Animations.php
3 months ago
BackButton.php
9 months ago
BeforeAfter.php
3 months ago
BlockPatterns.php
6 months ago
Carousel.php
2 months ago
ColumnsSameHeight.php
2 months ago
ContainerEdgeAlignment.php
1 week ago
CookieNotice.php
1 week ago
Counter.php
3 months ago
DownloadButton.php
2 months ago
Events.php
2 months ago
FaqSchema.php
2 months ago
FluidTypography.php
2 months ago
Gallery.php
1 week ago
GravityFormsInline.php
3 months ago
Headline.php
2 months ago
InsertPost.php
3 months ago
Maintenance.php
1 week ago
ProductCategories.php
2 months ago
ReadingProgress.php
9 months ago
ReadingTime.php
1 week ago
ScrollTop.php
1 week ago
ShapeAnimations.php
1 week ago
StackedImages.php
6 months ago
StickyColumn.php
2 months ago
SvgUpload.php
2 months ago
Testimonials.php
10 months ago
TextAnimation.php
3 months ago
UserText.php
2 months ago
CookieNotice.php
734 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Cookie Notice module for FrontBlocks. |
| 4 | * |
| 5 | * @package FrontBlocks |
| 6 | * @author Closemarketing |
| 7 | * @copyright 2026 Closemarketing |
| 8 | * @version 1.0 |
| 9 | */ |
| 10 | |
| 11 | namespace FrontBlocks\Frontend; |
| 12 | |
| 13 | defined( 'ABSPATH' ) || exit; |
| 14 | |
| 15 | /** |
| 16 | * CookieNotice class. |
| 17 | * |
| 18 | * Displays a configurable cookie consent banner on the frontend, conditionally |
| 19 | * loads Google Tag Manager / GA4 only after consent is granted, and keeps a |
| 20 | * lightweight aggregate acceptance-rate counter. |
| 21 | * |
| 22 | * The banner markup and assets are always enqueued/rendered (never gated by |
| 23 | * the visitor's own consent cookie) so that a full-page cache serves the exact |
| 24 | * same HTML to every visitor of a given URL. All consent-specific behavior — |
| 25 | * hiding the banner, and loading GTM/GA4 — happens client-side instead. |
| 26 | * |
| 27 | * @since 1.0.0 |
| 28 | */ |
| 29 | class CookieNotice { |
| 30 | |
| 31 | /** |
| 32 | * Option name storing the aggregate accepted counter. |
| 33 | * |
| 34 | * @var string |
| 35 | */ |
| 36 | const STATS_OPTION_ACCEPTED = 'frontblocks_cookie_notice_accepted_count'; |
| 37 | |
| 38 | /** |
| 39 | * Option name storing the aggregate rejected counter. |
| 40 | * |
| 41 | * @var string |
| 42 | */ |
| 43 | const STATS_OPTION_REJECTED = 'frontblocks_cookie_notice_rejected_count'; |
| 44 | |
| 45 | /** |
| 46 | * Nonce action used to protect the consent-logging AJAX endpoint. |
| 47 | * |
| 48 | * @var string |
| 49 | */ |
| 50 | const NONCE_ACTION = 'frbl_cookie_notice_nonce'; |
| 51 | |
| 52 | /** |
| 53 | * Constructor. |
| 54 | */ |
| 55 | public function __construct() { |
| 56 | if ( ! is_admin() && $this->is_enabled() ) { |
| 57 | // Priority 1: must run before any analytics/ads tag (Google Site Kit, |
| 58 | // a manually pasted GTM/gtag snippet, etc.) reads its consent defaults — |
| 59 | // Google Consent Mode only holds those tags back if 'default' is queued |
| 60 | // on the page's dataLayer before they call gtag('config', ...). |
| 61 | add_action( 'wp_head', array( $this, 'render_consent_mode_default' ), 1 ); |
| 62 | // Also early (wp_head, not wp_footer): for an already-accepted visitor |
| 63 | // this is what actually requests GTM/GA4, so it needs to run long |
| 64 | // before a slow page finishes loading — a footer-only bootstrap risks |
| 65 | // missing an early interaction or a request that never reaches the |
| 66 | // footer at all, silently undercounting analytics. |
| 67 | add_action( 'wp_head', array( $this, 'render_consent_bootstrap_script' ), 2 ); |
| 68 | add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); |
| 69 | add_action( 'wp_footer', array( $this, 'render_banner' ) ); |
| 70 | } |
| 71 | |
| 72 | // The endpoints must stay available for logged-out and logged-in visitors alike. |
| 73 | add_action( 'wp_ajax_frbl_log_cookie_consent', array( $this, 'log_consent_callback' ) ); |
| 74 | add_action( 'wp_ajax_nopriv_frbl_log_cookie_consent', array( $this, 'log_consent_callback' ) ); |
| 75 | add_action( 'wp_ajax_frbl_get_cookie_notice_config', array( $this, 'get_config_callback' ) ); |
| 76 | add_action( 'wp_ajax_nopriv_frbl_get_cookie_notice_config', array( $this, 'get_config_callback' ) ); |
| 77 | add_action( 'wp_ajax_frbl_get_cookie_notice_log_nonce', array( $this, 'get_log_nonce_callback' ) ); |
| 78 | add_action( 'wp_ajax_nopriv_frbl_get_cookie_notice_log_nonce', array( $this, 'get_log_nonce_callback' ) ); |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Check if the Cookie Notice module is enabled. |
| 83 | * |
| 84 | * @return bool |
| 85 | */ |
| 86 | private function is_enabled() { |
| 87 | $options = get_option( 'frontblocks_settings', array() ); |
| 88 | return (bool) ( $options['enable_cookie_notice'] ?? false ); |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Name of the cookie storing the visitor's consent decision. |
| 93 | * |
| 94 | * On multisite, COOKIEPATH alone can't isolate the root site from its |
| 95 | * subsites (the root site's path is '/', which every subsite path sits |
| 96 | * under), so the blog ID is folded into the cookie name itself instead. |
| 97 | * |
| 98 | * @return string |
| 99 | */ |
| 100 | private function get_cookie_name() { |
| 101 | if ( is_multisite() ) { |
| 102 | return 'frbl_cookie_consent_' . get_current_blog_id(); |
| 103 | } |
| 104 | |
| 105 | return 'frbl_cookie_consent'; |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Get the admin-ajax.php URL, forced onto the frontend's own scheme and host. |
| 110 | * |
| 111 | * The admin_url() function can point at a different scheme (e.g. |
| 112 | * FORCE_SSL_ADMIN on an http frontend) and even a different host (when |
| 113 | * WP_HOME and WP_SITEURL are configured separately) than the page that's |
| 114 | * about to fetch() it. 'credentials: same-origin' then omits the consent |
| 115 | * cookie, and the browser's CORS check blocks the response regardless — |
| 116 | * so only the admin-ajax.php path is taken from admin_url(); the scheme |
| 117 | * and host always come from the current request and home_url() instead, |
| 118 | * keeping the AJAX call same-origin with the frontend. |
| 119 | * |
| 120 | * @return string |
| 121 | */ |
| 122 | private function get_ajax_url() { |
| 123 | $home_parts = wp_parse_url( home_url() ); |
| 124 | $ajax_path = (string) wp_parse_url( admin_url( 'admin-ajax.php' ), PHP_URL_PATH ); |
| 125 | |
| 126 | $scheme = is_ssl() ? 'https' : 'http'; |
| 127 | $host = $home_parts['host'] ?? ''; |
| 128 | $port = isset( $home_parts['port'] ) ? ':' . $home_parts['port'] : ''; |
| 129 | |
| 130 | return $scheme . '://' . $host . $port . $ajax_path; |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Get the visitor's current consent decision from the cookie. |
| 135 | * |
| 136 | * @return string 'accepted', 'rejected', or '' when the visitor has not decided yet. |
| 137 | */ |
| 138 | private function get_consent() { |
| 139 | $cookie_name = $this->get_cookie_name(); |
| 140 | |
| 141 | if ( ! isset( $_COOKIE[ $cookie_name ] ) ) { |
| 142 | return ''; |
| 143 | } |
| 144 | |
| 145 | $consent = sanitize_key( wp_unslash( $_COOKIE[ $cookie_name ] ) ); |
| 146 | |
| 147 | return in_array( $consent, array( 'accepted', 'rejected' ), true ) ? $consent : ''; |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Check whether the current request is for the configured cookie policy page. |
| 152 | * |
| 153 | * Used to suppress the banner there so visitors can read the policy before |
| 154 | * deciding — otherwise, with the popup layout, the notice would immediately |
| 155 | * cover the policy content on that same page. |
| 156 | * |
| 157 | * @return bool |
| 158 | */ |
| 159 | private function is_policy_page() { |
| 160 | $options = get_option( 'frontblocks_settings', array() ); |
| 161 | $policy_page_id = (int) ( $options['cookie_notice_policy_page_id'] ?? 0 ); |
| 162 | |
| 163 | if ( ! $policy_page_id ) { |
| 164 | return false; |
| 165 | } |
| 166 | |
| 167 | return get_queried_object_id() === $policy_page_id; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Enqueue the frontend banner assets. |
| 172 | * |
| 173 | * Always enqueued, on every page including the configured policy page — |
| 174 | * never gated by the visitor's consent cookie, so a full-page cache can |
| 175 | * safely serve one cached HTML response to every visitor of a URL. The |
| 176 | * policy page only suppresses the visible banner markup (see |
| 177 | * render_banner()); it still needs these assets so an accepted visitor |
| 178 | * keeps getting tracking scripts there too. |
| 179 | * |
| 180 | * @return void |
| 181 | */ |
| 182 | public function enqueue_assets() { |
| 183 | $options = get_option( 'frontblocks_settings', array() ); |
| 184 | $days = (int) ( $options['cookie_notice_expiration_days'] ?? 365 ); |
| 185 | |
| 186 | wp_enqueue_style( |
| 187 | 'frontblocks-cookie-notice', |
| 188 | FRBL_PLUGIN_URL . 'assets/cookie-notice/frontblocks-cookie-notice.css', |
| 189 | array(), |
| 190 | FRBL_VERSION |
| 191 | ); |
| 192 | |
| 193 | wp_enqueue_script( |
| 194 | 'frontblocks-cookie-notice', |
| 195 | FRBL_PLUGIN_URL . 'assets/cookie-notice/frontblocks-cookie-notice.js', |
| 196 | array(), |
| 197 | FRBL_VERSION, |
| 198 | true |
| 199 | ); |
| 200 | |
| 201 | wp_localize_script( |
| 202 | 'frontblocks-cookie-notice', |
| 203 | 'frblCookieNotice', |
| 204 | array( |
| 205 | 'ajaxUrl' => $this->get_ajax_url(), |
| 206 | 'cookieName' => $this->get_cookie_name(), |
| 207 | 'cookiePath' => defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/', |
| 208 | 'expirationDays' => $days > 0 ? $days : 365, |
| 209 | ) |
| 210 | ); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Render the visible consent banner markup in the footer. |
| 215 | * |
| 216 | * Always rendered the same way for every visitor of a given URL — never |
| 217 | * gated by the visitor's own consent cookie — so a full-page cache stays |
| 218 | * safe; render_consent_bootstrap_script() (hooked much earlier, on |
| 219 | * wp_head) hides it immediately client-side when a decision cookie already |
| 220 | * exists, so a returning visitor never sees it flash. |
| 221 | * |
| 222 | * Suppressed on the configured cookie policy page so a popup layout can't |
| 223 | * block that page's own content — the bootstrap script's tracking pickup |
| 224 | * still runs there regardless, since it's on wp_head, not this method. |
| 225 | * |
| 226 | * @return void |
| 227 | */ |
| 228 | public function render_banner() { |
| 229 | if ( ! $this->is_policy_page() ) { |
| 230 | $this->render_banner_markup(); |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * Render the visible banner markup. |
| 236 | * |
| 237 | * @return void |
| 238 | */ |
| 239 | private function render_banner_markup() { |
| 240 | $options = get_option( 'frontblocks_settings', array() ); |
| 241 | |
| 242 | $message = trim( (string) ( $options['cookie_notice_message'] ?? '' ) ); |
| 243 | $accept_label = trim( (string) ( $options['cookie_notice_accept_label'] ?? '' ) ); |
| 244 | $reject_label = trim( (string) ( $options['cookie_notice_reject_label'] ?? '' ) ); |
| 245 | $policy_page_id = (int) ( $options['cookie_notice_policy_page_id'] ?? 0 ); |
| 246 | $policy_url = $policy_page_id ? (string) get_permalink( $policy_page_id ) : ''; |
| 247 | $layout = (string) ( $options['cookie_notice_layout'] ?? 'bar' ); |
| 248 | $position = (string) ( $options['cookie_notice_position'] ?? 'bottom-right' ); |
| 249 | $color = (string) ( $options['cookie_notice_color'] ?? '#687df9' ); |
| 250 | |
| 251 | if ( '' === $message ) { |
| 252 | $message = __( 'We use cookies to improve your experience on our website. By browsing this website, you agree to our use of cookies.', 'frontblocks' ); |
| 253 | } |
| 254 | |
| 255 | if ( '' === $accept_label ) { |
| 256 | $accept_label = __( 'Accept', 'frontblocks' ); |
| 257 | } |
| 258 | |
| 259 | if ( '' === $reject_label ) { |
| 260 | $reject_label = __( 'Reject', 'frontblocks' ); |
| 261 | } |
| 262 | |
| 263 | if ( ! in_array( $layout, array( 'bar', 'box', 'popup' ), true ) ) { |
| 264 | $layout = 'bar'; |
| 265 | } |
| 266 | |
| 267 | $classes = array( 'frbl-cookie-notice', 'frbl-cookie-notice--' . $layout ); |
| 268 | |
| 269 | if ( 'box' === $layout ) { |
| 270 | $classes[] = 'bottom-left' === $position ? 'frbl-cookie-notice--left' : 'frbl-cookie-notice--right'; |
| 271 | } |
| 272 | |
| 273 | $is_modal = 'popup' === $layout; |
| 274 | $accent_text = $this->get_readable_text_color( $color ); |
| 275 | $accent_link = $this->get_readable_on_white_color( $color ); |
| 276 | $style = sprintf( |
| 277 | '--frbl-cookie-accent: %1$s; --frbl-cookie-accent-contrast: %2$s; --frbl-cookie-accent-on-light: %3$s;', |
| 278 | esc_attr( $color ), |
| 279 | esc_attr( $accent_text ), |
| 280 | esc_attr( $accent_link ) |
| 281 | ); |
| 282 | ?> |
| 283 | <div |
| 284 | id="frbl-cookie-notice" |
| 285 | class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>" |
| 286 | style="<?php echo esc_attr( $style ); ?>" |
| 287 | role="<?php echo $is_modal ? 'dialog' : 'region'; ?>" |
| 288 | <?php echo $is_modal ? 'aria-modal="true"' : ''; ?> |
| 289 | aria-label="<?php echo esc_attr__( 'Cookie consent', 'frontblocks' ); ?>" |
| 290 | > |
| 291 | <div class="frbl-cookie-notice__panel"> |
| 292 | <span class="frbl-cookie-notice__icon" aria-hidden="true"> |
| 293 | <?php echo $this->get_cookie_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static inline SVG, no dynamic data. ?> |
| 294 | </span> |
| 295 | <p class="frbl-cookie-notice__message"> |
| 296 | <?php |
| 297 | echo esc_html( $message ); |
| 298 | |
| 299 | if ( $policy_url ) { |
| 300 | echo ' <a href="' . esc_url( $policy_url ) . '" class="frbl-cookie-notice__link" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Learn more', 'frontblocks' ) . '</a>'; |
| 301 | } |
| 302 | ?> |
| 303 | </p> |
| 304 | <div class="frbl-cookie-notice__actions"> |
| 305 | <button |
| 306 | type="button" |
| 307 | class="frbl-cookie-notice__button frbl-cookie-notice__button--reject" |
| 308 | data-frbl-cookie-action="reject" |
| 309 | > |
| 310 | <?php echo esc_html( $reject_label ); ?> |
| 311 | </button> |
| 312 | <button |
| 313 | type="button" |
| 314 | class="frbl-cookie-notice__button frbl-cookie-notice__button--accept" |
| 315 | data-frbl-cookie-action="accept" |
| 316 | > |
| 317 | <?php echo esc_html( $accept_label ); ?> |
| 318 | </button> |
| 319 | </div> |
| 320 | </div> |
| 321 | </div> |
| 322 | <?php |
| 323 | if ( $is_modal ) { |
| 324 | ?> |
| 325 | <noscript> |
| 326 | <style> |
| 327 | .frbl-cookie-notice--popup { |
| 328 | position: static; |
| 329 | display: block; |
| 330 | overflow: visible; |
| 331 | background-color: transparent; |
| 332 | padding: 0; |
| 333 | } |
| 334 | .frbl-cookie-notice--popup .frbl-cookie-notice__panel { |
| 335 | max-width: none; |
| 336 | box-shadow: none; |
| 337 | } |
| 338 | </style> |
| 339 | </noscript> |
| 340 | <?php |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | /** |
| 345 | * Print the Google Consent Mode default state, before any other script. |
| 346 | * |
| 347 | * This is what actually blocks analytics/ads tags that read Consent Mode |
| 348 | * (Google Site Kit's own gtag snippet, a manually pasted GTM container, |
| 349 | * etc.) from firing before the visitor decides — the banner markup and its |
| 350 | * own accept/reject buttons only control what *this plugin* loads via the |
| 351 | * GTM/GA4 ID fields below; they have no effect on tags any other plugin |
| 352 | * injects independently. Consent Mode is the standard way to reach those |
| 353 | * too, because gtag() queues commands on window.dataLayer regardless of |
| 354 | * which plugin's script eventually processes them — as long as this runs |
| 355 | * first, it doesn't matter which plugin's gtag.js loads second. |
| 356 | * |
| 357 | * Reads the decision straight from the request cookie (not the PHP-side |
| 358 | * get_consent(), which is the same lookup) so a returning visitor's already |
| 359 | * granted/denied state is reflected immediately, with no flash of a |
| 360 | * default-denied state while JS boots. |
| 361 | * |
| 362 | * @return void |
| 363 | */ |
| 364 | public function render_consent_mode_default() { |
| 365 | $consent = $this->get_consent(); |
| 366 | $granted = 'accepted' === $consent ? 'granted' : 'denied'; |
| 367 | ?> |
| 368 | <script> |
| 369 | window.dataLayer = window.dataLayer || []; |
| 370 | function gtag(){ window.dataLayer.push( arguments ); } |
| 371 | gtag( 'consent', 'default', { |
| 372 | 'ad_storage': '<?php echo esc_js( $granted ); ?>', |
| 373 | 'ad_user_data': '<?php echo esc_js( $granted ); ?>', |
| 374 | 'ad_personalization': '<?php echo esc_js( $granted ); ?>', |
| 375 | 'analytics_storage': '<?php echo esc_js( $granted ); ?>' |
| 376 | } ); |
| 377 | </script> |
| 378 | <?php |
| 379 | } |
| 380 | |
| 381 | /** |
| 382 | * Print the inline bootstrap script: hides the banner immediately when a |
| 383 | * decision cookie already exists, and — for an accepted visitor — fetches |
| 384 | * and injects the tracking scripts. Hooked on wp_head (not wp_footer, |
| 385 | * where the banner markup itself renders) precisely so an already-accepted |
| 386 | * visitor's tracking request fires as early as possible, on every page |
| 387 | * including the policy page (where render_banner_markup() is skipped but |
| 388 | * this still runs). |
| 389 | * |
| 390 | * This is an optimization, not the only implementation: it sets |
| 391 | * window.frblCookieNoticeBootstrapped so the registered |
| 392 | * frontblocks-cookie-notice.js file (enqueued in enqueue_assets()) knows |
| 393 | * this already ran and skips redoing it. On a site whose Content Security |
| 394 | * Policy blocks unnonced inline scripts, this one is simply never executed |
| 395 | * by the browser, and that registered script performs the same bootstrap |
| 396 | * itself instead — banner hiding and tracking still work there, just |
| 397 | * without the no-flash guarantee this inline copy provides. |
| 398 | * |
| 399 | * @return void |
| 400 | */ |
| 401 | public function render_consent_bootstrap_script() { |
| 402 | $cookie_name = $this->get_cookie_name(); |
| 403 | ?> |
| 404 | <script> |
| 405 | ( function () { |
| 406 | // This runs on wp_head, before '#frbl-cookie-notice' exists in the DOM |
| 407 | // (it's printed later, in wp_footer) — so, unlike the registered |
| 408 | // frontblocks-cookie-notice.js file, it can only handle the tracking |
| 409 | // side of an already-decided visitor, not hiding the banner itself. |
| 410 | var cookieMatch = document.cookie.match( new RegExp( '(?:^|; )<?php echo esc_js( $cookie_name ); ?>=([^;]*)' ) ); |
| 411 | var consent = ''; |
| 412 | |
| 413 | if ( cookieMatch ) { |
| 414 | try { |
| 415 | consent = decodeURIComponent( cookieMatch[ 1 ] ); |
| 416 | } catch ( e ) { |
| 417 | // Malformed percent-encoding: treat it the same as no cookie at all. |
| 418 | consent = ''; |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | window.frblCookieNoticeInject = window.frblCookieNoticeInject || function ( gtmId, ga4Id ) { |
| 423 | if ( gtmId ) { |
| 424 | window.dataLayer = window.dataLayer || []; |
| 425 | window.dataLayer.push( { 'gtm.start': new Date().getTime(), event: 'gtm.js' } ); |
| 426 | |
| 427 | var gtmScript = document.createElement( 'script' ); |
| 428 | gtmScript.async = true; |
| 429 | gtmScript.src = 'https://www.googletagmanager.com/gtm.js?id=' + encodeURIComponent( gtmId ); |
| 430 | document.head.appendChild( gtmScript ); |
| 431 | } |
| 432 | |
| 433 | if ( ga4Id ) { |
| 434 | var ga4Script = document.createElement( 'script' ); |
| 435 | ga4Script.async = true; |
| 436 | ga4Script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent( ga4Id ); |
| 437 | document.head.appendChild( ga4Script ); |
| 438 | |
| 439 | window.dataLayer = window.dataLayer || []; |
| 440 | window.gtag = window.gtag || function () { |
| 441 | window.dataLayer.push( arguments ); |
| 442 | }; |
| 443 | window.gtag( 'js', new Date() ); |
| 444 | window.gtag( 'config', ga4Id ); |
| 445 | } |
| 446 | }; |
| 447 | |
| 448 | if ( 'accepted' === consent ) { |
| 449 | var formData = new FormData(); |
| 450 | formData.append( 'action', 'frbl_get_cookie_notice_config' ); |
| 451 | |
| 452 | fetch( '<?php echo esc_url( $this->get_ajax_url() ); ?>', { |
| 453 | method: 'POST', |
| 454 | credentials: 'same-origin', |
| 455 | body: formData |
| 456 | } ) |
| 457 | .then( function ( response ) { return response.json(); } ) |
| 458 | .then( function ( response ) { |
| 459 | if ( response && response.success && response.data ) { |
| 460 | window.frblCookieNoticeInject( response.data.gtmId, response.data.ga4Id ); |
| 461 | } |
| 462 | } ) |
| 463 | .catch( function () {} ); |
| 464 | } |
| 465 | |
| 466 | window.frblCookieNoticeBootstrapped = true; |
| 467 | } )(); |
| 468 | </script> |
| 469 | <?php |
| 470 | } |
| 471 | |
| 472 | /** |
| 473 | * Inline SVG for the popup layout's icon badge. |
| 474 | * |
| 475 | * The badge's circular background comes from CSS (using the configured |
| 476 | * accent color), so this only needs the glyph itself, colored via |
| 477 | * `fill="currentColor"`. Public so the admin settings preview can reuse |
| 478 | * the exact same markup shown on the frontend. |
| 479 | * |
| 480 | * @return string Raw SVG markup. |
| 481 | */ |
| 482 | public static function get_cookie_icon_svg() { |
| 483 | return '<svg width="242" height="242" viewBox="0 0 242 242" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M120.931 242C120.045 242 119.159 241.991 118.268 241.973C85.0089 241.264 54.2629 227.347 31.7023 202.787C-10.324 157.038 -10.4104 85.2933 31.5114 39.4584C59.026 9.38964 99.4661 -4.79939 139.638 1.44977C144.565 2.21332 148.137 6.66272 147.764 11.5712C147.155 19.761 150.128 27.7827 155.918 33.5774C158.345 35.9998 161.126 37.9268 164.171 39.3039C167.407 40.7628 169.58 43.7487 169.989 47.2892C170.689 53.6065 173.47 59.3467 178.024 63.9052C182.487 68.3637 188.404 71.2178 194.667 71.9405C198.185 72.345 201.157 74.5174 202.621 77.7488C204.002 80.812 205.92 83.5753 208.311 85.9705C214.11 91.7606 222.2 94.7057 230.317 94.1285C235.371 93.7649 239.67 97.3326 240.443 102.259C246.37 140.331 233.662 179.317 206.438 206.55C183.514 229.474 153.236 242 120.931 242ZM120.559 9.43963C89.4356 9.43963 59.7077 22.4243 38.3832 45.7394C-0.311723 88.043 -0.23447 154.266 38.5559 196.497C59.385 219.167 87.7631 232.01 118.468 232.665C149.346 233.374 178.124 221.703 199.857 199.969C224.99 174.827 236.725 138.841 231.244 103.695C220.055 104.145 209.438 100.26 201.73 92.5515C198.539 89.361 195.985 85.6705 194.14 81.5847C185.259 80.2258 177.397 76.4263 171.443 70.4907C165.462 64.5051 161.662 56.638 160.735 48.3345C156.272 45.9485 152.573 43.3852 149.346 40.1629C141.629 32.4457 137.675 21.7744 138.475 10.8758C132.484 9.91232 126.494 9.43963 120.559 9.43963ZM169.68 189.671C158.799 189.671 149.946 180.817 149.946 169.937C149.946 159.047 158.799 150.194 169.68 150.194C180.56 150.194 189.413 159.047 189.413 169.937C189.413 180.817 180.56 189.671 169.68 189.671ZM169.68 159.502C163.935 159.502 159.254 164.183 159.254 169.937C159.254 175.681 163.935 180.363 169.68 180.363C175.424 180.363 180.105 175.681 180.105 169.937C180.105 164.183 175.424 159.502 169.68 159.502ZM80.9776 179.817C66.2977 179.817 54.3539 167.873 54.3539 153.193C54.3539 138.514 66.2977 126.57 80.9776 126.57C95.6621 126.57 107.606 138.514 107.606 153.193C107.606 167.873 95.662 179.817 80.9776 179.817ZM80.9776 135.878C71.4289 135.878 63.6617 143.649 63.6617 153.193C63.6617 162.738 71.4289 170.509 80.9776 170.509C90.5264 170.509 98.2981 162.738 98.2981 153.193C98.2981 143.649 90.5263 135.878 80.9776 135.878ZM140.447 116.985C129.667 116.985 120.895 108.213 120.895 97.4326C120.895 86.6523 129.667 77.8807 140.447 77.8807C151.227 77.8807 159.999 86.6523 159.999 97.4326C159.999 108.213 151.227 116.985 140.447 116.985ZM140.447 87.1885C134.802 87.1885 130.203 91.7834 130.203 97.4326C130.203 103.082 134.802 107.677 140.447 107.677C146.092 107.677 150.691 103.082 150.691 97.4326C150.691 91.7833 146.092 87.1885 140.447 87.1885ZM68.7701 87.7021C59.7077 87.7021 52.3314 80.3258 52.3314 71.2588C52.3314 62.1963 59.7077 54.82 68.7701 54.82C77.8371 54.82 85.2134 62.1963 85.2134 71.2588C85.2134 80.3258 77.8371 87.7021 68.7701 87.7021ZM68.7701 64.1279C64.8388 64.1279 61.6393 67.3275 61.6393 71.2588C61.6393 75.1946 64.8388 78.3942 68.7701 78.3942C72.706 78.3942 75.9055 75.1946 75.9055 71.2588C75.9055 67.3275 72.706 64.1279 68.7701 64.1279Z" fill="currentColor"/></svg>'; |
| 484 | } |
| 485 | |
| 486 | /** |
| 487 | * Pick black or white text, whichever has the higher actual WCAG contrast |
| 488 | * ratio against a background color (not just whichever "looks" darker/lighter). |
| 489 | * |
| 490 | * Public static — pure color math with no instance state, also used by the |
| 491 | * admin settings preview to show the same contrast the frontend actually renders. |
| 492 | * |
| 493 | * @param string $hex_color Background color, e.g. '#687df9'. |
| 494 | * @return string '#ffffff' or '#000000'. |
| 495 | */ |
| 496 | public static function get_readable_text_color( $hex_color ) { |
| 497 | $bg_luminance = self::get_relative_luminance( self::hex_to_rgb( $hex_color ) ); |
| 498 | |
| 499 | $white_contrast = self::get_contrast_ratio( $bg_luminance, 1 ); |
| 500 | $black_contrast = self::get_contrast_ratio( $bg_luminance, 0 ); |
| 501 | |
| 502 | // Pure black, not a lighter dark neutral: whichever of black/white has |
| 503 | // the lower contrast against any background is guaranteed to still |
| 504 | // reach ~4.58:1 at that background's worst-case luminance (~0.179), |
| 505 | // clearing the 4.5:1 button-text requirement for every allowed accent. |
| 506 | return $white_contrast >= $black_contrast ? '#ffffff' : '#000000'; |
| 507 | } |
| 508 | |
| 509 | /** |
| 510 | * Ensure a color stays legible when used as text on the banner's white panel — |
| 511 | * accent colors that don't reach a 4.5:1 contrast ratio against white fall |
| 512 | * back to a dark neutral instead. |
| 513 | * |
| 514 | * Public static — pure color math with no instance state, also used by the |
| 515 | * admin settings preview to show the same contrast the frontend actually renders. |
| 516 | * |
| 517 | * @param string $hex_color Requested accent color, e.g. '#687df9'. |
| 518 | * @return string A color safe to use as text on a white background. |
| 519 | */ |
| 520 | public static function get_readable_on_white_color( $hex_color ) { |
| 521 | $luminance = self::get_relative_luminance( self::hex_to_rgb( $hex_color ) ); |
| 522 | $contrast = self::get_contrast_ratio( $luminance, 1 ); |
| 523 | |
| 524 | return $contrast >= 4.5 ? $hex_color : '#111827'; |
| 525 | } |
| 526 | |
| 527 | /** |
| 528 | * WCAG relative luminance of an sRGB color. |
| 529 | * |
| 530 | * @param int[] $rgb Three-item [r, g, b] array, each 0-255. |
| 531 | * @return float Relative luminance between 0 (black) and 1 (white). |
| 532 | */ |
| 533 | private static function get_relative_luminance( $rgb ) { |
| 534 | $channels = array(); |
| 535 | |
| 536 | foreach ( $rgb as $channel ) { |
| 537 | $channel = $channel / 255; |
| 538 | $channels[] = $channel <= 0.03928 ? $channel / 12.92 : ( ( $channel + 0.055 ) / 1.055 ) ** 2.4; |
| 539 | } |
| 540 | |
| 541 | return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; |
| 542 | } |
| 543 | |
| 544 | /** |
| 545 | * WCAG contrast ratio between two relative luminances. |
| 546 | * |
| 547 | * @param float $luminance_a First relative luminance (0-1). |
| 548 | * @param float $luminance_b Second relative luminance (0-1). |
| 549 | * @return float Contrast ratio, from 1 (no contrast) to 21 (black on white). |
| 550 | */ |
| 551 | private static function get_contrast_ratio( $luminance_a, $luminance_b ) { |
| 552 | $lighter = max( $luminance_a, $luminance_b ); |
| 553 | $darker = min( $luminance_a, $luminance_b ); |
| 554 | |
| 555 | return ( $lighter + 0.05 ) / ( $darker + 0.05 ); |
| 556 | } |
| 557 | |
| 558 | /** |
| 559 | * Convert a hex color (3 or 6 digits, with or without '#') to an [r, g, b] triple. |
| 560 | * |
| 561 | * @param string $hex_color Hex color string. |
| 562 | * @return int[] Three-item array of 0-255 RGB values; black if the input is invalid. |
| 563 | */ |
| 564 | private static function hex_to_rgb( $hex_color ) { |
| 565 | $hex = ltrim( (string) $hex_color, '#' ); |
| 566 | |
| 567 | if ( 3 === strlen( $hex ) ) { |
| 568 | $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; |
| 569 | } |
| 570 | |
| 571 | if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) { |
| 572 | return array( 0, 0, 0 ); |
| 573 | } |
| 574 | |
| 575 | return array( |
| 576 | hexdec( substr( $hex, 0, 2 ) ), |
| 577 | hexdec( substr( $hex, 2, 2 ) ), |
| 578 | hexdec( substr( $hex, 4, 2 ) ), |
| 579 | ); |
| 580 | } |
| 581 | |
| 582 | /** |
| 583 | * AJAX callback: returns the GTM/GA4 identifiers, but only when the requesting |
| 584 | * browser's own consent cookie says 'accepted'. |
| 585 | * |
| 586 | * Deliberately unauthenticated: it's read-only, never touches the aggregate |
| 587 | * counters, and only ever echoes back non-secret IDs that are already public |
| 588 | * once GTM/GA4 loads. A nonce would have to be embedded in the cache-neutral |
| 589 | * HTML this module renders, and would go stale on any page a full-page cache |
| 590 | * keeps for longer than a WordPress nonce's lifetime — breaking tracking for |
| 591 | * every visitor of that cached page until it expires from the cache. |
| 592 | * |
| 593 | * @return void |
| 594 | */ |
| 595 | public function get_config_callback() { |
| 596 | $response = array( |
| 597 | 'gtmId' => '', |
| 598 | 'ga4Id' => '', |
| 599 | ); |
| 600 | |
| 601 | if ( $this->is_enabled() && 'accepted' === $this->get_consent() ) { |
| 602 | $options = get_option( 'frontblocks_settings', array() ); |
| 603 | $response['gtmId'] = $this->sanitize_gtm_id( $options['cookie_notice_gtm_id'] ?? '' ); |
| 604 | $response['ga4Id'] = $this->sanitize_ga4_id( $options['cookie_notice_ga4_id'] ?? '' ); |
| 605 | } |
| 606 | |
| 607 | wp_send_json_success( $response ); |
| 608 | } |
| 609 | |
| 610 | /** |
| 611 | * AJAX callback: returns a fresh nonce for the logging endpoint. |
| 612 | * |
| 613 | * Fetched live at the moment a visitor actually decides, instead of being |
| 614 | * embedded in the cache-neutral HTML this module renders — a nonce baked |
| 615 | * into that HTML would go stale on any page a full-page cache keeps around |
| 616 | * longer than a WordPress nonce's lifetime, silently dropping every decision |
| 617 | * logged from that cached response. Generating a nonce isn't a sensitive |
| 618 | * action in itself (the same thing any login form does for a logged-out |
| 619 | * visitor), so this endpoint needs no authentication of its own. |
| 620 | * |
| 621 | * @return void |
| 622 | */ |
| 623 | public function get_log_nonce_callback() { |
| 624 | wp_send_json_success( array( 'nonce' => wp_create_nonce( self::NONCE_ACTION ) ) ); |
| 625 | } |
| 626 | |
| 627 | /** |
| 628 | * AJAX callback: logs the visitor's decision in the aggregate accepted/rejected counters. |
| 629 | * |
| 630 | * This is a best-effort, lightweight aggregate stat, not a precise per-visitor |
| 631 | * metering system — the module explicitly renders cache-neutral HTML (see |
| 632 | * render_banner()), so there is no page-embedded value this endpoint could use |
| 633 | * to deduplicate a replayed request without also breaking under a full-page |
| 634 | * cache, the same way a one-time token would. The nonce itself is fetched |
| 635 | * fresh via get_log_nonce_callback() right before this call, so it stays |
| 636 | * valid regardless of how long a cache keeps the page that triggered it. |
| 637 | * |
| 638 | * @return void |
| 639 | */ |
| 640 | public function log_consent_callback() { |
| 641 | if ( ! $this->is_enabled() ) { |
| 642 | wp_send_json_error( array( 'message' => __( 'Cookie Notice is disabled.', 'frontblocks' ) ), 403 ); |
| 643 | } |
| 644 | |
| 645 | $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; |
| 646 | |
| 647 | if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { |
| 648 | wp_send_json_error( array( 'message' => __( 'Security check failed.', 'frontblocks' ) ), 403 ); |
| 649 | } |
| 650 | |
| 651 | $decision = isset( $_POST['decision'] ) ? sanitize_key( wp_unslash( $_POST['decision'] ) ) : ''; |
| 652 | |
| 653 | if ( ! in_array( $decision, array( 'accepted', 'rejected' ), true ) ) { |
| 654 | wp_send_json_error( array( 'message' => __( 'Invalid decision.', 'frontblocks' ) ), 400 ); |
| 655 | } |
| 656 | |
| 657 | $this->maybe_increment_stat( $decision ); |
| 658 | |
| 659 | wp_send_json_success(); |
| 660 | } |
| 661 | |
| 662 | /** |
| 663 | * Increment the aggregate accepted/rejected counter for a decision. |
| 664 | * |
| 665 | * Logged-in administrators are excluded so testing the banner doesn't skew stats. |
| 666 | * |
| 667 | * @param string $decision 'accepted' or 'rejected'. |
| 668 | * @return void |
| 669 | */ |
| 670 | private function maybe_increment_stat( $decision ) { |
| 671 | if ( current_user_can( 'manage_options' ) ) { |
| 672 | return; |
| 673 | } |
| 674 | |
| 675 | $option_name = 'accepted' === $decision ? self::STATS_OPTION_ACCEPTED : self::STATS_OPTION_REJECTED; |
| 676 | |
| 677 | $this->increment_option_atomically( $option_name ); |
| 678 | } |
| 679 | |
| 680 | /** |
| 681 | * Increment an integer option by 1 directly in the database. |
| 682 | * |
| 683 | * A plain get_option()/update_option() round trip races under concurrent |
| 684 | * requests — two visitors deciding at the same moment can both read the same |
| 685 | * value and one increment gets overwritten. A single UPDATE ... SET value = value + 1 |
| 686 | * lets the database serialize concurrent increments instead. |
| 687 | * |
| 688 | * @param string $option_name Option name storing a plain integer. |
| 689 | * @return void |
| 690 | */ |
| 691 | private function increment_option_atomically( $option_name ) { |
| 692 | global $wpdb; |
| 693 | |
| 694 | $sql = $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s", $option_name ); |
| 695 | |
| 696 | $updated = $wpdb->query( $sql ); |
| 697 | |
| 698 | if ( ! $updated ) { |
| 699 | // First time this counter is created. add_option() returns false if another |
| 700 | // request created the row first — in that case fall back to the atomic UPDATE |
| 701 | // so this increment isn't silently dropped. |
| 702 | if ( ! add_option( $option_name, 1, '', 'no' ) ) { |
| 703 | $wpdb->query( $sql ); |
| 704 | } |
| 705 | } |
| 706 | |
| 707 | wp_cache_delete( $option_name, 'options' ); |
| 708 | } |
| 709 | |
| 710 | /** |
| 711 | * Validate a Google Tag Manager container ID (e.g. GTM-XXXXXXX). |
| 712 | * |
| 713 | * @param string $value Raw value. |
| 714 | * @return string Sanitized ID, or an empty string when it doesn't match the expected format. |
| 715 | */ |
| 716 | private function sanitize_gtm_id( $value ) { |
| 717 | $value = strtoupper( trim( (string) $value ) ); |
| 718 | |
| 719 | return preg_match( '/^GTM-[A-Z0-9]+$/', $value ) ? $value : ''; |
| 720 | } |
| 721 | |
| 722 | /** |
| 723 | * Validate a GA4 Measurement ID (e.g. G-XXXXXXXXXX). |
| 724 | * |
| 725 | * @param string $value Raw value. |
| 726 | * @return string Sanitized ID, or an empty string when it doesn't match the expected format. |
| 727 | */ |
| 728 | private function sanitize_ga4_id( $value ) { |
| 729 | $value = strtoupper( trim( (string) $value ) ); |
| 730 | |
| 731 | return preg_match( '/^G-[A-Z0-9]+$/', $value ) ? $value : ''; |
| 732 | } |
| 733 | } |
| 734 |