PluginProbe ʕ •ᴥ•ʔ
FrontBlocks for Gutenberg/GeneratePress / 1.5.0
FrontBlocks for Gutenberg/GeneratePress v1.5.0
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
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