PluginProbe ʕ •ᴥ•ʔ
FrontBlocks for Gutenberg/GeneratePress / 1.5.1
FrontBlocks for Gutenberg/GeneratePress v1.5.1
1.5.2 1.5.1 1.4.0 1.5.0 trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 ci-artifacts
frontblocks / includes / Frontend / CookieNotice.php
frontblocks / includes / Frontend Last commit date
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
813 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 // Filterable so an add-on that relabels the binary choice as "accept all" /
257 // "reject non-essential" (once it introduces per-category consent) doesn't
258 // need the site admin to retype the button copy themselves.
259 $accept_label = apply_filters( 'frbl_cookie_notice_default_accept_label', __( 'Accept', 'frontblocks' ) );
260 }
261
262 if ( '' === $reject_label ) {
263 $reject_label = apply_filters( 'frbl_cookie_notice_default_reject_label', __( 'Reject', 'frontblocks' ) );
264 }
265
266 if ( ! in_array( $layout, array( 'bar', 'box', 'popup' ), true ) ) {
267 $layout = 'bar';
268 }
269
270 $classes = array( 'frbl-cookie-notice', 'frbl-cookie-notice--' . $layout );
271
272 if ( 'box' === $layout ) {
273 $classes[] = 'bottom-left' === $position ? 'frbl-cookie-notice--left' : 'frbl-cookie-notice--right';
274 }
275
276 $is_modal = 'popup' === $layout;
277 $accent_text = $this->get_readable_text_color( $color );
278 $accent_link = $this->get_readable_on_white_color( $color );
279 $style = sprintf(
280 '--frbl-cookie-accent: %1$s; --frbl-cookie-accent-contrast: %2$s; --frbl-cookie-accent-on-light: %3$s;',
281 esc_attr( $color ),
282 esc_attr( $accent_text ),
283 esc_attr( $accent_link )
284 );
285 ?>
286 <div
287 id="frbl-cookie-notice"
288 class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
289 style="<?php echo esc_attr( $style ); ?>"
290 role="<?php echo $is_modal ? 'dialog' : 'region'; ?>"
291 <?php echo $is_modal ? 'aria-modal="true"' : ''; ?>
292 aria-label="<?php echo esc_attr__( 'Cookie consent', 'frontblocks' ); ?>"
293 >
294 <div class="frbl-cookie-notice__panel">
295 <span class="frbl-cookie-notice__icon" aria-hidden="true">
296 <?php echo $this->get_cookie_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static inline SVG, no dynamic data. ?>
297 </span>
298 <p class="frbl-cookie-notice__message">
299 <?php
300 echo esc_html( $message );
301
302 if ( $policy_url ) {
303 echo ' <a href="' . esc_url( $policy_url ) . '" class="frbl-cookie-notice__link" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Learn more', 'frontblocks' ) . '</a>';
304 }
305 ?>
306 </p>
307 <div class="frbl-cookie-notice__actions">
308 <?php
309 /**
310 * Fires right before the reject/accept buttons, inside the same actions
311 * row. Lets an add-on (e.g. per-category consent) insert its own button —
312 * a "Customize" trigger — without forking this markup.
313 *
314 * @param array $options The 'frontblocks_settings' option array.
315 */
316 do_action( 'frbl_cookie_notice_before_actions', $options );
317 ?>
318 <button
319 type="button"
320 class="frbl-cookie-notice__button frbl-cookie-notice__button--reject"
321 data-frbl-cookie-action="reject"
322 >
323 <?php echo esc_html( $reject_label ); ?>
324 </button>
325 <button
326 type="button"
327 class="frbl-cookie-notice__button frbl-cookie-notice__button--accept"
328 data-frbl-cookie-action="accept"
329 >
330 <?php echo esc_html( $accept_label ); ?>
331 </button>
332 </div>
333 </div>
334 </div>
335 <?php
336 if ( $is_modal ) {
337 ?>
338 <noscript>
339 <style>
340 .frbl-cookie-notice--popup {
341 position: static;
342 display: block;
343 overflow: visible;
344 background-color: transparent;
345 padding: 0;
346 }
347 .frbl-cookie-notice--popup .frbl-cookie-notice__panel {
348 max-width: none;
349 box-shadow: none;
350 }
351 </style>
352 </noscript>
353 <?php
354 }
355
356 /**
357 * Fires right after the banner markup, still inside the same wp_footer
358 * output. Lets an add-on print extra markup that belongs to the same
359 * consent flow — e.g. a "customize categories" dialog — right next to it.
360 *
361 * @param array $options The 'frontblocks_settings' option array.
362 */
363 do_action( 'frbl_cookie_notice_after_banner', $options );
364 }
365
366 /**
367 * Print the Google Consent Mode default state, before any other script.
368 *
369 * This is what actually blocks analytics/ads tags that read Consent Mode
370 * (Google Site Kit's own gtag snippet, a manually pasted GTM container,
371 * etc.) from firing before the visitor decides — the banner markup and its
372 * own accept/reject buttons only control what *this plugin* loads via the
373 * GTM/GA4 ID fields below; they have no effect on tags any other plugin
374 * injects independently. Consent Mode is the standard way to reach those
375 * too, because gtag() queues commands on window.dataLayer regardless of
376 * which plugin's script eventually processes them — as long as this runs
377 * first, it doesn't matter which plugin's gtag.js loads second.
378 *
379 * Only the cookie *name* (a fixed string) is embedded server-side — the
380 * decision itself is read from document.cookie client-side, in the browser,
381 * exactly like render_consent_bootstrap_script() below. This keeps the
382 * printed HTML identical for every visitor of a given URL, so a full-page
383 * cache stays safe; an earlier version of this method embedded the
384 * granted/denied value directly, which a full-page cache could then have
385 * served to the wrong visitor.
386 *
387 * @return void
388 */
389 public function render_consent_mode_default() {
390 $cookie_name = $this->get_cookie_name();
391 ?>
392 <script>
393 window.dataLayer = window.dataLayer || [];
394 function gtag(){ window.dataLayer.push( arguments ); }
395 ( function () {
396 var cookieMatch = document.cookie.match( new RegExp( '(?:^|; )<?php echo esc_js( $cookie_name ); ?>=([^;]*)' ) );
397 var consent = '';
398
399 if ( cookieMatch ) {
400 try {
401 consent = decodeURIComponent( cookieMatch[ 1 ] );
402 } catch ( e ) {
403 // Malformed percent-encoding: treat it the same as no cookie at all,
404 // same as the bootstrap script below — a thrown, uncaught error here
405 // would abort before gtag('consent', 'default', ...) ever runs.
406 consent = '';
407 }
408 }
409
410 var granted = 'accepted' === consent ? 'granted' : 'denied';
411 var state = {
412 ad_storage: granted,
413 ad_user_data: granted,
414 ad_personalization: granted,
415 analytics_storage: granted
416 };
417
418 // An add-on tracking per-category consent (analytics vs. marketing)
419 // can define this — reading its own cookie the same way, client-side —
420 // to send the granular signals Consent Mode actually expects instead
421 // of this binary default. Must be defined by the time this script
422 // runs, i.e. at an earlier wp_head priority than this method's own.
423 if ( typeof window.frblCookieNoticeConsentModeState === 'function' ) {
424 var overrideState = window.frblCookieNoticeConsentModeState();
425
426 if ( overrideState ) {
427 state = overrideState;
428 }
429 }
430
431 // An add-on reporting its own per-category consent as stale (see
432 // window.frblCookieNoticeIsConsentStale, already used to keep the
433 // banner/tracking bootstrap from trusting stale consent) means a
434 // fresh decision is needed — so deny by default here too, instead
435 // of falling back to this plugin's own (possibly still 'accepted')
436 // binary cookie, which would let an independently loaded, Consent
437 // Mode-aware tag (e.g. Site Kit) run before the visitor re-decides.
438 if ( typeof window.frblCookieNoticeIsConsentStale === 'function' && window.frblCookieNoticeIsConsentStale() ) {
439 state = {
440 ad_storage: 'denied',
441 ad_user_data: 'denied',
442 ad_personalization: 'denied',
443 analytics_storage: 'denied'
444 };
445 }
446
447 gtag( 'consent', 'default', state );
448 } )();
449 </script>
450 <?php
451 }
452
453 /**
454 * Print the inline bootstrap script: hides the banner immediately when a
455 * decision cookie already exists, and — for an accepted visitor — fetches
456 * and injects the tracking scripts. Hooked on wp_head (not wp_footer,
457 * where the banner markup itself renders) precisely so an already-accepted
458 * visitor's tracking request fires as early as possible, on every page
459 * including the policy page (where render_banner_markup() is skipped but
460 * this still runs).
461 *
462 * This is an optimization, not the only implementation: it sets
463 * window.frblCookieNoticeBootstrapped so the registered
464 * frontblocks-cookie-notice.js file (enqueued in enqueue_assets()) knows
465 * this already ran and skips redoing it. On a site whose Content Security
466 * Policy blocks unnonced inline scripts, this one is simply never executed
467 * by the browser, and that registered script performs the same bootstrap
468 * itself instead — banner hiding and tracking still work there, just
469 * without the no-flash guarantee this inline copy provides.
470 *
471 * @return void
472 */
473 public function render_consent_bootstrap_script() {
474 $cookie_name = $this->get_cookie_name();
475 ?>
476 <script>
477 ( function () {
478 // This runs on wp_head, before '#frbl-cookie-notice' exists in the DOM
479 // (it's printed later, in wp_footer) — so, unlike the registered
480 // frontblocks-cookie-notice.js file, it can only handle the tracking
481 // side of an already-decided visitor, not hiding the banner itself.
482 var cookieMatch = document.cookie.match( new RegExp( '(?:^|; )<?php echo esc_js( $cookie_name ); ?>=([^;]*)' ) );
483 var consent = '';
484
485 if ( cookieMatch ) {
486 try {
487 consent = decodeURIComponent( cookieMatch[ 1 ] );
488 } catch ( e ) {
489 // Malformed percent-encoding: treat it the same as no cookie at all.
490 consent = '';
491 }
492 }
493
494 window.frblCookieNoticeInject = window.frblCookieNoticeInject || function ( gtmId, ga4Id ) {
495 if ( gtmId ) {
496 window.dataLayer = window.dataLayer || [];
497 window.dataLayer.push( { 'gtm.start': new Date().getTime(), event: 'gtm.js' } );
498
499 var gtmScript = document.createElement( 'script' );
500 gtmScript.async = true;
501 gtmScript.src = 'https://www.googletagmanager.com/gtm.js?id=' + encodeURIComponent( gtmId );
502 document.head.appendChild( gtmScript );
503 }
504
505 if ( ga4Id ) {
506 var ga4Script = document.createElement( 'script' );
507 ga4Script.async = true;
508 ga4Script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent( ga4Id );
509 document.head.appendChild( ga4Script );
510
511 window.dataLayer = window.dataLayer || [];
512 window.gtag = window.gtag || function () {
513 window.dataLayer.push( arguments );
514 };
515 window.gtag( 'js', new Date() );
516 window.gtag( 'config', ga4Id );
517 }
518 };
519
520 // An add-on tracking per-category consent can define this (printed
521 // earlier than this script, at a lower wp_head priority) to say the
522 // stored consent is stale — e.g. the site admin just added a new
523 // integration — so tracking shouldn't start yet either, not just the
524 // banner staying hidden.
525 var isStale = typeof window.frblCookieNoticeIsConsentStale === 'function' && window.frblCookieNoticeIsConsentStale();
526
527 if ( 'accepted' === consent && ! isStale ) {
528 var formData = new FormData();
529 formData.append( 'action', 'frbl_get_cookie_notice_config' );
530
531 fetch( '<?php echo esc_url( $this->get_ajax_url() ); ?>', {
532 method: 'POST',
533 credentials: 'same-origin',
534 body: formData
535 } )
536 .then( function ( response ) { return response.json(); } )
537 .then( function ( response ) {
538 if ( response && response.success && response.data ) {
539 window.frblCookieNoticeInject( response.data.gtmId, response.data.ga4Id );
540 }
541 } )
542 .catch( function () {} );
543 }
544
545 window.frblCookieNoticeBootstrapped = true;
546 } )();
547 </script>
548 <?php
549 }
550
551 /**
552 * Inline SVG for the popup layout's icon badge.
553 *
554 * The badge's circular background comes from CSS (using the configured
555 * accent color), so this only needs the glyph itself, colored via
556 * `fill="currentColor"`. Public so the admin settings preview can reuse
557 * the exact same markup shown on the frontend.
558 *
559 * @return string Raw SVG markup.
560 */
561 public static function get_cookie_icon_svg() {
562 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>';
563 }
564
565 /**
566 * Pick black or white text, whichever has the higher actual WCAG contrast
567 * ratio against a background color (not just whichever "looks" darker/lighter).
568 *
569 * Public static — pure color math with no instance state, also used by the
570 * admin settings preview to show the same contrast the frontend actually renders.
571 *
572 * @param string $hex_color Background color, e.g. '#687df9'.
573 * @return string '#ffffff' or '#000000'.
574 */
575 public static function get_readable_text_color( $hex_color ) {
576 $bg_luminance = self::get_relative_luminance( self::hex_to_rgb( $hex_color ) );
577
578 $white_contrast = self::get_contrast_ratio( $bg_luminance, 1 );
579 $black_contrast = self::get_contrast_ratio( $bg_luminance, 0 );
580
581 // Pure black, not a lighter dark neutral: whichever of black/white has
582 // the lower contrast against any background is guaranteed to still
583 // reach ~4.58:1 at that background's worst-case luminance (~0.179),
584 // clearing the 4.5:1 button-text requirement for every allowed accent.
585 return $white_contrast >= $black_contrast ? '#ffffff' : '#000000';
586 }
587
588 /**
589 * Ensure a color stays legible when used as text on the banner's white panel —
590 * accent colors that don't reach a 4.5:1 contrast ratio against white fall
591 * back to a dark neutral instead.
592 *
593 * Public static — pure color math with no instance state, also used by the
594 * admin settings preview to show the same contrast the frontend actually renders.
595 *
596 * @param string $hex_color Requested accent color, e.g. '#687df9'.
597 * @return string A color safe to use as text on a white background.
598 */
599 public static function get_readable_on_white_color( $hex_color ) {
600 $luminance = self::get_relative_luminance( self::hex_to_rgb( $hex_color ) );
601 $contrast = self::get_contrast_ratio( $luminance, 1 );
602
603 return $contrast >= 4.5 ? $hex_color : '#111827';
604 }
605
606 /**
607 * WCAG relative luminance of an sRGB color.
608 *
609 * @param int[] $rgb Three-item [r, g, b] array, each 0-255.
610 * @return float Relative luminance between 0 (black) and 1 (white).
611 */
612 private static function get_relative_luminance( $rgb ) {
613 $channels = array();
614
615 foreach ( $rgb as $channel ) {
616 $channel = $channel / 255;
617 $channels[] = $channel <= 0.03928 ? $channel / 12.92 : ( ( $channel + 0.055 ) / 1.055 ) ** 2.4;
618 }
619
620 return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
621 }
622
623 /**
624 * WCAG contrast ratio between two relative luminances.
625 *
626 * @param float $luminance_a First relative luminance (0-1).
627 * @param float $luminance_b Second relative luminance (0-1).
628 * @return float Contrast ratio, from 1 (no contrast) to 21 (black on white).
629 */
630 private static function get_contrast_ratio( $luminance_a, $luminance_b ) {
631 $lighter = max( $luminance_a, $luminance_b );
632 $darker = min( $luminance_a, $luminance_b );
633
634 return ( $lighter + 0.05 ) / ( $darker + 0.05 );
635 }
636
637 /**
638 * Convert a hex color (3 or 6 digits, with or without '#') to an [r, g, b] triple.
639 *
640 * @param string $hex_color Hex color string.
641 * @return int[] Three-item array of 0-255 RGB values; black if the input is invalid.
642 */
643 private static function hex_to_rgb( $hex_color ) {
644 $hex = ltrim( (string) $hex_color, '#' );
645
646 if ( 3 === strlen( $hex ) ) {
647 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
648 }
649
650 if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
651 return array( 0, 0, 0 );
652 }
653
654 return array(
655 hexdec( substr( $hex, 0, 2 ) ),
656 hexdec( substr( $hex, 2, 2 ) ),
657 hexdec( substr( $hex, 4, 2 ) ),
658 );
659 }
660
661 /**
662 * AJAX callback: returns the GTM/GA4 identifiers, but only when the requesting
663 * browser's own consent cookie says 'accepted'.
664 *
665 * Deliberately unauthenticated: it's read-only, never touches the aggregate
666 * counters, and only ever echoes back non-secret IDs that are already public
667 * once GTM/GA4 loads. A nonce would have to be embedded in the cache-neutral
668 * HTML this module renders, and would go stale on any page a full-page cache
669 * keeps for longer than a WordPress nonce's lifetime — breaking tracking for
670 * every visitor of that cached page until it expires from the cache.
671 *
672 * @return void
673 */
674 public function get_config_callback() {
675 $response = array(
676 'gtmId' => '',
677 'ga4Id' => '',
678 );
679
680 if ( $this->is_enabled() && 'accepted' === $this->get_consent() ) {
681 $options = get_option( 'frontblocks_settings', array() );
682 $response['gtmId'] = $this->sanitize_gtm_id( $options['cookie_notice_gtm_id'] ?? '' );
683 $response['ga4Id'] = $this->sanitize_ga4_id( $options['cookie_notice_ga4_id'] ?? '' );
684 }
685
686 wp_send_json_success( $response );
687 }
688
689 /**
690 * AJAX callback: returns a fresh nonce for the logging endpoint.
691 *
692 * Fetched live at the moment a visitor actually decides, instead of being
693 * embedded in the cache-neutral HTML this module renders — a nonce baked
694 * into that HTML would go stale on any page a full-page cache keeps around
695 * longer than a WordPress nonce's lifetime, silently dropping every decision
696 * logged from that cached response. Generating a nonce isn't a sensitive
697 * action in itself (the same thing any login form does for a logged-out
698 * visitor), so this endpoint needs no authentication of its own.
699 *
700 * @return void
701 */
702 public function get_log_nonce_callback() {
703 wp_send_json_success( array( 'nonce' => wp_create_nonce( self::NONCE_ACTION ) ) );
704 }
705
706 /**
707 * AJAX callback: logs the visitor's decision in the aggregate accepted/rejected counters.
708 *
709 * This is a best-effort, lightweight aggregate stat, not a precise per-visitor
710 * metering system — the module explicitly renders cache-neutral HTML (see
711 * render_banner()), so there is no page-embedded value this endpoint could use
712 * to deduplicate a replayed request without also breaking under a full-page
713 * cache, the same way a one-time token would. The nonce itself is fetched
714 * fresh via get_log_nonce_callback() right before this call, so it stays
715 * valid regardless of how long a cache keeps the page that triggered it.
716 *
717 * @return void
718 */
719 public function log_consent_callback() {
720 if ( ! $this->is_enabled() ) {
721 wp_send_json_error( array( 'message' => __( 'Cookie Notice is disabled.', 'frontblocks' ) ), 403 );
722 }
723
724 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
725
726 if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
727 wp_send_json_error( array( 'message' => __( 'Security check failed.', 'frontblocks' ) ), 403 );
728 }
729
730 $decision = isset( $_POST['decision'] ) ? sanitize_key( wp_unslash( $_POST['decision'] ) ) : '';
731
732 if ( ! in_array( $decision, array( 'accepted', 'rejected' ), true ) ) {
733 wp_send_json_error( array( 'message' => __( 'Invalid decision.', 'frontblocks' ) ), 400 );
734 }
735
736 $this->maybe_increment_stat( $decision );
737
738 wp_send_json_success();
739 }
740
741 /**
742 * Increment the aggregate accepted/rejected counter for a decision.
743 *
744 * Logged-in administrators are excluded so testing the banner doesn't skew stats.
745 *
746 * @param string $decision 'accepted' or 'rejected'.
747 * @return void
748 */
749 private function maybe_increment_stat( $decision ) {
750 if ( current_user_can( 'manage_options' ) ) {
751 return;
752 }
753
754 $option_name = 'accepted' === $decision ? self::STATS_OPTION_ACCEPTED : self::STATS_OPTION_REJECTED;
755
756 $this->increment_option_atomically( $option_name );
757 }
758
759 /**
760 * Increment an integer option by 1 directly in the database.
761 *
762 * A plain get_option()/update_option() round trip races under concurrent
763 * requests — two visitors deciding at the same moment can both read the same
764 * value and one increment gets overwritten. A single UPDATE ... SET value = value + 1
765 * lets the database serialize concurrent increments instead.
766 *
767 * @param string $option_name Option name storing a plain integer.
768 * @return void
769 */
770 private function increment_option_atomically( $option_name ) {
771 global $wpdb;
772
773 $sql = $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s", $option_name );
774
775 $updated = $wpdb->query( $sql );
776
777 if ( ! $updated ) {
778 // First time this counter is created. add_option() returns false if another
779 // request created the row first — in that case fall back to the atomic UPDATE
780 // so this increment isn't silently dropped.
781 if ( ! add_option( $option_name, 1, '', 'no' ) ) {
782 $wpdb->query( $sql );
783 }
784 }
785
786 wp_cache_delete( $option_name, 'options' );
787 }
788
789 /**
790 * Validate a Google Tag Manager container ID (e.g. GTM-XXXXXXX).
791 *
792 * @param string $value Raw value.
793 * @return string Sanitized ID, or an empty string when it doesn't match the expected format.
794 */
795 private function sanitize_gtm_id( $value ) {
796 $value = strtoupper( trim( (string) $value ) );
797
798 return preg_match( '/^GTM-[A-Z0-9]+$/', $value ) ? $value : '';
799 }
800
801 /**
802 * Validate a GA4 Measurement ID (e.g. G-XXXXXXXXXX).
803 *
804 * @param string $value Raw value.
805 * @return string Sanitized ID, or an empty string when it doesn't match the expected format.
806 */
807 private function sanitize_ga4_id( $value ) {
808 $value = strtoupper( trim( (string) $value ) );
809
810 return preg_match( '/^G-[A-Z0-9]+$/', $value ) ? $value : '';
811 }
812 }
813