PluginProbe ʕ •ᴥ•ʔ
FrontBlocks for Gutenberg/GeneratePress / 1.5.2
FrontBlocks for Gutenberg/GeneratePress v1.5.2
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 5 days 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
1205 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 * Additional tracking tools detectable from a pasted snippet (see
54 * detect_tracking_snippet()), beyond the dedicated GTM/GA4 ID fields.
55 *
56 * @var string[]
57 */
58 const TRACKING_TYPES = array( 'clientify_analytics_plus', 'clientify_analytics_classic', 'brevo' );
59
60 /**
61 * Constructor.
62 */
63 public function __construct() {
64 // These listeners must be available outside wp-admin for cron and integrations.
65 add_action( 'update_option_frontblocks_settings', array( $this, 'handle_frontblocks_settings_updated' ), 10, 3 );
66 add_action( 'add_option_frontblocks_settings', array( $this, 'handle_frontblocks_settings_added' ), 10, 2 );
67
68 if ( ! is_admin() && $this->is_enabled() ) {
69 // Priority 1: must run before any analytics/ads tag (Google Site Kit,
70 // a manually pasted GTM/gtag snippet, etc.) reads its consent defaults —
71 // Google Consent Mode only holds those tags back if 'default' is queued
72 // on the page's dataLayer before they call gtag('config', ...).
73 add_action( 'wp_head', array( $this, 'render_consent_mode_default' ), 1 );
74 // Also early (wp_head, not wp_footer): for an already-accepted visitor
75 // this is what actually requests GTM/GA4, so it needs to run long
76 // before a slow page finishes loading — a footer-only bootstrap risks
77 // missing an early interaction or a request that never reaches the
78 // footer at all, silently undercounting analytics.
79 add_action( 'wp_head', array( $this, 'render_consent_bootstrap_script' ), 2 );
80 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) );
81 add_action( 'wp_footer', array( $this, 'render_banner' ) );
82 }
83
84 // The endpoints must stay available for logged-out and logged-in visitors alike.
85 add_action( 'wp_ajax_frbl_log_cookie_consent', array( $this, 'log_consent_callback' ) );
86 add_action( 'wp_ajax_nopriv_frbl_log_cookie_consent', array( $this, 'log_consent_callback' ) );
87 add_action( 'wp_ajax_frbl_get_cookie_notice_config', array( $this, 'get_config_callback' ) );
88 add_action( 'wp_ajax_nopriv_frbl_get_cookie_notice_config', array( $this, 'get_config_callback' ) );
89 add_action( 'wp_ajax_frbl_get_cookie_notice_log_nonce', array( $this, 'get_log_nonce_callback' ) );
90 add_action( 'wp_ajax_nopriv_frbl_get_cookie_notice_log_nonce', array( $this, 'get_log_nonce_callback' ) );
91 }
92
93 /**
94 * Handle a saved FrontBlocks settings option.
95 *
96 * @param mixed $old_value Previous option value.
97 * @param mixed $new_value New option value.
98 * @param string $option_name Option name.
99 * @return void
100 */
101 public function handle_frontblocks_settings_updated( $old_value, $new_value, $option_name ) {
102 if ( 'frontblocks_settings' !== $option_name || ! is_array( $old_value ) || ! is_array( $new_value ) || ! $this->settings_changed( $old_value, $new_value ) ) {
103 return;
104 }
105
106 $this->handle_settings_changed( $old_value, $new_value );
107 }
108
109 /**
110 * Handle the first save of the FrontBlocks settings option.
111 *
112 * @param string $option_name Option name.
113 * @param mixed $new_value New option value.
114 * @return void
115 */
116 public function handle_frontblocks_settings_added( $option_name, $new_value ) {
117 if ( ! is_array( $new_value ) || ! $this->settings_changed( array(), $new_value ) ) {
118 return;
119 }
120
121 $this->handle_settings_changed( array(), $new_value );
122 }
123
124 /**
125 * Invalidate page caches after Cookie Notice settings change.
126 *
127 * @param array $old_options Previous settings.
128 * @param array $new_options New settings.
129 * @return void
130 */
131 private function handle_settings_changed( $old_options, $new_options ) {
132 $cache_was_purged = false;
133
134 if ( function_exists( 'rocket_clean_domain' ) ) {
135 rocket_clean_domain();
136 $cache_was_purged = true;
137 }
138
139 /**
140 * Fires after Cookie Notice settings affecting frontend output have changed.
141 *
142 * Cache integrations can use this action to invalidate cached pages.
143 *
144 * @param array $old_options Previous FrontBlocks settings.
145 * @param array $new_options New FrontBlocks settings.
146 */
147 do_action( 'frbl_cookie_notice_settings_updated', $old_options, $new_options );
148
149 $user_id = get_current_user_id();
150 if ( $user_id ) {
151 set_transient( 'frbl_cookie_notice_cache_notice_' . $user_id, $cache_was_purged ? 'wp-rocket' : 'manual', MINUTE_IN_SECONDS );
152 }
153 }
154
155 /**
156 * Check whether Cookie Notice settings changed from their frontend defaults.
157 *
158 * @param array $old_options Previous settings.
159 * @param array $new_options New settings.
160 * @return bool
161 */
162 private function settings_changed( $old_options, $new_options ) {
163 $defaults = array(
164 'enable_cookie_notice' => false,
165 'cookie_notice_message' => '',
166 'cookie_notice_accept_label' => '',
167 'cookie_notice_reject_label' => '',
168 'cookie_notice_policy_page_id' => 0,
169 'cookie_notice_layout' => 'bar',
170 'cookie_notice_position' => 'bottom-right',
171 'cookie_notice_color' => '#687df9',
172 'cookie_notice_bg_color' => '#ffffff',
173 'cookie_notice_radius' => 'small',
174 'cookie_notice_expiration_days' => 365,
175 'cookie_notice_gtm_id' => '',
176 'cookie_notice_ga4_id' => '',
177 'cookie_notice_tracking_integrations' => array(),
178 );
179
180 foreach ( $defaults as $key => $default ) {
181 $old_value = array_key_exists( $key, $old_options ) ? $old_options[ $key ] : $default;
182 $new_value = array_key_exists( $key, $new_options ) ? $new_options[ $key ] : $default;
183
184 if ( $old_value !== $new_value ) {
185 return true;
186 }
187 }
188
189 return false;
190 }
191
192 /**
193 * Check if the Cookie Notice module is enabled.
194 *
195 * @return bool
196 */
197 private function is_enabled() {
198 $options = get_option( 'frontblocks_settings', array() );
199 return (bool) ( $options['enable_cookie_notice'] ?? false );
200 }
201
202 /**
203 * Name of the cookie storing the visitor's consent decision.
204 *
205 * On multisite, COOKIEPATH alone can't isolate the root site from its
206 * subsites (the root site's path is '/', which every subsite path sits
207 * under), so the blog ID is folded into the cookie name itself instead.
208 *
209 * @return string
210 */
211 private function get_cookie_name() {
212 if ( is_multisite() ) {
213 return 'frbl_cookie_consent_' . get_current_blog_id();
214 }
215
216 return 'frbl_cookie_consent';
217 }
218
219 /**
220 * Get the admin-ajax.php URL, forced onto the frontend's own scheme and host.
221 *
222 * The admin_url() function can point at a different scheme (e.g.
223 * FORCE_SSL_ADMIN on an http frontend) and even a different host (when
224 * WP_HOME and WP_SITEURL are configured separately) than the page that's
225 * about to fetch() it. 'credentials: same-origin' then omits the consent
226 * cookie, and the browser's CORS check blocks the response regardless —
227 * so only the admin-ajax.php path is taken from admin_url(); the scheme
228 * and host always come from the current request and home_url() instead,
229 * keeping the AJAX call same-origin with the frontend.
230 *
231 * @return string
232 */
233 private function get_ajax_url() {
234 $home_parts = wp_parse_url( home_url() );
235 $ajax_path = (string) wp_parse_url( admin_url( 'admin-ajax.php' ), PHP_URL_PATH );
236
237 $scheme = is_ssl() ? 'https' : 'http';
238 $host = $home_parts['host'] ?? '';
239 $port = isset( $home_parts['port'] ) ? ':' . $home_parts['port'] : '';
240
241 return $scheme . '://' . $host . $port . $ajax_path;
242 }
243
244 /**
245 * Get the visitor's current consent decision from the cookie.
246 *
247 * @return string 'accepted', 'rejected', or '' when the visitor has not decided yet.
248 */
249 private function get_consent() {
250 $cookie_name = $this->get_cookie_name();
251
252 if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
253 return '';
254 }
255
256 $consent = sanitize_key( wp_unslash( $_COOKIE[ $cookie_name ] ) );
257
258 return in_array( $consent, array( 'accepted', 'rejected' ), true ) ? $consent : '';
259 }
260
261 /**
262 * Check whether the current request is for the configured cookie policy page.
263 *
264 * Used to suppress the banner there so visitors can read the policy before
265 * deciding — otherwise, with the popup layout, the notice would immediately
266 * cover the policy content on that same page.
267 *
268 * @return bool
269 */
270 private function is_policy_page() {
271 $options = get_option( 'frontblocks_settings', array() );
272 $policy_page_id = (int) ( $options['cookie_notice_policy_page_id'] ?? 0 );
273
274 if ( ! $policy_page_id ) {
275 return false;
276 }
277
278 return get_queried_object_id() === $policy_page_id;
279 }
280
281 /**
282 * Enqueue the frontend banner assets.
283 *
284 * Always enqueued, on every page including the configured policy page —
285 * never gated by the visitor's consent cookie, so a full-page cache can
286 * safely serve one cached HTML response to every visitor of a URL. The
287 * policy page only suppresses the visible banner markup (see
288 * render_banner()); it still needs these assets so an accepted visitor
289 * keeps getting tracking scripts there too.
290 *
291 * @return void
292 */
293 public function enqueue_assets() {
294 $options = get_option( 'frontblocks_settings', array() );
295 $days = (int) ( $options['cookie_notice_expiration_days'] ?? 365 );
296
297 wp_enqueue_style(
298 'frontblocks-cookie-notice',
299 FRBL_PLUGIN_URL . 'assets/cookie-notice/frontblocks-cookie-notice.css',
300 array(),
301 FRBL_VERSION
302 );
303
304 wp_enqueue_script(
305 'frontblocks-cookie-notice',
306 FRBL_PLUGIN_URL . 'assets/cookie-notice/frontblocks-cookie-notice.js',
307 array(),
308 FRBL_VERSION,
309 true
310 );
311
312 wp_localize_script(
313 'frontblocks-cookie-notice',
314 'frblCookieNotice',
315 array(
316 'ajaxUrl' => $this->get_ajax_url(),
317 'cookieName' => $this->get_cookie_name(),
318 'cookiePath' => defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/',
319 'expirationDays' => $days > 0 ? $days : 365,
320 )
321 );
322 }
323
324 /**
325 * Render the visible consent banner markup in the footer.
326 *
327 * Always rendered the same way for every visitor of a given URL — never
328 * gated by the visitor's own consent cookie — so a full-page cache stays
329 * safe; render_consent_bootstrap_script() (hooked much earlier, on
330 * wp_head) hides it immediately client-side when a decision cookie already
331 * exists, so a returning visitor never sees it flash.
332 *
333 * Suppressed on the configured cookie policy page so a popup layout can't
334 * block that page's own content — the bootstrap script's tracking pickup
335 * still runs there regardless, since it's on wp_head, not this method.
336 *
337 * @return void
338 */
339 public function render_banner() {
340 if ( ! $this->is_policy_page() ) {
341 $this->render_banner_markup();
342 }
343 }
344
345 /**
346 * Render the visible banner markup.
347 *
348 * @return void
349 */
350 private function render_banner_markup() {
351 $options = get_option( 'frontblocks_settings', array() );
352
353 $message = trim( (string) ( $options['cookie_notice_message'] ?? '' ) );
354 $accept_label = trim( (string) ( $options['cookie_notice_accept_label'] ?? '' ) );
355 $reject_label = trim( (string) ( $options['cookie_notice_reject_label'] ?? '' ) );
356 $policy_page_id = (int) ( $options['cookie_notice_policy_page_id'] ?? 0 );
357 $policy_url = $policy_page_id ? (string) get_permalink( $policy_page_id ) : '';
358 $layout = (string) ( $options['cookie_notice_layout'] ?? 'bar' );
359 $position = (string) ( $options['cookie_notice_position'] ?? 'bottom-right' );
360 $color = (string) ( $options['cookie_notice_color'] ?? '#687df9' );
361 $bg_color = (string) ( $options['cookie_notice_bg_color'] ?? '#ffffff' );
362 $radius = (string) ( $options['cookie_notice_radius'] ?? 'small' );
363
364 if ( '' === $message ) {
365 $message = __( 'We use cookies to improve your experience on our website. By browsing this website, you agree to our use of cookies.', 'frontblocks' );
366 }
367
368 if ( '' === $accept_label ) {
369 // Filterable so an add-on that relabels the binary choice as "accept all" /
370 // "reject non-essential" (once it introduces per-category consent) doesn't
371 // need the site admin to retype the button copy themselves.
372 $accept_label = apply_filters( 'frbl_cookie_notice_default_accept_label', __( 'Accept', 'frontblocks' ) );
373 }
374
375 if ( '' === $reject_label ) {
376 $reject_label = apply_filters( 'frbl_cookie_notice_default_reject_label', __( 'Reject', 'frontblocks' ) );
377 }
378
379 if ( ! in_array( $layout, array( 'bar', 'box', 'popup' ), true ) ) {
380 $layout = 'bar';
381 }
382
383 // '--init' starts the banner invisible/off-screen: it's what keeps an
384 // already-decided visitor from ever seeing it flash into view before
385 // frontblocks-cookie-notice.js hides it, and doubles as the "from" state
386 // of the entrance animation for a visitor who still needs to decide (the
387 // script removes it once that's determined). See the noscript style
388 // below for the no-JS fallback.
389 $classes = array( 'frbl-cookie-notice', 'frbl-cookie-notice--' . $layout, 'frbl-cookie-notice--init' );
390 $content_width = function_exists( 'generate_get_option' ) ? absint( generate_get_option( 'container_width' ) ) : 0;
391
392 if ( $content_width > 0 ) {
393 $classes[] = 'frbl-cookie-notice--generatepress';
394 }
395
396 if ( 'box' === $layout ) {
397 $classes[] = 'bottom-left' === $position ? 'frbl-cookie-notice--left' : 'frbl-cookie-notice--right';
398 }
399
400 $is_modal = 'popup' === $layout;
401 $accent_text = $this->get_readable_text_color( $color );
402 $accent_link = $this->get_readable_on_white_color( $color );
403 $panel_text = $this->get_readable_text_color( $bg_color );
404 $style = sprintf(
405 '--frbl-cookie-accent: %1$s; --frbl-cookie-accent-contrast: %2$s; --frbl-cookie-accent-on-light: %3$s; --frbl-cookie-bg: %4$s; --frbl-cookie-text: %5$s; --frbl-cookie-radius: %6$s;',
406 esc_attr( $color ),
407 esc_attr( $accent_text ),
408 esc_attr( $accent_link ),
409 esc_attr( $bg_color ),
410 esc_attr( $panel_text ),
411 esc_attr( $this->get_radius_value( $radius ) )
412 );
413
414 if ( $content_width > 0 ) {
415 $style .= sprintf( ' --frbl-cookie-content-width: %dpx;', $content_width );
416 }
417 ?>
418 <div
419 id="frbl-cookie-notice"
420 class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
421 style="<?php echo esc_attr( $style ); ?>"
422 role="<?php echo $is_modal ? 'dialog' : 'region'; ?>"
423 <?php echo $is_modal ? 'aria-modal="true"' : ''; ?>
424 aria-label="<?php echo esc_attr__( 'Cookie consent', 'frontblocks' ); ?>"
425 >
426 <div class="frbl-cookie-notice__panel">
427 <span class="frbl-cookie-notice__icon" aria-hidden="true">
428 <?php echo $this->get_cookie_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static inline SVG, no dynamic data. ?>
429 </span>
430 <p class="frbl-cookie-notice__message">
431 <?php
432 echo esc_html( $message );
433
434 if ( $policy_url ) {
435 echo ' <a href="' . esc_url( $policy_url ) . '" class="frbl-cookie-notice__link" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Learn more', 'frontblocks' ) . '</a>';
436 }
437 ?>
438 </p>
439 <div class="frbl-cookie-notice__actions">
440 <?php
441 /**
442 * Fires right before the reject/accept buttons, inside the same actions
443 * row. Lets an add-on (e.g. per-category consent) insert its own button —
444 * a "Customize" trigger — without forking this markup.
445 *
446 * @param array $options The 'frontblocks_settings' option array.
447 */
448 do_action( 'frbl_cookie_notice_before_actions', $options );
449 ?>
450 <button
451 type="button"
452 class="frbl-cookie-notice__button frbl-cookie-notice__button--reject"
453 data-frbl-cookie-action="reject"
454 >
455 <?php echo esc_html( $reject_label ); ?>
456 </button>
457 <button
458 type="button"
459 class="frbl-cookie-notice__button frbl-cookie-notice__button--accept"
460 data-frbl-cookie-action="accept"
461 >
462 <?php echo esc_html( $accept_label ); ?>
463 </button>
464 </div>
465 </div>
466 </div>
467 <?php
468 // Without JS, nothing would ever remove '--init' (see the class list
469 // above), so the banner would stay invisible forever — this resets it
470 // back to plain visible/static for a no-JS visitor.
471 ?>
472 <noscript>
473 <style>
474 #frbl-cookie-notice.frbl-cookie-notice--init {
475 opacity: 1;
476 pointer-events: auto;
477 transform: none;
478 }
479 </style>
480 </noscript>
481 <?php
482 if ( $is_modal ) {
483 ?>
484 <noscript>
485 <style>
486 .frbl-cookie-notice--popup {
487 position: static;
488 display: block;
489 overflow: visible;
490 background-color: transparent;
491 padding: 0;
492 }
493 .frbl-cookie-notice--popup .frbl-cookie-notice__panel {
494 max-width: none;
495 box-shadow: none;
496 }
497 </style>
498 </noscript>
499 <?php
500 }
501
502 /**
503 * Fires right after the banner markup, still inside the same wp_footer
504 * output. Lets an add-on print extra markup that belongs to the same
505 * consent flow — e.g. a "customize categories" dialog — right next to it.
506 *
507 * @param array $options The 'frontblocks_settings' option array.
508 */
509 do_action( 'frbl_cookie_notice_after_banner', $options );
510 }
511
512 /**
513 * Print the Google Consent Mode default state, before any other script.
514 *
515 * This is what actually blocks analytics/ads tags that read Consent Mode
516 * (Google Site Kit's own gtag snippet, a manually pasted GTM container,
517 * etc.) from firing before the visitor decides — the banner markup and its
518 * own accept/reject buttons only control what *this plugin* loads via the
519 * GTM/GA4 ID fields below; they have no effect on tags any other plugin
520 * injects independently. Consent Mode is the standard way to reach those
521 * too, because gtag() queues commands on window.dataLayer regardless of
522 * which plugin's script eventually processes them — as long as this runs
523 * first, it doesn't matter which plugin's gtag.js loads second.
524 *
525 * Only the cookie *name* (a fixed string) is embedded server-side — the
526 * decision itself is read from document.cookie client-side, in the browser,
527 * exactly like render_consent_bootstrap_script() below. This keeps the
528 * printed HTML identical for every visitor of a given URL, so a full-page
529 * cache stays safe; an earlier version of this method embedded the
530 * granted/denied value directly, which a full-page cache could then have
531 * served to the wrong visitor.
532 *
533 * @return void
534 */
535 public function render_consent_mode_default() {
536 $cookie_name = $this->get_cookie_name();
537 ?>
538 <script>
539 window.dataLayer = window.dataLayer || [];
540 function gtag(){ window.dataLayer.push( arguments ); }
541 ( function () {
542 var cookieMatch = document.cookie.match( new RegExp( '(?:^|; )<?php echo esc_js( $cookie_name ); ?>=([^;]*)' ) );
543 var consent = '';
544
545 if ( cookieMatch ) {
546 try {
547 consent = decodeURIComponent( cookieMatch[ 1 ] );
548 } catch ( e ) {
549 // Malformed percent-encoding: treat it the same as no cookie at all,
550 // same as the bootstrap script below — a thrown, uncaught error here
551 // would abort before gtag('consent', 'default', ...) ever runs.
552 consent = '';
553 }
554 }
555
556 var granted = 'accepted' === consent ? 'granted' : 'denied';
557 var state = {
558 ad_storage: granted,
559 ad_user_data: granted,
560 ad_personalization: granted,
561 analytics_storage: granted
562 };
563
564 // An add-on tracking per-category consent (analytics vs. marketing)
565 // can define this — reading its own cookie the same way, client-side —
566 // to send the granular signals Consent Mode actually expects instead
567 // of this binary default. Must be defined by the time this script
568 // runs, i.e. at an earlier wp_head priority than this method's own.
569 if ( typeof window.frblCookieNoticeConsentModeState === 'function' ) {
570 var overrideState = window.frblCookieNoticeConsentModeState();
571
572 if ( overrideState ) {
573 state = overrideState;
574 }
575 }
576
577 // An add-on reporting its own per-category consent as stale (see
578 // window.frblCookieNoticeIsConsentStale, already used to keep the
579 // banner/tracking bootstrap from trusting stale consent) means a
580 // fresh decision is needed — so deny by default here too, instead
581 // of falling back to this plugin's own (possibly still 'accepted')
582 // binary cookie, which would let an independently loaded, Consent
583 // Mode-aware tag (e.g. Site Kit) run before the visitor re-decides.
584 if ( typeof window.frblCookieNoticeIsConsentStale === 'function' && window.frblCookieNoticeIsConsentStale() ) {
585 state = {
586 ad_storage: 'denied',
587 ad_user_data: 'denied',
588 ad_personalization: 'denied',
589 analytics_storage: 'denied'
590 };
591 }
592
593 gtag( 'consent', 'default', state );
594 } )();
595 </script>
596 <?php
597 }
598
599 /**
600 * Print the inline bootstrap script: hides the banner immediately when a
601 * decision cookie already exists, and — for an accepted visitor — fetches
602 * and injects the tracking scripts. Hooked on wp_head (not wp_footer,
603 * where the banner markup itself renders) precisely so an already-accepted
604 * visitor's tracking request fires as early as possible, on every page
605 * including the policy page (where render_banner_markup() is skipped but
606 * this still runs).
607 *
608 * This is an optimization, not the only implementation: it sets
609 * window.frblCookieNoticeBootstrapped so the registered
610 * frontblocks-cookie-notice.js file (enqueued in enqueue_assets()) knows
611 * this already ran and skips redoing it. On a site whose Content Security
612 * Policy blocks unnonced inline scripts, this one is simply never executed
613 * by the browser, and that registered script performs the same bootstrap
614 * itself instead — banner hiding and tracking still work there, just
615 * without the no-flash guarantee this inline copy provides.
616 *
617 * @return void
618 */
619 public function render_consent_bootstrap_script() {
620 $cookie_name = $this->get_cookie_name();
621 ?>
622 <script>
623 ( function () {
624 // This runs on wp_head, before '#frbl-cookie-notice' exists in the DOM
625 // (it's printed later, in wp_footer) — so, unlike the registered
626 // frontblocks-cookie-notice.js file, it can only handle the tracking
627 // side of an already-decided visitor, not hiding the banner itself.
628 var cookieMatch = document.cookie.match( new RegExp( '(?:^|; )<?php echo esc_js( $cookie_name ); ?>=([^;]*)' ) );
629 var consent = '';
630
631 if ( cookieMatch ) {
632 try {
633 consent = decodeURIComponent( cookieMatch[ 1 ] );
634 } catch ( e ) {
635 // Malformed percent-encoding: treat it the same as no cookie at all.
636 consent = '';
637 }
638 }
639
640 window.frblCookieNoticeInject = window.frblCookieNoticeInject || function ( gtmId, ga4Id, trackingIntegrations ) {
641 if ( gtmId ) {
642 window.dataLayer = window.dataLayer || [];
643 window.dataLayer.push( { 'gtm.start': new Date().getTime(), event: 'gtm.js' } );
644
645 var gtmScript = document.createElement( 'script' );
646 gtmScript.async = true;
647 gtmScript.src = 'https://www.googletagmanager.com/gtm.js?id=' + encodeURIComponent( gtmId );
648 document.head.appendChild( gtmScript );
649 }
650
651 if ( ga4Id ) {
652 var ga4Script = document.createElement( 'script' );
653 ga4Script.async = true;
654 ga4Script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent( ga4Id );
655 document.head.appendChild( ga4Script );
656
657 window.dataLayer = window.dataLayer || [];
658 window.gtag = window.gtag || function () {
659 window.dataLayer.push( arguments );
660 };
661 window.gtag( 'js', new Date() );
662 window.gtag( 'config', ga4Id );
663 }
664
665 if ( ! Array.isArray( trackingIntegrations ) ) {
666 trackingIntegrations = [];
667 }
668
669 trackingIntegrations.forEach( function ( integration ) {
670 var trackingType = integration && integration.type ? integration.type : '';
671 var trackingId = integration && integration.id ? integration.id : '';
672
673 if ( ! trackingId ) {
674 return;
675 }
676
677 if ( 'clientify_analytics_plus' === trackingType ) {
678 var clientifyPixel = document.createElement( 'script' );
679 clientifyPixel.defer = true;
680 clientifyPixel.src = 'https://analyticsplusdev.clientify.net/analytics_plus/pixel/' + encodeURIComponent( trackingId );
681 document.head.appendChild( clientifyPixel );
682 } else if ( 'clientify_analytics_classic' === trackingType ) {
683 ( function ( d, w, u, o ) {
684 w[ o ] = w[ o ] || function () {
685 ( w[ o ].q = w[ o ].q || [] ).push( arguments );
686 };
687 var a = d.createElement( 'script' ),
688 m = d.getElementsByTagName( 'script' )[ 0 ];
689 a.async = 1; a.src = u;
690 m.parentNode.insertBefore( a, m );
691 } )( document, window, 'https://analytics.clientify.net/tracker.js', 'ana' );
692 window.ana( 'setTrackerUrl', 'https://analytics.clientify.net' );
693 window.ana( 'setTrackingCode', trackingId );
694 window.ana( 'trackPageview' );
695 } else if ( 'brevo' === trackingType ) {
696 var brevoScript = document.createElement( 'script' );
697 brevoScript.async = true;
698 brevoScript.src = 'https://cdn.brevo.com/js/sdk-loader.js';
699 document.head.appendChild( brevoScript );
700
701 window.Brevo = window.Brevo || [];
702 window.Brevo.push( [ 'init', { client_key: trackingId } ] );
703 }
704 } );
705 };
706
707 // An add-on tracking per-category consent can define this (printed
708 // earlier than this script, at a lower wp_head priority) to say the
709 // stored consent is stale — e.g. the site admin just added a new
710 // integration — so tracking shouldn't start yet either, not just the
711 // banner staying hidden.
712 var isStale = typeof window.frblCookieNoticeIsConsentStale === 'function' && window.frblCookieNoticeIsConsentStale();
713
714 if ( 'accepted' === consent && ! isStale ) {
715 var formData = new FormData();
716 formData.append( 'action', 'frbl_get_cookie_notice_config' );
717
718 fetch( '<?php echo esc_url( $this->get_ajax_url() ); ?>', {
719 method: 'POST',
720 credentials: 'same-origin',
721 body: formData
722 } )
723 .then( function ( response ) { return response.json(); } )
724 .then( function ( response ) {
725 if ( response && response.success && response.data ) {
726 window.frblCookieNoticeInject( response.data.gtmId, response.data.ga4Id, response.data.trackingIntegrations );
727 }
728 } )
729 .catch( function () {} );
730 }
731
732 window.frblCookieNoticeBootstrapped = true;
733 } )();
734 </script>
735 <?php
736 }
737
738 /**
739 * Inline SVG for the popup layout's icon badge.
740 *
741 * The badge's circular background comes from CSS (using the configured
742 * accent color), so this only needs the glyph itself, colored via
743 * `fill="currentColor"`. Public so the admin settings preview can reuse
744 * the exact same markup shown on the frontend.
745 *
746 * @return string Raw SVG markup.
747 */
748 public static function get_cookie_icon_svg() {
749 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>';
750 }
751
752 /**
753 * Map a corner-rounding preset to its CSS value.
754 *
755 * Public static — also used by the admin settings preview so it renders the
756 * exact same rounding the frontend does.
757 *
758 * @param string $preset 'none', 'small', or 'large'.
759 * @return string CSS length, e.g. '12px'.
760 */
761 public static function get_radius_value( $preset ) {
762 $radii = array(
763 'none' => '0px',
764 'small' => '12px',
765 'large' => '24px',
766 );
767
768 return $radii[ $preset ] ?? $radii['small'];
769 }
770
771 /**
772 * Pick black or white text, whichever has the higher actual WCAG contrast
773 * ratio against a background color (not just whichever "looks" darker/lighter).
774 *
775 * Public static — pure color math with no instance state, also used by the
776 * admin settings preview to show the same contrast the frontend actually renders.
777 *
778 * @param string $hex_color Background color, e.g. '#687df9'.
779 * @return string '#ffffff' or '#000000'.
780 */
781 public static function get_readable_text_color( $hex_color ) {
782 $bg_luminance = self::get_relative_luminance( self::hex_to_rgb( $hex_color ) );
783
784 $white_contrast = self::get_contrast_ratio( $bg_luminance, 1 );
785 $black_contrast = self::get_contrast_ratio( $bg_luminance, 0 );
786
787 // Pure black, not a lighter dark neutral: whichever of black/white has
788 // the lower contrast against any background is guaranteed to still
789 // reach ~4.58:1 at that background's worst-case luminance (~0.179),
790 // clearing the 4.5:1 button-text requirement for every allowed accent.
791 return $white_contrast >= $black_contrast ? '#ffffff' : '#000000';
792 }
793
794 /**
795 * Ensure a color stays legible when used as text on the banner's white panel —
796 * accent colors that don't reach a 4.5:1 contrast ratio against white fall
797 * back to a dark neutral instead.
798 *
799 * Public static — pure color math with no instance state, also used by the
800 * admin settings preview to show the same contrast the frontend actually renders.
801 *
802 * @param string $hex_color Requested accent color, e.g. '#687df9'.
803 * @return string A color safe to use as text on a white background.
804 */
805 public static function get_readable_on_white_color( $hex_color ) {
806 $luminance = self::get_relative_luminance( self::hex_to_rgb( $hex_color ) );
807 $contrast = self::get_contrast_ratio( $luminance, 1 );
808
809 return $contrast >= 4.5 ? $hex_color : '#111827';
810 }
811
812 /**
813 * WCAG relative luminance of an sRGB color.
814 *
815 * @param int[] $rgb Three-item [r, g, b] array, each 0-255.
816 * @return float Relative luminance between 0 (black) and 1 (white).
817 */
818 private static function get_relative_luminance( $rgb ) {
819 $channels = array();
820
821 foreach ( $rgb as $channel ) {
822 $channel = $channel / 255;
823 $channels[] = $channel <= 0.03928 ? $channel / 12.92 : ( ( $channel + 0.055 ) / 1.055 ) ** 2.4;
824 }
825
826 return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
827 }
828
829 /**
830 * WCAG contrast ratio between two relative luminances.
831 *
832 * @param float $luminance_a First relative luminance (0-1).
833 * @param float $luminance_b Second relative luminance (0-1).
834 * @return float Contrast ratio, from 1 (no contrast) to 21 (black on white).
835 */
836 private static function get_contrast_ratio( $luminance_a, $luminance_b ) {
837 $lighter = max( $luminance_a, $luminance_b );
838 $darker = min( $luminance_a, $luminance_b );
839
840 return ( $lighter + 0.05 ) / ( $darker + 0.05 );
841 }
842
843 /**
844 * Convert a hex color (3 or 6 digits, with or without '#') to an [r, g, b] triple.
845 *
846 * @param string $hex_color Hex color string.
847 * @return int[] Three-item array of 0-255 RGB values; black if the input is invalid.
848 */
849 private static function hex_to_rgb( $hex_color ) {
850 $hex = ltrim( (string) $hex_color, '#' );
851
852 if ( 3 === strlen( $hex ) ) {
853 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
854 }
855
856 if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
857 return array( 0, 0, 0 );
858 }
859
860 return array(
861 hexdec( substr( $hex, 0, 2 ) ),
862 hexdec( substr( $hex, 2, 2 ) ),
863 hexdec( substr( $hex, 4, 2 ) ),
864 );
865 }
866
867 /**
868 * AJAX callback: returns the GTM/GA4 identifiers, but only when the requesting
869 * browser's own consent cookie says 'accepted'.
870 *
871 * Deliberately unauthenticated: it's read-only, never touches the aggregate
872 * counters, and only ever echoes back non-secret IDs that are already public
873 * once GTM/GA4 loads. A nonce would have to be embedded in the cache-neutral
874 * HTML this module renders, and would go stale on any page a full-page cache
875 * keeps for longer than a WordPress nonce's lifetime — breaking tracking for
876 * every visitor of that cached page until it expires from the cache.
877 *
878 * @return void
879 */
880 public function get_config_callback() {
881 $response = array(
882 'gtmId' => '',
883 'ga4Id' => '',
884 'trackingIntegrations' => array(),
885 );
886
887 if ( $this->is_enabled() && 'accepted' === $this->get_consent() ) {
888 $options = get_option( 'frontblocks_settings', array() );
889 $site_kit_tags = $this->get_google_site_kit_managed_tags();
890 $response['gtmId'] = $site_kit_tags['gtm'] ? '' : $this->sanitize_gtm_id( $options['cookie_notice_gtm_id'] ?? '' );
891 $response['ga4Id'] = $site_kit_tags['ga4'] ? '' : $this->sanitize_ga4_id( $options['cookie_notice_ga4_id'] ?? '' );
892 $response['trackingIntegrations'] = self::get_tracking_integrations( $options );
893 }
894
895 wp_send_json_success( $response );
896 }
897
898 /**
899 * Get the Google tags that Site Kit is configured to place.
900 *
901 * Site Kit may be active without placing a tag. Only suppress the matching
902 * FrontBlocks ID when its Site Kit module has both an identifier and snippet
903 * placement enabled, avoiding duplicated tags without disabling tracking on
904 * partially configured Site Kit installations.
905 *
906 * @return array{gtm: bool, ga4: bool}
907 */
908 private function get_google_site_kit_managed_tags() {
909 $tags = array(
910 'gtm' => false,
911 'ga4' => false,
912 );
913
914 if ( ! defined( 'GOOGLESITEKIT_VERSION' ) && ! class_exists( '\\Google\\Site_Kit\\Plugin' ) ) {
915 return $tags;
916 }
917
918 $tag_manager_settings = get_option( 'googlesitekit_tagmanager_settings', array() );
919 if ( is_array( $tag_manager_settings ) && ! empty( $tag_manager_settings['containerID'] ) && ( ! isset( $tag_manager_settings['useSnippet'] ) || $tag_manager_settings['useSnippet'] ) ) {
920 $tags['gtm'] = true;
921 }
922
923 $analytics_settings = get_option( 'googlesitekit_analytics-4_settings', array() );
924 if ( is_array( $analytics_settings ) && ! empty( $analytics_settings['measurementID'] ) && ( ! isset( $analytics_settings['useSnippet'] ) || $analytics_settings['useSnippet'] ) ) {
925 $tags['ga4'] = true;
926 }
927
928 return $tags;
929 }
930
931 /**
932 * AJAX callback: returns a fresh nonce for the logging endpoint.
933 *
934 * Fetched live at the moment a visitor actually decides, instead of being
935 * embedded in the cache-neutral HTML this module renders — a nonce baked
936 * into that HTML would go stale on any page a full-page cache keeps around
937 * longer than a WordPress nonce's lifetime, silently dropping every decision
938 * logged from that cached response. Generating a nonce isn't a sensitive
939 * action in itself (the same thing any login form does for a logged-out
940 * visitor), so this endpoint needs no authentication of its own.
941 *
942 * @return void
943 */
944 public function get_log_nonce_callback() {
945 wp_send_json_success( array( 'nonce' => wp_create_nonce( self::NONCE_ACTION ) ) );
946 }
947
948 /**
949 * AJAX callback: logs the visitor's decision in the aggregate accepted/rejected counters.
950 *
951 * This is a best-effort, lightweight aggregate stat, not a precise per-visitor
952 * metering system — the module explicitly renders cache-neutral HTML (see
953 * render_banner()), so there is no page-embedded value this endpoint could use
954 * to deduplicate a replayed request without also breaking under a full-page
955 * cache, the same way a one-time token would. The nonce itself is fetched
956 * fresh via get_log_nonce_callback() right before this call, so it stays
957 * valid regardless of how long a cache keeps the page that triggered it.
958 *
959 * @return void
960 */
961 public function log_consent_callback() {
962 if ( ! $this->is_enabled() ) {
963 wp_send_json_error( array( 'message' => __( 'Cookie Notice is disabled.', 'frontblocks' ) ), 403 );
964 }
965
966 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
967
968 if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
969 wp_send_json_error( array( 'message' => __( 'Security check failed.', 'frontblocks' ) ), 403 );
970 }
971
972 $decision = isset( $_POST['decision'] ) ? sanitize_key( wp_unslash( $_POST['decision'] ) ) : '';
973
974 if ( ! in_array( $decision, array( 'accepted', 'rejected' ), true ) ) {
975 wp_send_json_error( array( 'message' => __( 'Invalid decision.', 'frontblocks' ) ), 400 );
976 }
977
978 $this->maybe_increment_stat( $decision );
979
980 wp_send_json_success();
981 }
982
983 /**
984 * Increment the aggregate accepted/rejected counter for a decision.
985 *
986 * Logged-in administrators are excluded so testing the banner doesn't skew stats.
987 *
988 * @param string $decision 'accepted' or 'rejected'.
989 * @return void
990 */
991 private function maybe_increment_stat( $decision ) {
992 if ( current_user_can( 'manage_options' ) ) {
993 return;
994 }
995
996 $option_name = 'accepted' === $decision ? self::STATS_OPTION_ACCEPTED : self::STATS_OPTION_REJECTED;
997
998 $this->increment_option_atomically( $option_name );
999 }
1000
1001 /**
1002 * Increment an integer option by 1 directly in the database.
1003 *
1004 * A plain get_option()/update_option() round trip races under concurrent
1005 * requests — two visitors deciding at the same moment can both read the same
1006 * value and one increment gets overwritten. A single UPDATE ... SET value = value + 1
1007 * lets the database serialize concurrent increments instead.
1008 *
1009 * @param string $option_name Option name storing a plain integer.
1010 * @return void
1011 */
1012 private function increment_option_atomically( $option_name ) {
1013 global $wpdb;
1014
1015 $sql = $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s", $option_name );
1016
1017 $updated = $wpdb->query( $sql );
1018
1019 if ( ! $updated ) {
1020 // First time this counter is created. add_option() returns false if another
1021 // request created the row first — in that case fall back to the atomic UPDATE
1022 // so this increment isn't silently dropped.
1023 if ( ! add_option( $option_name, 1, '', 'no' ) ) {
1024 $wpdb->query( $sql );
1025 }
1026 }
1027
1028 wp_cache_delete( $option_name, 'options' );
1029 }
1030
1031 /**
1032 * Validate a Google Tag Manager container ID (e.g. GTM-XXXXXXX).
1033 *
1034 * @param string $value Raw value.
1035 * @return string Sanitized ID, or an empty string when it doesn't match the expected format.
1036 */
1037 private function sanitize_gtm_id( $value ) {
1038 $value = strtoupper( trim( (string) $value ) );
1039
1040 return preg_match( '/^GTM-[A-Z0-9]+$/', $value ) ? $value : '';
1041 }
1042
1043 /**
1044 * Validate a GA4 Measurement ID (e.g. G-XXXXXXXXXX).
1045 *
1046 * @param string $value Raw value.
1047 * @return string Sanitized ID, or an empty string when it doesn't match the expected format.
1048 */
1049 private function sanitize_ga4_id( $value ) {
1050 $value = strtoupper( trim( (string) $value ) );
1051
1052 return preg_match( '/^G-[A-Z0-9]+$/', $value ) ? $value : '';
1053 }
1054
1055 /**
1056 * Validate a stored tracking integration type against the ones this
1057 * plugin actually knows how to inject.
1058 *
1059 * @param string $value Raw stored value.
1060 * @return string One of TRACKING_TYPES, or '' if unrecognized.
1061 */
1062 private function sanitize_tracking_type( $value ) {
1063 return in_array( $value, self::TRACKING_TYPES, true ) ? $value : '';
1064 }
1065
1066 /**
1067 * Detect which supported tool a pasted tracking snippet belongs to, and
1068 * pull out the single id/code it needs to be rebuilt later.
1069 *
1070 * The admin settings field only asks for "paste your tracking snippet" —
1071 * it deliberately doesn't ask which tool or product it's from, so this is
1072 * what tells them apart. Order matters: Clientify's two products are only
1073 * distinguishable by which loader URL they reference, so both are checked
1074 * before falling through to Brevo.
1075 *
1076 * Public static — also used by the admin settings page to detect what was
1077 * just pasted and by the settings sanitizer to decide what to store.
1078 *
1079 * @param string $raw Raw snippet as pasted by the admin.
1080 * @return array{type: string, id: string}|null The detected type/id pair, or null if unrecognized.
1081 */
1082 public static function detect_tracking_snippet( $raw ) {
1083 $raw = (string) $raw;
1084
1085 if ( '' === trim( $raw ) ) {
1086 return null;
1087 }
1088
1089 if ( preg_match( '#analyticsplusdev\.clientify\.net/analytics_plus/pixel/([A-Za-z0-9_-]+)#', $raw, $matches ) ) {
1090 return array(
1091 'type' => 'clientify_analytics_plus',
1092 'id' => $matches[1],
1093 );
1094 }
1095
1096 // The classic snippet calls a generic ana(...) dispatcher with the
1097 // method name as its first string argument — e.g.
1098 // ana('setTrackingCode', 'CF-12345-12345-ABCDE') — not a
1099 // setTrackingCode(...) call itself.
1100 if ( false !== strpos( $raw, 'analytics.clientify.net/tracker.js' )
1101 && preg_match( '#ana\(\s*[\'"]setTrackingCode[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]#', $raw, $matches )
1102 ) {
1103 return array(
1104 'type' => 'clientify_analytics_classic',
1105 'id' => $matches[1],
1106 );
1107 }
1108
1109 if ( false !== strpos( $raw, 'cdn.brevo.com/js/sdk-loader.js' )
1110 && preg_match( '#client_key\s*:\s*[\'"]([^\'"]+)[\'"]#', $raw, $matches )
1111 ) {
1112 return array(
1113 'type' => 'brevo',
1114 'id' => $matches[1],
1115 );
1116 }
1117
1118 return null;
1119 }
1120
1121 /**
1122 * Return the safe, normalized integration records stored in the settings.
1123 *
1124 * Raw tracking code is never persisted. The legacy single-integration
1125 * options are read only as a migration path and are converted on the next
1126 * settings save.
1127 *
1128 * @param array $options FrontBlocks settings.
1129 * @return array<int, array{type: string, id: string}> Supported integration records.
1130 */
1131 public static function get_tracking_integrations( $options ) {
1132 if ( ! is_array( $options ) ) {
1133 return array();
1134 }
1135
1136 $stored = array_key_exists( 'cookie_notice_tracking_integrations', $options ) ? $options['cookie_notice_tracking_integrations'] : null;
1137 if ( null === $stored ) {
1138 $legacy_type = $options['cookie_notice_tracking_type'] ?? '';
1139 $legacy_id = $options['cookie_notice_tracking_id'] ?? '';
1140 $stored = array(
1141 array(
1142 'type' => $legacy_type,
1143 'id' => $legacy_id,
1144 ),
1145 );
1146 }
1147
1148 if ( ! is_array( $stored ) ) {
1149 return array();
1150 }
1151
1152 $integrations = array();
1153 foreach ( $stored as $integration ) {
1154 if ( ! is_array( $integration ) ) {
1155 continue;
1156 }
1157
1158 $type = $integration['type'] ?? '';
1159 $id = sanitize_text_field( $integration['id'] ?? '' );
1160 if ( in_array( $type, self::TRACKING_TYPES, true ) && '' !== $id ) {
1161 $integrations[ $type ] = array(
1162 'type' => $type,
1163 'id' => $id,
1164 );
1165 }
1166 }
1167
1168 return array_values( $integrations );
1169 }
1170
1171 /**
1172 * The consent category an integration falls under by default, for
1173 * FrontBlocks PRO's Advanced Cookie Management to key its per-category
1174 * gating on — this plugin's own gating stays a plain accept/reject
1175 * binary regardless of category.
1176 *
1177 * @param string $type Integration type: 'gtm', 'ga4', or one of TRACKING_TYPES.
1178 * @return string Category slug, e.g. 'analytics' or 'marketing'.
1179 */
1180 public static function get_integration_default_category( $type ) {
1181 $categories = array(
1182 'gtm' => 'analytics',
1183 'ga4' => 'analytics',
1184 'clientify_analytics_plus' => 'marketing',
1185 'clientify_analytics_classic' => 'marketing',
1186 'brevo' => 'marketing',
1187 );
1188
1189 $category = $categories[ $type ] ?? 'marketing';
1190
1191 /**
1192 * Filters which consent category an integration defaults to.
1193 *
1194 * FrontBlocks PRO's Advanced Cookie Management reads this to decide
1195 * which category gate (e.g. "Analytics" vs "Marketing") an
1196 * integration falls under when the visitor granted only some
1197 * categories, instead of this plugin's own binary accept/reject.
1198 *
1199 * @param string $category Default category slug ('analytics' or 'marketing').
1200 * @param string $type Integration type ('gtm', 'ga4', 'clientify_analytics_plus', 'clientify_analytics_classic', 'brevo').
1201 */
1202 return apply_filters( 'frbl_cookie_notice_integration_category', $category, $type );
1203 }
1204 }
1205