From 7c0b8d34821fb697715af3c63e7c766e02bcc003 Mon Sep 17 00:00:00 2001
From: castellon
Date: Wed, 30 Sep 2026 13:12:13 +0200
Subject: [PATCH 1/3] Add "Customize cookie settings" button and preferences
panel (#11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a real
+
+
+
+
+
+
+
+
+
+ assertStringNotContainsString( 'data-frcn-cookie-action="customize"', $html );
}
+
+ /**
+ * Without a real toggle, the panel looks identical no matter what Accept
+ * all/Reject all/Save changes did — a visitor gets no visible
+ * confirmation of the effect of their choice. The Free tier ships one
+ * real, native checkbox (data-frcn-category="optional") that
+ * frontconsent-cookie-notice.js syncs from the actual consent cookie on
+ * every open and reads back on Save changes/Accept all, so the panel
+ * always reflects what was actually recorded.
+ */
+ public function test_panel_includes_a_real_optional_category_toggle() {
+ update_option( 'frontconsent_settings', array( 'enable_cookie_notice' => true ) );
+
+ $html = $this->render_banner_html();
+
+ $this->assertMatchesRegularExpression(
+ '/]*type="checkbox"[^>]*data-frcn-category="optional"/',
+ $html
+ );
+ $this->assertStringContainsString( 'Analytics & Marketing', $html );
+
+ // Must appear after the static necessary block and before the PRO
+ // extension point, matching where render_preferences_panel() prints it.
+ $necessary_pos = strpos( $html, 'frcn-cookie-preferences__category--necessary' );
+ $toggle_pos = strpos( $html, 'data-frcn-category="optional"' );
+
+ $this->assertNotFalse( $necessary_pos );
+ $this->assertNotFalse( $toggle_pos );
+ $this->assertGreaterThan( $necessary_pos, $toggle_pos );
+ }
}
From 4bd26044f91a87e266f01a3392663c82fdb07ff2 Mon Sep 17 00:00:00 2001
From: castellon
Date: Wed, 30 Sep 2026 13:54:36 +0200
Subject: [PATCH 3/3] fix: split Analytics and Marketing into two real,
independent toggles
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Analytics and Marketing gated the exact same binary accepted/rejected
decision despite showing as separate concepts ("analytics y marketing
son diferentes"). Replace the single "Analytics & Marketing" checkbox
with two independent ones (data-frcn-category="analytics"/"marketing")
and make the split functionally meaningful end to end:
- frontconsent-cookie-notice.js persists the visitor's actual
per-category selection into a new frontconsent_categories cookie
(JSON, e.g. {"analytics":true,"marketing":false}) alongside the
existing binary consent cookie, and syncs each toggle's checked
state from it independently when the panel opens.
- CookieNotice::get_config_callback() reads that cookie server-side
and builds a real allowedCategories map (still filterable via
frcn_cookie_notice_allowed_tracking_categories for PRO), instead of
always returning null. frcnCookieNoticeInject() already gated GTM/
GA4 ('analytics') separately from Clientify/Brevo/OpenAI ads
('marketing') on this map — it just needed a real value to gate on.
- Backward compatible: a visitor who accepted before this cookie
existed has no frontconsent_categories cookie yet; both categories
default to allowed for them (matching the previous allow-all
behavior) until they make an explicit per-category choice.
Updates existing PHPUnit/JS tests for the new markup and cookie logic,
and adds coverage for the malformed-cookie fallback and the backward-
compat default.
Co-Authored-By: Claude Sonnet 5
---
.../frontconsent-cookie-notice.js | 90 +++++++++-
.../frontconsent-cookie-notice.min.js | 2 +-
docs/preferences-panel-hooks.md | 57 +++++-
includes/Frontend/CookieNotice.php | 146 +++++++++++++---
tests/Unit/CookieNoticeConfigCallbackTest.php | 110 +++++++++++-
.../Unit/CookieNoticePreferencesPanelTest.php | 58 +++---
.../js/cookie-notice-focus-management.test.js | 165 +++++++++++++++++-
tests/js/cookie-notice-injection.test.js | 29 ++-
8 files changed, 593 insertions(+), 64 deletions(-)
diff --git a/assets/cookie-notice/frontconsent-cookie-notice.js b/assets/cookie-notice/frontconsent-cookie-notice.js
index 4a9e484..79a071f 100644
--- a/assets/cookie-notice/frontconsent-cookie-notice.js
+++ b/assets/cookie-notice/frontconsent-cookie-notice.js
@@ -326,6 +326,26 @@
'; path=' + frcnCookieNotice.cookiePath + '; max-age=' + maxAge + '; SameSite=Lax' + secure;
}
+ /**
+ * Persist the visitor's actual per-category selection (e.g.
+ * {"analytics":true,"marketing":false}) into its own cookie, right
+ * alongside the binary consent cookie above — same path/max-age/
+ * SameSite conventions, just a separate cookie name (see
+ * CookieNotice::get_categories_cookie_name()). This is what lets
+ * get_config_callback() build a real allowedCategories map server-side
+ * on the next request, purely from the normal cookie header, with no
+ * new AJAX plumbing needed.
+ *
+ * @param {Object} categories Category slug => bool map (see collectPreferencesCategories()).
+ */
+ function setCategoriesCookie(categories) {
+ var maxAge = parseInt(frcnCookieNotice.expirationDays, 10) * 24 * 60 * 60;
+ var secure = window.location.protocol === 'https:' ? '; Secure' : '';
+
+ document.cookie = frcnCookieNotice.categoriesCookieName + '=' + encodeURIComponent(JSON.stringify(categories)) +
+ '; path=' + frcnCookieNotice.cookiePath + '; max-age=' + maxAge + '; SameSite=Lax' + secure;
+ }
+
function updateConsentMode(decision) {
var granted = decision === 'accepted' ? 'granted' : 'denied';
@@ -418,7 +438,20 @@
decided = true;
+ // The banner's own plain Accept/Reject buttons call this with no
+ // `categories` argument at all — they still must record an
+ // explicit, real per-category state (both allowed on Accept, both
+ // denied on Reject) rather than leaving the categories cookie
+ // stale or absent, since get_config_callback() treats "cookie
+ // present" as "the visitor made an explicit per-category choice".
+ var categoriesToStore = categories || {
+ necessary: true,
+ analytics: decision === 'accepted',
+ marketing: decision === 'accepted'
+ };
+
setConsentCookie(decision);
+ setCategoriesCookie(categoriesToStore);
updateConsentMode(decision);
announce(decision === 'accepted' ? i18n.accepted : i18n.rejected);
hideBannerIfPresent();
@@ -598,13 +631,20 @@
}
/**
- * Reflects the visitor's current, actually-recorded decision onto
- * every [data-frcn-category] toggle in the panel every time it opens
- * — otherwise the panel looks identical no matter what Accept
- * all/Reject all/Save changes previously did, leaving no visible
- * confirmation of what happened. The consent cookie (accepted/rejected)
- * remains the single source of truth this reads from; toggles are
- * purely a reflection of it, not a second, independently-tracked state.
+ * Reflects the visitor's current, actually-recorded per-category
+ * decision onto each [data-frcn-category] toggle independently every
+ * time the panel opens — reading the frontconsent_categories cookie
+ * (written by setCategoriesCookie() above), not the single binary
+ * consent cookie, so unchecking Marketing while leaving Analytics
+ * checked is reflected as two different states, not one flag mirrored
+ * onto both toggles.
+ *
+ * Backward compatibility: a visitor who accepted before this cookie
+ * existed has no frontconsent_categories cookie yet — for them,
+ * 'accepted' previously meant everything was allowed, so both
+ * toggles show checked (matching that same reality) until they make
+ * an explicit per-category choice here. Absent or rejected: both
+ * unchecked.
*/
function syncPreferencesToggles() {
if (!preferencesPanel) {
@@ -612,10 +652,38 @@
}
var accepted = readCookie(frcnCookieNotice.cookieName) === 'accepted';
+ var storedCategoriesRaw = readCookie(frcnCookieNotice.categoriesCookieName);
+ var storedCategories = null;
+
+ if (storedCategoriesRaw) {
+ try {
+ var parsed = JSON.parse(storedCategoriesRaw);
+
+ if (parsed && typeof parsed === 'object') {
+ storedCategories = parsed;
+ }
+ } catch (e) {
+ // Malformed/tampered cookie value: treat it the same as
+ // no categories cookie at all (the accepted/rejected
+ // fallback below).
+ storedCategories = null;
+ }
+ }
+
var toggles = preferencesPanel.querySelectorAll('[data-frcn-category]');
Array.prototype.forEach.call(toggles, function (toggle) {
- toggle.checked = accepted;
+ var category = toggle.getAttribute('data-frcn-category');
+
+ if (storedCategories && Object.prototype.hasOwnProperty.call(storedCategories, category)) {
+ toggle.checked = !!storedCategories[category];
+ } else {
+ // No explicit stored value for this category (cookie
+ // missing/unparseable, or simply doesn't mention it yet):
+ // fall back to the binary cookie's own "everything was
+ // allowed/nothing was allowed" reality.
+ toggle.checked = accepted;
+ }
});
}
@@ -722,7 +790,11 @@
if (panelRejectBtn) {
panelRejectBtn.addEventListener('click', function () {
- handleDecision('rejected', { necessary: true });
+ // Explicit, real "both off" state — not an ambiguous
+ // absence of analytics/marketing keys — so the categories
+ // cookie (and the server-side allowedCategories it drives)
+ // reflects an actual decision rather than "unspecified".
+ handleDecision('rejected', { necessary: true, analytics: false, marketing: false });
});
}
diff --git a/assets/cookie-notice/frontconsent-cookie-notice.min.js b/assets/cookie-notice/frontconsent-cookie-notice.min.js
index ba11dc1..7248d0e 100644
--- a/assets/cookie-notice/frontconsent-cookie-notice.min.js
+++ b/assets/cookie-notice/frontconsent-cookie-notice.min.js
@@ -1 +1 @@
-!function(){"use strict";function e(e){var n=document.cookie.match(new RegExp("(?:^|; )"+e+"=([^;]*)"));if(!n)return"";try{return decodeURIComponent(n[1])}catch(e){return""}}function n(){var e=new FormData;e.append("action","frcn_get_cookie_notice_config"),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:e}).then(function(e){return e.json()}).then(function(e){e&&e.success&&e.data&&window.frcnCookieNoticeInject&&window.frcnCookieNoticeInject(e.data.gtmId,e.data.ga4Id,e.data.trackingIntegrations,e.data.allowedCategories)}).catch(function(){})}function t(){if(!window.frcnCookieNoticeBootstrapped){window.frcnCookieNoticeInject=window.frcnCookieNoticeInject||function(e,n,t,o){var c=function(e){return!o||!!o[e]};if(e&&c("analytics")){window.dataLayer=window.dataLayer||[],window.dataLayer.push({"gtm.start":(new Date).getTime(),event:"gtm.js"});var i=document.createElement("script");i.async=!0,i.src="https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(e),document.head.appendChild(i)}if(n&&c("analytics")){var a=document.createElement("script");a.async=!0,a.src="https://www.googletagmanager.com/gtag/js?id="+encodeURIComponent(n),document.head.appendChild(a),window.dataLayer=window.dataLayer||[],window.gtag=window.gtag||function(){window.dataLayer.push(arguments)},window.gtag("js",new Date),window.gtag("config",n)}Array.isArray(t)||(t=[]),t.forEach(function(e){var n=e&&e.type?e.type:"",t=e&&e.id?e.id:"",o=e&&e.category?e.category:"marketing";if(t&&c(o))if("clientify_analytics_plus"===n){var i=document.createElement("script");i.defer=!0,i.src="https://analyticsplusdev.clientify.net/analytics_plus/pixel/"+encodeURIComponent(t),document.head.appendChild(i)}else if("clientify_analytics_classic"===n)!function(e,n,t,o){n[o]=n[o]||function(){(n[o].q=n[o].q||[]).push(arguments)};var c=e.createElement("script"),i=e.getElementsByTagName("script")[0];c.async=1,c.src="https://analytics.clientify.net/tracker.js",i.parentNode.insertBefore(c,i)}(document,window,0,"ana"),window.ana("setTrackerUrl","https://analytics.clientify.net"),window.ana("setTrackingCode",t),window.ana("trackPageview");else if("brevo"===n){var a=document.createElement("script");a.async=!0,a.src="https://cdn.brevo.com/js/sdk-loader.js",document.head.appendChild(a),window.Brevo=window.Brevo||[],window.Brevo.push(["init",{client_key:t}])}else if("openai_chatgpt_ads"===n){if(!window.oaiq){window.oaiq=function(){window.oaiq.q.push(arguments)},window.oaiq.q=[];var r=document.createElement("script");r.async=!0,r.src="https://bzrcdn.openai.com/sdk/oaiq.min.js",document.head.appendChild(r)}window.oaiq("init",{pixelId:t,debug:!0})}else"function"==typeof window.frcnCookieNoticeInjectIntegration?window.frcnCookieNoticeInjectIntegration(e):(window.frcnCookieNoticePendingIntegrations=window.frcnCookieNoticePendingIntegrations||[],window.frcnCookieNoticePendingIntegrations.push(e))})};var t="function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale();"accepted"!==e(frcnCookieNotice.cookieName)||t||n(),window.frcnCookieNoticeBootstrapped=!0}}"undefined"!=typeof frcnCookieNotice&&function(){window.dataLayer=window.dataLayer||[],window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};var n="accepted"===e(frcnCookieNotice.cookieName)?"granted":"denied",t={ad_storage:n,ad_user_data:n,ad_personalization:n,analytics_storage:n};if("function"==typeof window.frcnCookieNoticeConsentModeState){var o=window.frcnCookieNoticeConsentModeState();o&&(t=o)}"function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale()&&(t={ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});window.gtag("consent","default",t)}(),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",i):i();var o=!1;function c(n){var t=document.getElementById("frcn-cookie-reopen");if(t){var c=e(frcnCookieNotice.cookieName);"accepted"!==c&&"rejected"!==c||(t.hidden=!1,o||(o=!0,t.addEventListener("click",function(){"function"==typeof n&&n(t)})))}}function i(){if("undefined"!=typeof frcnCookieNotice){var o="undefined"!=typeof frcnCookieNoticeA11y?frcnCookieNoticeA11y:{bannerOpened:"Cookie consent banner opened.",accepted:"Cookies accepted.",rejected:"Cookies rejected."},i=document.getElementById("frcn-cookie-notice-announcer"),a=!1,r=document.getElementById("frcn-cookie-notice"),d=!!r&&r.classList.contains("frcn-cookie-notice--popup"),s=document.activeElement,f=!1,u=document.getElementById("frcn-cookie-preferences"),l=null,p=!1;if(u){var w=u.querySelector('[data-frcn-cookie-action="close-preferences"]'),y=u.querySelector('[data-frcn-cookie-action="accept"]'),k=u.querySelector('[data-frcn-cookie-action="reject"]'),m=u.querySelector('[data-frcn-cookie-action="save"]');w&&w.addEventListener("click",function(){b()}),y&&y.addEventListener("click",function(){var e=q();Object.keys(e).forEach(function(n){e[n]=!0}),N("accepted",e)}),k&&k.addEventListener("click",function(){N("rejected",{necessary:!0})}),m&&m.addEventListener("click",function(){var e=q();N(Object.keys(e).some(function(n){return"necessary"!==n&&e[n]})?"accepted":"rejected",e)})}if(t(),c(I),r&&!function(){var n=e(frcnCookieNotice.cookieName);if("function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale())return!1;if("accepted"===n||"rejected"===n)return r.style.display="none",!0;return!1}()){var v,g=r.querySelector('[data-frcn-cookie-action="accept"]'),C=r.querySelector('[data-frcn-cookie-action="reject"]'),h=r.querySelector('[data-frcn-cookie-action="customize"]');v=d?150:20,window.setTimeout(function(){if(r.classList.remove("frcn-cookie-notice--init"),E(o.bannerOpened),d){document.body.classList.add("frcn-cookie-notice-lock-scroll");var e=r.querySelector('[data-frcn-cookie-action="accept"]');e&&e.focus({preventScroll:!0}),document.addEventListener("keydown",_),f=!0}},v),g&&g.addEventListener("click",function(e){e.preventDefault(),N("accepted")}),C&&C.addEventListener("click",function(e){e.preventDefault(),N("rejected")}),h&&h.addEventListener("click",function(){var e;I(h);try{e=new CustomEvent("frcnCookieNoticeCustomize")}catch(n){(e=document.createEvent("CustomEvent")).initCustomEvent("frcnCookieNoticeCustomize",!0,!0,null)}document.dispatchEvent(e)})}}function E(e){i&&(i.textContent=e)}function N(e,t){a||(a=!0,function(e){var n=24*parseInt(frcnCookieNotice.expirationDays,10)*60*60,t="https:"===window.location.protocol?"; Secure":"";document.cookie=frcnCookieNotice.cookieName+"="+e+"; path="+frcnCookieNotice.cookiePath+"; max-age="+n+"; SameSite=Lax"+t}(e),function(e){var n="accepted"===e?"granted":"denied";window.dataLayer=window.dataLayer||[],window.gtag=window.gtag||function(){window.dataLayer.push(arguments)},window.gtag("consent","update",{ad_storage:n,ad_user_data:n,ad_personalization:n,analytics_storage:n})}(e),E("accepted"===e?o.accepted:o.rejected),function(){if(!r)return;r.classList.add("frcn-cookie-notice--hidden"),document.body.classList.remove("frcn-cookie-notice-lock-scroll"),f&&(document.removeEventListener("keydown",_),f=!1);d&&s&&"function"==typeof s.focus&&s.focus({preventScroll:!0});window.setTimeout(function(){r.parentNode&&r.parentNode.removeChild(r)},300)}(),b(),c(I),function(e){var n;try{n=new CustomEvent("frcnCookieConsent",{detail:{consent:e}})}catch(t){(n=document.createEvent("CustomEvent")).initCustomEvent("frcnCookieConsent",!0,!0,{consent:e})}document.dispatchEvent(n)}(e),function(e,n){var t=new FormData;t.append("action","frcn_get_cookie_notice_log_nonce"),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:t}).then(function(e){return e.json()}).then(function(t){if(t&&t.success&&t.data){var o=new FormData;return o.append("action","frcn_log_cookie_consent"),o.append("nonce",t.data.nonce),o.append("decision",e),n&&o.append("categories",JSON.stringify(n)),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:o})}}).catch(function(){})}(e,t),"accepted"===e&&n())}function L(e){return Array.prototype.slice.call(e.querySelectorAll('a[href], button, input, [tabindex]:not([tabindex="-1"])'))}function _(e){u&&!u.hidden||("Tab"===e.key?function(e){var n=L(r);if(n.length){var t=n[0],o=n[n.length-1];e.shiftKey&&document.activeElement===t?(e.preventDefault(),o.focus()):e.shiftKey||document.activeElement!==o||(e.preventDefault(),t.focus())}}(e):"Escape"!==e.key&&"Esc"!==e.key||(e.preventDefault(),N("rejected")))}function j(){return L(u)}function S(e){"Tab"===e.key?function(e){var n=j();if(n.length){var t=n[0],o=n[n.length-1];e.shiftKey&&document.activeElement===t?(e.preventDefault(),o.focus()):e.shiftKey||document.activeElement!==o||(e.preventDefault(),t.focus())}}(e):"Escape"!==e.key&&"Esc"!==e.key||(e.preventDefault(),b())}function I(n){if(u){a=!1,function(){if(u){var n="accepted"===e(frcnCookieNotice.cookieName),t=u.querySelectorAll("[data-frcn-category]");Array.prototype.forEach.call(t,function(e){e.checked=n})}}(),l=n||document.activeElement,u.hidden=!1,document.body.classList.add("frcn-cookie-notice-lock-scroll");var t=j();t.length&&t[0].focus({preventScroll:!0}),p||(document.addEventListener("keydown",S),p=!0),E(o.bannerOpened)}}function b(){u&&!u.hidden&&(u.hidden=!0,d&&r&&!r.classList.contains("frcn-cookie-notice--hidden")||document.body.classList.remove("frcn-cookie-notice-lock-scroll"),p&&(document.removeEventListener("keydown",S),p=!1),l&&"function"==typeof l.focus&&l.focus({preventScroll:!0}),l=null)}function q(){var e={necessary:!0};if(!u)return e;var n=u.querySelectorAll("[data-frcn-category]");return Array.prototype.forEach.call(n,function(n){e[n.getAttribute("data-frcn-category")]=!!n.checked}),e}}}();
\ No newline at end of file
+!function(){"use strict";function e(e){var n=document.cookie.match(new RegExp("(?:^|; )"+e+"=([^;]*)"));if(!n)return"";try{return decodeURIComponent(n[1])}catch(e){return""}}function n(){var e=new FormData;e.append("action","frcn_get_cookie_notice_config"),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:e}).then(function(e){return e.json()}).then(function(e){e&&e.success&&e.data&&window.frcnCookieNoticeInject&&window.frcnCookieNoticeInject(e.data.gtmId,e.data.ga4Id,e.data.trackingIntegrations,e.data.allowedCategories)}).catch(function(){})}function t(){if(!window.frcnCookieNoticeBootstrapped){window.frcnCookieNoticeInject=window.frcnCookieNoticeInject||function(e,n,t,o){var c=function(e){return!o||!!o[e]};if(e&&c("analytics")){window.dataLayer=window.dataLayer||[],window.dataLayer.push({"gtm.start":(new Date).getTime(),event:"gtm.js"});var i=document.createElement("script");i.async=!0,i.src="https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(e),document.head.appendChild(i)}if(n&&c("analytics")){var a=document.createElement("script");a.async=!0,a.src="https://www.googletagmanager.com/gtag/js?id="+encodeURIComponent(n),document.head.appendChild(a),window.dataLayer=window.dataLayer||[],window.gtag=window.gtag||function(){window.dataLayer.push(arguments)},window.gtag("js",new Date),window.gtag("config",n)}Array.isArray(t)||(t=[]),t.forEach(function(e){var n=e&&e.type?e.type:"",t=e&&e.id?e.id:"",o=e&&e.category?e.category:"marketing";if(t&&c(o))if("clientify_analytics_plus"===n){var i=document.createElement("script");i.defer=!0,i.src="https://analyticsplusdev.clientify.net/analytics_plus/pixel/"+encodeURIComponent(t),document.head.appendChild(i)}else if("clientify_analytics_classic"===n)!function(e,n,t,o){n[o]=n[o]||function(){(n[o].q=n[o].q||[]).push(arguments)};var c=e.createElement("script"),i=e.getElementsByTagName("script")[0];c.async=1,c.src="https://analytics.clientify.net/tracker.js",i.parentNode.insertBefore(c,i)}(document,window,0,"ana"),window.ana("setTrackerUrl","https://analytics.clientify.net"),window.ana("setTrackingCode",t),window.ana("trackPageview");else if("brevo"===n){var a=document.createElement("script");a.async=!0,a.src="https://cdn.brevo.com/js/sdk-loader.js",document.head.appendChild(a),window.Brevo=window.Brevo||[],window.Brevo.push(["init",{client_key:t}])}else if("openai_chatgpt_ads"===n){if(!window.oaiq){window.oaiq=function(){window.oaiq.q.push(arguments)},window.oaiq.q=[];var r=document.createElement("script");r.async=!0,r.src="https://bzrcdn.openai.com/sdk/oaiq.min.js",document.head.appendChild(r)}window.oaiq("init",{pixelId:t,debug:!0})}else"function"==typeof window.frcnCookieNoticeInjectIntegration?window.frcnCookieNoticeInjectIntegration(e):(window.frcnCookieNoticePendingIntegrations=window.frcnCookieNoticePendingIntegrations||[],window.frcnCookieNoticePendingIntegrations.push(e))})};var t="function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale();"accepted"!==e(frcnCookieNotice.cookieName)||t||n(),window.frcnCookieNoticeBootstrapped=!0}}"undefined"!=typeof frcnCookieNotice&&function(){window.dataLayer=window.dataLayer||[],window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};var n="accepted"===e(frcnCookieNotice.cookieName)?"granted":"denied",t={ad_storage:n,ad_user_data:n,ad_personalization:n,analytics_storage:n};if("function"==typeof window.frcnCookieNoticeConsentModeState){var o=window.frcnCookieNoticeConsentModeState();o&&(t=o)}"function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale()&&(t={ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});window.gtag("consent","default",t)}(),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",i):i();var o=!1;function c(n){var t=document.getElementById("frcn-cookie-reopen");if(t){var c=e(frcnCookieNotice.cookieName);"accepted"!==c&&"rejected"!==c||(t.hidden=!1,o||(o=!0,t.addEventListener("click",function(){"function"==typeof n&&n(t)})))}}function i(){if("undefined"!=typeof frcnCookieNotice){var o="undefined"!=typeof frcnCookieNoticeA11y?frcnCookieNoticeA11y:{bannerOpened:"Cookie consent banner opened.",accepted:"Cookies accepted.",rejected:"Cookies rejected."},i=document.getElementById("frcn-cookie-notice-announcer"),a=!1,r=document.getElementById("frcn-cookie-notice"),d=!!r&&r.classList.contains("frcn-cookie-notice--popup"),s=document.activeElement,f=!1,u=document.getElementById("frcn-cookie-preferences"),l=null,p=!1;if(u){var w=u.querySelector('[data-frcn-cookie-action="close-preferences"]'),y=u.querySelector('[data-frcn-cookie-action="accept"]'),k=u.querySelector('[data-frcn-cookie-action="reject"]'),m=u.querySelector('[data-frcn-cookie-action="save"]');w&&w.addEventListener("click",function(){b()}),y&&y.addEventListener("click",function(){var e=q();Object.keys(e).forEach(function(n){e[n]=!0}),E("accepted",e)}),k&&k.addEventListener("click",function(){E("rejected",{necessary:!0,analytics:!1,marketing:!1})}),m&&m.addEventListener("click",function(){var e=q();E(Object.keys(e).some(function(n){return"necessary"!==n&&e[n]})?"accepted":"rejected",e)})}if(t(),c(I),r&&!function(){var n=e(frcnCookieNotice.cookieName);if("function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale())return!1;if("accepted"===n||"rejected"===n)return r.style.display="none",!0;return!1}()){var g,v=r.querySelector('[data-frcn-cookie-action="accept"]'),C=r.querySelector('[data-frcn-cookie-action="reject"]'),h=r.querySelector('[data-frcn-cookie-action="customize"]');g=d?150:20,window.setTimeout(function(){if(r.classList.remove("frcn-cookie-notice--init"),N(o.bannerOpened),d){document.body.classList.add("frcn-cookie-notice-lock-scroll");var e=r.querySelector('[data-frcn-cookie-action="accept"]');e&&e.focus({preventScroll:!0}),document.addEventListener("keydown",S),f=!0}},g),v&&v.addEventListener("click",function(e){e.preventDefault(),E("accepted")}),C&&C.addEventListener("click",function(e){e.preventDefault(),E("rejected")}),h&&h.addEventListener("click",function(){var e;I(h);try{e=new CustomEvent("frcnCookieNoticeCustomize")}catch(n){(e=document.createEvent("CustomEvent")).initCustomEvent("frcnCookieNoticeCustomize",!0,!0,null)}document.dispatchEvent(e)})}}function N(e){i&&(i.textContent=e)}function E(e,t){if(!a){a=!0;var i=t||{necessary:!0,analytics:"accepted"===e,marketing:"accepted"===e};!function(e){var n=24*parseInt(frcnCookieNotice.expirationDays,10)*60*60,t="https:"===window.location.protocol?"; Secure":"";document.cookie=frcnCookieNotice.cookieName+"="+e+"; path="+frcnCookieNotice.cookiePath+"; max-age="+n+"; SameSite=Lax"+t}(e),function(e){var n=24*parseInt(frcnCookieNotice.expirationDays,10)*60*60,t="https:"===window.location.protocol?"; Secure":"";document.cookie=frcnCookieNotice.categoriesCookieName+"="+encodeURIComponent(JSON.stringify(e))+"; path="+frcnCookieNotice.cookiePath+"; max-age="+n+"; SameSite=Lax"+t}(i),function(e){var n="accepted"===e?"granted":"denied";window.dataLayer=window.dataLayer||[],window.gtag=window.gtag||function(){window.dataLayer.push(arguments)},window.gtag("consent","update",{ad_storage:n,ad_user_data:n,ad_personalization:n,analytics_storage:n})}(e),N("accepted"===e?o.accepted:o.rejected),function(){if(!r)return;r.classList.add("frcn-cookie-notice--hidden"),document.body.classList.remove("frcn-cookie-notice-lock-scroll"),f&&(document.removeEventListener("keydown",S),f=!1);d&&s&&"function"==typeof s.focus&&s.focus({preventScroll:!0});window.setTimeout(function(){r.parentNode&&r.parentNode.removeChild(r)},300)}(),b(),c(I),function(e){var n;try{n=new CustomEvent("frcnCookieConsent",{detail:{consent:e}})}catch(t){(n=document.createEvent("CustomEvent")).initCustomEvent("frcnCookieConsent",!0,!0,{consent:e})}document.dispatchEvent(n)}(e),function(e,n){var t=new FormData;t.append("action","frcn_get_cookie_notice_log_nonce"),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:t}).then(function(e){return e.json()}).then(function(t){if(t&&t.success&&t.data){var o=new FormData;return o.append("action","frcn_log_cookie_consent"),o.append("nonce",t.data.nonce),o.append("decision",e),n&&o.append("categories",JSON.stringify(n)),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:o})}}).catch(function(){})}(e,t),"accepted"===e&&n()}}function L(e){return Array.prototype.slice.call(e.querySelectorAll('a[href], button, input, [tabindex]:not([tabindex="-1"])'))}function S(e){u&&!u.hidden||("Tab"===e.key?function(e){var n=L(r);if(n.length){var t=n[0],o=n[n.length-1];e.shiftKey&&document.activeElement===t?(e.preventDefault(),o.focus()):e.shiftKey||document.activeElement!==o||(e.preventDefault(),t.focus())}}(e):"Escape"!==e.key&&"Esc"!==e.key||(e.preventDefault(),E("rejected")))}function _(){return L(u)}function j(e){"Tab"===e.key?function(e){var n=_();if(n.length){var t=n[0],o=n[n.length-1];e.shiftKey&&document.activeElement===t?(e.preventDefault(),o.focus()):e.shiftKey||document.activeElement!==o||(e.preventDefault(),t.focus())}}(e):"Escape"!==e.key&&"Esc"!==e.key||(e.preventDefault(),b())}function I(n){if(u){a=!1,function(){if(u){var n="accepted"===e(frcnCookieNotice.cookieName),t=e(frcnCookieNotice.categoriesCookieName),o=null;if(t)try{var c=JSON.parse(t);c&&"object"==typeof c&&(o=c)}catch(e){o=null}var i=u.querySelectorAll("[data-frcn-category]");Array.prototype.forEach.call(i,function(e){var t=e.getAttribute("data-frcn-category");o&&Object.prototype.hasOwnProperty.call(o,t)?e.checked=!!o[t]:e.checked=n})}}(),l=n||document.activeElement,u.hidden=!1,document.body.classList.add("frcn-cookie-notice-lock-scroll");var t=_();t.length&&t[0].focus({preventScroll:!0}),p||(document.addEventListener("keydown",j),p=!0),N(o.bannerOpened)}}function b(){u&&!u.hidden&&(u.hidden=!0,d&&r&&!r.classList.contains("frcn-cookie-notice--hidden")||document.body.classList.remove("frcn-cookie-notice-lock-scroll"),p&&(document.removeEventListener("keydown",j),p=!1),l&&"function"==typeof l.focus&&l.focus({preventScroll:!0}),l=null)}function q(){var e={necessary:!0};if(!u)return e;var n=u.querySelectorAll("[data-frcn-category]");return Array.prototype.forEach.call(n,function(n){e[n.getAttribute("data-frcn-category")]=!!n.checked}),e}}}();
\ No newline at end of file
diff --git a/docs/preferences-panel-hooks.md b/docs/preferences-panel-hooks.md
index 5dd8141..6093b4b 100644
--- a/docs/preferences-panel-hooks.md
+++ b/docs/preferences-panel-hooks.md
@@ -39,12 +39,15 @@ Fires inside the cookie preferences panel (`#frcn-cookie-preferences`),
right after the static, always-on "Strictly necessary" section and before the
Reject all / Save changes / Accept all buttons.
-FrontConsent PRO hooks here to render its own per-category toggles (e.g.
-Preferences, Analytics, Marketing), each with a description and, optionally,
-its cookie list. Nothing is printed here in the Free tier — without PRO (or
-any other add-on) active, only the static "Strictly necessary" block is
-shown, per the issue's requirement that no disabled/teaser UI clutters the
-panel by default.
+FrontConsent PRO hooks here to render its own additional per-category toggles
+(e.g. Preferences), each with a description and, optionally, its cookie list.
+Nothing is printed on this specific action in the Free tier — the Free
+tier's own two built-in toggles (Analytics, Marketing; see "The Free tier's
+own built-in categories" below) are rendered directly in
+`render_preferences_panel()`, before this action fires, not through it —
+without PRO (or any other add-on) active, nothing *extra* is added beyond
+those two, per the issue's requirement that no disabled/teaser UI clutters
+the panel by default.
**Contract for anything hooked here:** any toggle intended to be read back by
"Save changes" must be a checkable control (e.g. ``)
@@ -130,6 +133,48 @@ add_filter( 'frcn_cookie_consent_categories', function ( $categories, $decision
}, 10, 2 );
```
+## The Free tier's own built-in categories: Analytics and Marketing
+
+The Free tier ships two real, independently-controllable toggles in the
+panel — `data-frcn-category="analytics"` and `data-frcn-category="marketing"`
+— not a single combined checkbox, because they gate genuinely different
+integrations (see `CookieNotice::get_integration_default_category()`: GTM/GA4
+default to `'analytics'`; Clientify/Brevo/OpenAI ads default to
+`'marketing'`). Unchecking one and leaving the other checked actually changes
+what loads on the next page load, not just what the panel displays.
+
+This is wired end to end without any PRO add-on:
+
+1. `frontconsent-cookie-notice.js`'s `collectPreferencesCategories()` reads
+ both toggles' checked state on Save changes / Accept all / Reject all.
+2. `setCategoriesCookie()` persists that selection into its own cookie —
+ `frontconsent_categories` (or `frontconsent_categories_` on
+ multisite — see `CookieNotice::get_categories_cookie_name()`) — as JSON,
+ e.g. `{"analytics":true,"marketing":false}`. It's written with the same
+ path/max-age/`SameSite=Lax` conventions as the existing binary consent
+ cookie, just under a different name, and is sent to the server
+ automatically via the normal cookie header (no new AJAX plumbing needed).
+3. `CookieNotice::get_config_callback()` reads that cookie server-side
+ (`get_allowed_categories_default()`), JSON-decodes it defensively (never
+ fatals on malformed/tampered input), and uses it to build the real
+ `allowedCategories` map returned to the frontend — still run through the
+ existing `frcn_cookie_notice_allowed_tracking_categories` filter, so a PRO
+ add-on can still override it.
+4. `frcnCookieNoticeInject()` (in the registered script and its inline
+ wp_head bootstrap copy) was already able to gate on a real
+ `allowedCategories` map — this only had to stop being permanently `null`
+ in the Free tier.
+
+**Backward compatibility:** a visitor who already accepted before this
+per-category cookie existed has no `frontconsent_categories` cookie yet.
+Previously `'accepted'` meant everything loaded (`allowedCategories` was
+always `null`, i.e. allow-all) — so a missing or unparseable/tampered
+categories cookie falls back to allowing both known categories for an
+already-accepted visitor, never regressing them to losing tracking they
+already consented to. Granular blocking only starts once a visitor has
+actually gone through the panel and this cookie exists with real per-category
+data.
+
## Client-side: `frontconsent-cookie-notice.js`
The preferences panel (`#frcn-cookie-preferences`) is printed unconditionally
diff --git a/includes/Frontend/CookieNotice.php b/includes/Frontend/CookieNotice.php
index ada609b..45ae36d 100644
--- a/includes/Frontend/CookieNotice.php
+++ b/includes/Frontend/CookieNotice.php
@@ -240,6 +240,29 @@ private function get_cookie_name() {
return 'frcn_cookie_consent';
}
+ /**
+ * Name of the cookie storing the visitor's actual per-category selection
+ * (e.g. `{"analytics":true,"marketing":false}`), written client-side by
+ * frontconsent-cookie-notice.js right alongside the binary consent cookie
+ * (see get_cookie_name()) whenever a decision is recorded from the
+ * preferences panel. Sent to the server automatically on every request
+ * via the normal cookie header, which is what lets get_config_callback()
+ * build a real allowedCategories map without any new AJAX plumbing.
+ *
+ * Named and scoped the same multisite-aware way as get_cookie_name() —
+ * see that method's own docblock for why the blog ID has to be folded
+ * into the name itself rather than relying on COOKIEPATH.
+ *
+ * @return string
+ */
+ private function get_categories_cookie_name() {
+ if ( is_multisite() ) {
+ return 'frontconsent_categories_' . get_current_blog_id();
+ }
+
+ return 'frontconsent_categories';
+ }
+
/**
* Get the admin-ajax.php URL, forced onto the frontend's own scheme and host.
*
@@ -375,8 +398,9 @@ public function enqueue_assets() {
'frontconsent-cookie-notice',
'frcnCookieNotice',
array(
- 'ajaxUrl' => $this->get_ajax_url(),
- 'cookieName' => $this->get_cookie_name(),
+ 'ajaxUrl' => $this->get_ajax_url(),
+ 'cookieName' => $this->get_cookie_name(),
+ 'categoriesCookieName' => $this->get_categories_cookie_name(),
// Always '/', not COOKIEPATH: COOKIEPATH is derived from the
// Home URL's own path, but this cookie must also be sent to
// the admin-ajax.php request in get_ajax_url(), which lives
@@ -384,14 +408,14 @@ public function enqueue_assets() {
// and Site URL have different paths, COOKIEPATH would scope
// the cookie to a path admin-ajax.php falls outside of, and
// the browser would silently omit it from that request.
- 'cookiePath' => '/',
- 'expirationDays' => $days > 0 ? $days : 365,
+ 'cookiePath' => '/',
+ 'expirationDays' => $days > 0 ? $days : 365,
// The reopen trigger's banner never renders on the policy page
// (see render_banner()) — reloading in place there would leave
// the visitor with no controls at all, so JS instead sends them
// home, where the banner is guaranteed to render.
- 'isPolicyPage' => $this->is_policy_page(),
- 'homeUrl' => home_url( '/' ),
+ 'isPolicyPage' => $this->is_policy_page(),
+ 'homeUrl' => home_url( '/' ),
)
);
@@ -688,33 +712,54 @@ class="frcn-cookie-preferences__close"
-
+
+
+
+
+
+
+
+
-
+
get_allowed_categories_default() );
if ( $this->is_enabled() && $has_tracking_consent ) {
$options = get_option( 'frontconsent_settings', array() );
@@ -1451,6 +1496,61 @@ function ( $integration ) {
wp_send_json_success( $response );
}
+ /**
+ * Build the Free tier's own real, per-category allowedCategories default
+ * — read from the frontconsent_categories cookie a visitor's browser
+ * sends automatically once they've made an explicit choice in the
+ * preferences panel (see get_categories_cookie_name()). This is what
+ * turns the Analytics/Marketing toggles in render_preferences_panel()
+ * into an actually meaningful split: frcnCookieNoticeInject() (in
+ * frontconsent-cookie-notice.js) uses this map to gate GTM/GA4
+ * ('analytics') separately from Clientify/Brevo/OpenAI ads ('marketing').
+ *
+ * Backward compatibility: a visitor who accepted before this per-category
+ * cookie existed has no frontconsent_categories cookie at all yet.
+ * Previously 'accepted' meant everything loaded (allowedCategories was
+ * always null, i.e. allow-all) — so a missing or unparseable/tampered
+ * categories cookie must keep allowing both known categories for an
+ * already-accepted visitor, rather than silently blocking tracking they
+ * already consented to. Granular blocking only starts once a visitor has
+ * actually gone through the panel and this cookie exists with real data.
+ *
+ * @return array Category slug => whether it's allowed.
+ */
+ private function get_allowed_categories_default() {
+ $fallback_allowed = 'accepted' === $this->get_consent();
+ $fallback = array(
+ 'analytics' => $fallback_allowed,
+ 'marketing' => $fallback_allowed,
+ );
+
+ $cookie_name = $this->get_categories_cookie_name();
+
+ if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
+ return $fallback;
+ }
+
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw JSON can't be run through a string sanitizer without corrupting it; every decoded key/value is sanitized individually below (sanitize_key()/boolean cast) before it's ever used.
+ $decoded = json_decode( wp_unslash( $_COOKIE[ $cookie_name ] ), true );
+
+ if ( ! is_array( $decoded ) ) {
+ // Malformed/tampered cookie value (invalid JSON, not an array,
+ // etc.): fall back to the exact same safe default as no cookie at
+ // all, never fatal or warn on untrusted input.
+ return $fallback;
+ }
+
+ $categories = array();
+ foreach ( $decoded as $key => $value ) {
+ $categories[ sanitize_key( (string) $key ) ] = (bool) $value;
+ }
+
+ // Merged onto the fallback (not a full replacement): a cookie that
+ // only ever recorded one category still gets a defined value for the
+ // other one, instead of silently denying it.
+ return array_merge( $fallback, $categories );
+ }
+
/**
* Get the Google tag IDs that Site Kit is configured to place.
*
diff --git a/tests/Unit/CookieNoticeConfigCallbackTest.php b/tests/Unit/CookieNoticeConfigCallbackTest.php
index 1fd768c..0d5529e 100644
--- a/tests/Unit/CookieNoticeConfigCallbackTest.php
+++ b/tests/Unit/CookieNoticeConfigCallbackTest.php
@@ -35,7 +35,7 @@ public function set_up() {
public function tear_down() {
remove_filter( 'wp_die_ajax_handler', array( $this, 'get_die_handler' ) );
remove_filter( 'wp_doing_ajax', '__return_true' );
- unset( $_COOKIE['frcn_cookie_consent'] );
+ unset( $_COOKIE['frcn_cookie_consent'], $_COOKIE['frontconsent_categories'] );
delete_option( 'frontconsent_settings' );
parent::tear_down();
}
@@ -173,4 +173,112 @@ public function test_gtm_id_still_loads_when_site_kit_manages_a_different_contai
delete_option( 'googlesitekit_tagmanager_settings' );
}
+
+ /**
+ * A well-formed frontconsent_categories cookie is decoded and drives
+ * allowedCategories exactly — this is the real, functionally meaningful
+ * gate: analytics allowed, marketing denied.
+ */
+ public function test_allowed_categories_reflect_a_well_formed_categories_cookie() {
+ $_COOKIE['frontconsent_categories'] = wp_json_encode(
+ array(
+ 'analytics' => true,
+ 'marketing' => false,
+ )
+ );
+
+ $response = $this->get_config();
+
+ $this->assertSame(
+ array(
+ 'analytics' => true,
+ 'marketing' => false,
+ ),
+ $response['data']['allowedCategories']
+ );
+ }
+
+ /**
+ * Backward compatibility: a visitor who already accepted before this
+ * per-category cookie existed has no frontconsent_categories cookie at
+ * all. They must not be regressed to losing already-loaded tracking —
+ * allowedCategories falls back to allowing both known categories, the
+ * same "accepted = everything loads" reality as before this feature.
+ */
+ public function test_allowed_categories_default_to_both_allowed_when_no_categories_cookie_exists_but_binary_consent_is_accepted() {
+ unset( $_COOKIE['frontconsent_categories'] );
+
+ $response = $this->get_config();
+
+ $this->assertSame(
+ array(
+ 'analytics' => true,
+ 'marketing' => true,
+ ),
+ $response['data']['allowedCategories']
+ );
+ }
+
+ /**
+ * A malformed/tampered categories cookie (invalid JSON, or valid JSON
+ * that isn't an object/array) must never fatal or warn — it falls back
+ * to the exact same safe default as no cookie at all.
+ */
+ public function test_malformed_categories_cookie_falls_back_to_the_same_default_as_no_cookie() {
+ $_COOKIE['frontconsent_categories'] = 'not-valid-json{{{';
+
+ $response = $this->get_config();
+
+ $this->assertSame(
+ array(
+ 'analytics' => true,
+ 'marketing' => true,
+ ),
+ $response['data']['allowedCategories']
+ );
+
+ $_COOKIE['frontconsent_categories'] = wp_json_encode( 'a plain string, not an object' );
+
+ $response = $this->get_config();
+
+ $this->assertSame(
+ array(
+ 'analytics' => true,
+ 'marketing' => true,
+ ),
+ $response['data']['allowedCategories']
+ );
+ }
+
+ /**
+ * A rejected binary consent must keep short-circuiting before any of the
+ * category-reading logic even matters for the tracking payload itself —
+ * confirms the existing $has_tracking_consent gate stays intact: no GTM/
+ * GA4 id and no tracking integrations are ever returned for a rejected
+ * visitor, regardless of what the categories cookie says.
+ */
+ public function test_rejected_binary_consent_still_gates_tracking_payload_regardless_of_categories_cookie() {
+ $_COOKIE['frcn_cookie_consent'] = 'rejected';
+ $_COOKIE['frontconsent_categories'] = wp_json_encode(
+ array(
+ 'analytics' => true,
+ 'marketing' => true,
+ )
+ );
+
+ update_option(
+ 'frontconsent_settings',
+ array(
+ 'enable_cookie_notice' => true,
+ 'cookie_notice_tracking_integrations' => array(
+ array( 'type' => 'gtm', 'id' => 'GTM-ABC1234' ),
+ ),
+ )
+ );
+
+ $response = $this->get_config();
+
+ $this->assertSame( '', $response['data']['gtmId'] );
+ $this->assertSame( array(), $response['data']['trackingIntegrations'] );
+ }
}
diff --git a/tests/Unit/CookieNoticePreferencesPanelTest.php b/tests/Unit/CookieNoticePreferencesPanelTest.php
index 43194ba..9fc9ba5 100644
--- a/tests/Unit/CookieNoticePreferencesPanelTest.php
+++ b/tests/Unit/CookieNoticePreferencesPanelTest.php
@@ -79,9 +79,9 @@ public function test_customize_button_renders_in_every_layout_with_the_cookie_ic
public function test_customize_button_appears_before_reject_and_accept() {
update_option( 'frontconsent_settings', array( 'enable_cookie_notice' => true ) );
- $html = $this->render_banner_html();
- $customize_pos = strpos( $html, 'data-frcn-cookie-action="customize"' );
- $reject_pos = strpos( $html, 'data-frcn-cookie-action="reject"' );
+ $html = $this->render_banner_html();
+ $customize_pos = strpos( $html, 'data-frcn-cookie-action="customize"' );
+ $reject_pos = strpos( $html, 'data-frcn-cookie-action="reject"' );
$this->assertNotFalse( $customize_pos );
$this->assertNotFalse( $reject_pos );
@@ -197,16 +197,19 @@ static function () {
}
/**
- * With no add-on hooked in (Free, or PRO inactive), nothing extra is
- * rendered beyond the static necessary section — no category UI leaks
- * through by default.
+ * With no add-on hooked in (Free, or PRO inactive), the panel shows
+ * exactly its own two built-in category toggles (analytics, marketing)
+ * and nothing else — no additional PRO-style category UI leaks through
+ * by default via the frcn_cookie_preferences_categories extension point.
*/
- public function test_no_category_ui_is_rendered_without_an_add_on() {
+ public function test_no_extra_category_ui_is_rendered_without_an_add_on() {
update_option( 'frontconsent_settings', array( 'enable_cookie_notice' => true ) );
$html = $this->render_banner_html();
- $this->assertStringNotContainsString( 'data-frcn-category', $html );
+ preg_match_all( '/data-frcn-category="([^"]+)"/', $html, $matches );
+
+ $this->assertSame( array( 'analytics', 'marketing' ), $matches[1] );
}
/**
@@ -295,32 +298,45 @@ public function test_customize_button_is_printed_via_the_existing_before_actions
}
/**
- * Without a real toggle, the panel looks identical no matter what Accept
- * all/Reject all/Save changes did — a visitor gets no visible
- * confirmation of the effect of their choice. The Free tier ships one
- * real, native checkbox (data-frcn-category="optional") that
- * frontconsent-cookie-notice.js syncs from the actual consent cookie on
- * every open and reads back on Save changes/Accept all, so the panel
- * always reflects what was actually recorded.
+ * Without real, independent toggles, the panel looks identical no matter
+ * what Accept all/Reject all/Save changes did — a visitor gets no visible
+ * confirmation of the effect of their choice, and (per the "analytics y
+ * marketing son diferentes" feedback) a single combined checkbox would
+ * misleadingly promise granularity it doesn't have. The Free tier ships
+ * two real, native, independently-checkable checkboxes —
+ * data-frcn-category="analytics" and data-frcn-category="marketing" —
+ * that frontconsent-cookie-notice.js syncs from the frontconsent_categories
+ * cookie on every open and reads back on Save changes/Accept all, so the
+ * panel always reflects what was actually recorded per category.
*/
- public function test_panel_includes_a_real_optional_category_toggle() {
+ public function test_panel_includes_real_analytics_and_marketing_category_toggles() {
update_option( 'frontconsent_settings', array( 'enable_cookie_notice' => true ) );
$html = $this->render_banner_html();
$this->assertMatchesRegularExpression(
- '/]*type="checkbox"[^>]*data-frcn-category="optional"/',
+ '/]*type="checkbox"[^>]*data-frcn-category="analytics"/',
+ $html
+ );
+ $this->assertMatchesRegularExpression(
+ '/]*type="checkbox"[^>]*data-frcn-category="marketing"/',
$html
);
- $this->assertStringContainsString( 'Analytics & Marketing', $html );
+ $this->assertStringContainsString( 'Analytics', $html );
+ $this->assertStringContainsString( 'Marketing', $html );
+ $this->assertStringNotContainsString( 'data-frcn-category="optional"', $html );
+ $this->assertStringNotContainsString( 'Analytics & Marketing', $html );
// Must appear after the static necessary block and before the PRO
// extension point, matching where render_preferences_panel() prints it.
$necessary_pos = strpos( $html, 'frcn-cookie-preferences__category--necessary' );
- $toggle_pos = strpos( $html, 'data-frcn-category="optional"' );
+ $analytics_pos = strpos( $html, 'data-frcn-category="analytics"' );
+ $marketing_pos = strpos( $html, 'data-frcn-category="marketing"' );
$this->assertNotFalse( $necessary_pos );
- $this->assertNotFalse( $toggle_pos );
- $this->assertGreaterThan( $necessary_pos, $toggle_pos );
+ $this->assertNotFalse( $analytics_pos );
+ $this->assertNotFalse( $marketing_pos );
+ $this->assertGreaterThan( $necessary_pos, $analytics_pos );
+ $this->assertGreaterThan( $analytics_pos, $marketing_pos );
}
}
diff --git a/tests/js/cookie-notice-focus-management.test.js b/tests/js/cookie-notice-focus-management.test.js
index cd36bd0..a3b59d9 100644
--- a/tests/js/cookie-notice-focus-management.test.js
+++ b/tests/js/cookie-notice-focus-management.test.js
@@ -95,6 +95,22 @@ function createEnvironment(options) {
var panelSaveBtn = createElement('panel-save');
var panelFocusable = [panelCloseBtn, panelAcceptBtn, panelRejectBtn, panelSaveBtn];
+ // The two real, independent category checkboxes (see issue: "analytics y
+ // marketing son diferentes") — a plain object stub is enough since only
+ // .checked and .getAttribute('data-frcn-category') are ever read/set.
+ var categoryToggles = options.categoryToggles || [
+ { category: 'analytics', checked: false },
+ { category: 'marketing', checked: false }
+ ];
+ var categoryToggleElements = categoryToggles.map(function (toggle) {
+ return {
+ checked: toggle.checked,
+ getAttribute(name) {
+ return 'data-frcn-category' === name ? toggle.category : null;
+ }
+ };
+ });
+
var preferencesPanel = {
hidden: true,
classList: { add() {}, remove() {}, contains() { return false; } },
@@ -115,16 +131,45 @@ function createEnvironment(options) {
},
querySelectorAll(selector) {
if ('[data-frcn-category]' === selector) {
- return [];
+ return categoryToggleElements;
}
return panelFocusable;
}
};
+ // A minimal accumulating cookie jar (real browsers merge each
+ // `document.cookie = "name=value; attrs..."` assignment into the
+ // existing set rather than replacing it wholesale) — needed now that
+ // handleDecision() writes both the binary consent cookie and the new
+ // per-category cookie in the same decision, and tests need to read both
+ // back independently afterwards.
+ var cookieJar = {};
+
+ (options.cookie || '').split(';').forEach(function (pair) {
+ var index = pair.indexOf('=');
+
+ if (index > -1) {
+ cookieJar[pair.slice(0, index).trim()] = pair.slice(index + 1).trim();
+ }
+ });
+
document = {
activeElement: triggerEl,
body: { classList: { add() {}, remove() {} } },
- cookie: options.cookie || '',
+ get cookie() {
+ return Object.keys(cookieJar).map(function (name) {
+ return name + '=' + cookieJar[name];
+ }).join('; ');
+ },
+ set cookie(value) {
+ var index = value.indexOf('=');
+
+ if (index === -1) {
+ return;
+ }
+
+ cookieJar[value.slice(0, index).trim()] = value.slice(index + 1).split(';')[0];
+ },
head: { appendChild() {} },
readyState: 'loading',
_domListeners: {},
@@ -182,6 +227,7 @@ function createEnvironment(options) {
frcnCookieNotice: {
ajaxUrl: 'https://example.test/wp-admin/admin-ajax.php',
cookieName: 'frcn_cookie_consent',
+ categoriesCookieName: 'frontconsent_categories',
cookiePath: '/',
expirationDays: 365,
isPolicyPage: '',
@@ -216,6 +262,7 @@ function createEnvironment(options) {
return {
acceptBtn,
announcer,
+ categoryToggleElements,
context,
customizeBtn,
fireModalKeydown,
@@ -391,7 +438,121 @@ test('Save changes with nothing else to opt into records the same rejection the
var env = createEnvironment({ cookie: 'frcn_cookie_consent=accepted' });
env.reopenBtn.dispatchEvent({ type: 'click' });
+
+ // Opening the panel with an 'accepted' binary cookie and no categories
+ // cookie yet checks both toggles by default (backward compatibility —
+ // see syncPreferencesToggles()); simulate the visitor explicitly
+ // unchecking both before saving, i.e. genuinely "nothing else to opt into".
+ env.categoryToggleElements.forEach(function (toggle) {
+ toggle.checked = false;
+ });
+
env.panelSaveBtn.dispatchEvent({ type: 'click' });
assert.ok(/frcn_cookie_consent=rejected/.test(document.cookie));
});
+
+/**
+ * Tests for the real, independent Analytics/Marketing category split
+ * (analytics y marketing son diferentes) — the frontconsent_categories
+ * cookie and syncPreferencesToggles()/collectPreferencesCategories() must
+ * treat each category on its own, not as two cosmetic mirrors of one flag.
+ */
+
+function readStoredCategories() {
+ var match = /frontconsent_categories=([^;]*)/.exec(document.cookie);
+
+ if (!match) {
+ return null;
+ }
+
+ return JSON.parse(decodeURIComponent(match[1]));
+}
+
+test('opening the panel with a categories cookie present sets each checkbox independently, not both mirroring one flag', () => {
+ var env = createEnvironment({
+ cookie: 'frcn_cookie_consent=accepted; frontconsent_categories=' +
+ encodeURIComponent(JSON.stringify({ analytics: true, marketing: false }))
+ });
+
+ env.reopenBtn.dispatchEvent({ type: 'click' });
+
+ var analyticsToggle = env.categoryToggleElements[0];
+ var marketingToggle = env.categoryToggleElements[1];
+
+ assert.equal(analyticsToggle.checked, true);
+ assert.equal(marketingToggle.checked, false);
+});
+
+test('opening the panel with no categories cookie but an accepted binary cookie checks both (backward compatibility)', () => {
+ var env = createEnvironment({ cookie: 'frcn_cookie_consent=accepted' });
+
+ env.reopenBtn.dispatchEvent({ type: 'click' });
+
+ var analyticsToggle = env.categoryToggleElements[0];
+ var marketingToggle = env.categoryToggleElements[1];
+
+ assert.equal(analyticsToggle.checked, true);
+ assert.equal(marketingToggle.checked, true);
+});
+
+test('opening the panel with no categories cookie and no/rejected binary cookie leaves both unchecked', () => {
+ var env = createEnvironment({ cookie: '' });
+
+ env.reopenBtn.dispatchEvent({ type: 'click' });
+
+ var analyticsToggle = env.categoryToggleElements[0];
+ var marketingToggle = env.categoryToggleElements[1];
+
+ assert.equal(analyticsToggle.checked, false);
+ assert.equal(marketingToggle.checked, false);
+});
+
+test('clicking Save changes with only Analytics checked persists {necessary: true, analytics: true, marketing: false}', () => {
+ var env = createEnvironment({ cookie: 'frcn_cookie_consent=accepted' });
+
+ env.reopenBtn.dispatchEvent({ type: 'click' });
+
+ // Simulate the visitor's actual click: opening the panel checks both by
+ // default here (backward-compat fallback, no categories cookie yet) —
+ // then they explicitly uncheck Marketing while leaving Analytics checked.
+ env.categoryToggleElements[0].checked = true;
+ env.categoryToggleElements[1].checked = false;
+
+ env.panelSaveBtn.dispatchEvent({ type: 'click' });
+
+ // Only Analytics is checked, so the binary decision this implies is
+ // still 'accepted' (at least one non-necessary category is on).
+ assert.ok(/frcn_cookie_consent=accepted/.test(document.cookie));
+ assert.deepEqual(readStoredCategories(), { necessary: true, analytics: true, marketing: false });
+});
+
+test('Reject all explicitly persists {necessary: true, analytics: false, marketing: false}', () => {
+ var env = createEnvironment({
+ cookie: 'frcn_cookie_consent=accepted',
+ categoryToggles: [
+ { category: 'analytics', checked: true },
+ { category: 'marketing', checked: true }
+ ]
+ });
+
+ env.reopenBtn.dispatchEvent({ type: 'click' });
+ env.panelRejectBtn.dispatchEvent({ type: 'click' });
+
+ assert.deepEqual(readStoredCategories(), { necessary: true, analytics: false, marketing: false });
+});
+
+test('Accept all persists both categories as true regardless of their prior checked state', () => {
+ var env = createEnvironment({
+ cookie: 'frcn_cookie_consent=accepted',
+ categoryToggles: [
+ { category: 'analytics', checked: false },
+ { category: 'marketing', checked: false }
+ ]
+ });
+
+ env.reopenBtn.dispatchEvent({ type: 'click' });
+ env.panelAcceptBtn.dispatchEvent({ type: 'click' });
+
+ assert.deepEqual(readStoredCategories(), { necessary: true, analytics: true, marketing: true });
+});
diff --git a/tests/js/cookie-notice-injection.test.js b/tests/js/cookie-notice-injection.test.js
index cb2dbc3..4ae1782 100644
--- a/tests/js/cookie-notice-injection.test.js
+++ b/tests/js/cookie-notice-injection.test.js
@@ -52,10 +52,36 @@ function createEnvironment(options = {}) {
querySelector() { return null; },
querySelectorAll() { return []; }
};
+ // An accumulating cookie jar (real browsers merge each
+ // `document.cookie = "name=value; attrs..."` assignment into the
+ // existing set rather than replacing it wholesale) — handleDecision()
+ // now writes both the binary consent cookie and the per-category cookie
+ // for a single decision, and both must be readable afterwards.
+ const cookieJar = {};
+
+ (options.cookie || '').split(';').forEach((pair) => {
+ const index = pair.indexOf('=');
+
+ if (index > -1) {
+ cookieJar[pair.slice(0, index).trim()] = pair.slice(index + 1).trim();
+ }
+ });
+
const document = {
activeElement: null,
body: { classList: { add() {}, remove() {} } },
- cookie: options.cookie || '',
+ get cookie() {
+ return Object.keys(cookieJar).map((name) => `${name}=${cookieJar[name]}`).join('; ');
+ },
+ set cookie(value) {
+ const index = value.indexOf('=');
+
+ if (index === -1) {
+ return;
+ }
+
+ cookieJar[value.slice(0, index).trim()] = value.slice(index + 1).split(';')[0];
+ },
head: { appendChild(script) { scripts.push(script); } },
readyState: 'loading',
addEventListener(event, callback) { listeners[event] = callback; },
@@ -107,6 +133,7 @@ function createEnvironment(options = {}) {
frcnCookieNotice: {
ajaxUrl: 'https://example.test/wp-admin/admin-ajax.php',
cookieName: 'frcn_cookie_consent',
+ categoriesCookieName: 'frontconsent_categories',
cookiePath: '/',
expirationDays: 365,
isPolicyPage: options.isPolicyPage || '',