diff --git a/assets/cookie-notice/frontconsent-cookie-notice.css b/assets/cookie-notice/frontconsent-cookie-notice.css index a461752..370de1f 100644 --- a/assets/cookie-notice/frontconsent-cookie-notice.css +++ b/assets/cookie-notice/frontconsent-cookie-notice.css @@ -142,6 +142,36 @@ color: var(--frcn-cookie-text); } +/* + * "Customize cookie settings" — a secondary/outline button so it never + * competes visually with Accept/Reject: same shape and sizing as the other + * actions, but a plain border and no filled background. + */ +.frcn-cookie-notice__button--customize { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: transparent; + border: 1px solid var(--frcn-cookie-accent-on-light); + color: var(--frcn-cookie-text); +} + +.frcn-cookie-notice__button-icon { + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + background-color: var(--frcn-cookie-accent-on-light); + -webkit-mask-image: var(--frcn-cookie-icon-url); + mask-image: var(--frcn-cookie-icon-url); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + /* Bar layout: floating bottom bar, spanning most of the viewport width. */ .frcn-cookie-notice--bar { left: 20px; @@ -390,3 +420,161 @@ body.frcn-cookie-notice-lock-scroll { .frcn-cookie-reopen[hidden] { display: none; } + +/* + * Cookie preferences panel: a true modal dialog, printed unconditionally + * (see CookieNotice::render_preferences_panel()) and hidden by default via + * the `hidden` attribute — frontconsent-cookie-notice.js removes it to open + * the dialog, from either the "Customize" button or the persistent reopen + * trigger. + */ +.frcn-cookie-preferences { + --frcn-cookie-accent: #687df9; + --frcn-cookie-accent-contrast: #ffffff; + --frcn-cookie-accent-on-light: #687df9; + --frcn-cookie-bg: #ffffff; + --frcn-cookie-text: #1f2937; + --frcn-cookie-radius: 12px; + + position: fixed; + z-index: 1000000; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + overflow-y: auto; + background-color: rgba(17, 24, 39, 0.5); + font-family: inherit; + line-height: 1.5; + box-sizing: border-box; +} + +.frcn-cookie-preferences * { + box-sizing: border-box; +} + +.frcn-cookie-preferences[hidden] { + display: none; +} + +.frcn-cookie-preferences__panel { + background-color: var(--frcn-cookie-bg); + color: var(--frcn-cookie-text); + border-radius: var(--frcn-cookie-radius); + max-width: 480px; + width: 100%; + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.frcn-cookie-preferences__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.frcn-cookie-preferences__title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--frcn-cookie-text); +} + +.frcn-cookie-preferences__close { + appearance: none; + background: transparent; + border: none; + cursor: pointer; + font-size: 20px; + line-height: 1; + padding: 4px 8px; + color: var(--frcn-cookie-text); +} + +.frcn-cookie-preferences__close:focus-visible { + outline: 2px solid var(--frcn-cookie-accent-on-light); + outline-offset: 2px; +} + +.frcn-cookie-preferences__message { + margin: 0; + font-size: 14px; + color: var(--frcn-cookie-text); +} + +.frcn-cookie-preferences__category { + border: 1px solid rgba(31, 41, 55, 0.12); + border-radius: calc(var(--frcn-cookie-radius) / 2); + padding: 12px 16px; +} + +.frcn-cookie-preferences__category-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.frcn-cookie-preferences__category-title { + font-weight: 600; + font-size: 14px; +} + +.frcn-cookie-preferences__category-badge { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--frcn-cookie-accent-on-light); +} + +.frcn-cookie-preferences__category-toggle { + width: 20px; + height: 20px; + flex-shrink: 0; + accent-color: var(--frcn-cookie-accent); + cursor: pointer; +} + +.frcn-cookie-preferences__category-toggle:focus-visible { + outline: 2px solid var(--frcn-cookie-accent); + outline-offset: 2px; +} + +.frcn-cookie-preferences__category-description { + margin: 8px 0 0; + font-size: 13px; + color: var(--frcn-cookie-text); +} + +.frcn-cookie-preferences__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-end; +} + +.frcn-cookie-preferences__actions .frcn-cookie-notice__button { + flex: 1 1 auto; +} + +@media (max-width: 600px) { + .frcn-cookie-preferences__panel { + max-height: calc(100vh - 24px); + } + + .frcn-cookie-preferences__actions { + flex-direction: column; + } + + .frcn-cookie-preferences__actions .frcn-cookie-notice__button { + width: 100%; + text-align: center; + } +} diff --git a/assets/cookie-notice/frontconsent-cookie-notice.js b/assets/cookie-notice/frontconsent-cookie-notice.js index 27549ec..79a071f 100644 --- a/assets/cookie-notice/frontconsent-cookie-notice.js +++ b/assets/cookie-notice/frontconsent-cookie-notice.js @@ -241,37 +241,17 @@ window.frcnCookieNoticeBootstrapped = true; } - function hideBannerIfDecided(banner) { - var consent = readCookie(frcnCookieNotice.cookieName); - - // An add-on tracking per-category consent (analytics vs. marketing) can - // define this to say "the categories cookie is stale — e.g. the site - // admin just added a new integration — so re-prompt even though the - // legacy accepted/rejected cookie here still looks decided." - if (typeof window.frcnCookieNoticeIsConsentStale === 'function' && window.frcnCookieNoticeIsConsentStale()) { - return false; - } - - if (banner && (consent === 'accepted' || consent === 'rejected')) { - banner.style.display = 'none'; - return true; - } - - return false; - } - /** * Reveal the persistent "Cookie preferences" trigger once a decision - * cookie exists, and wire it to let the visitor withdraw an acceptance or - * replace a rejection at any time — the only way to do so short of - * deleting the cookie by hand. Reloading the page after clearing the - * cookie is deliberately simple: it lets the server render a fresh, - * undecided banner exactly the way a first-time visitor gets one, - * instead of duplicating that logic client-side. + * cookie exists, and wire it to open the same preferences panel the + * "Customize cookie settings" button opens — the only way to reach it + * again once the original banner is gone. Bound once per page load + * (reopenTriggerWired), same as before; only reveal is repeated when this + * is called again right after an in-page decision. */ var reopenTriggerWired = false; - function setUpReopenTrigger() { + function setUpReopenTrigger(openPreferences) { var reopenBtn = document.getElementById('frcn-cookie-reopen'); if (!reopenBtn) { @@ -295,12 +275,8 @@ reopenTriggerWired = true; reopenBtn.addEventListener('click', function () { - document.cookie = frcnCookieNotice.cookieName + '=; path=' + frcnCookieNotice.cookiePath + '; max-age=0; SameSite=Lax'; - - if (frcnCookieNotice.isPolicyPage && frcnCookieNotice.homeUrl) { - window.location.href = frcnCookieNotice.homeUrl; - } else { - window.location.reload(); + if (typeof openPreferences === 'function') { + openPreferences(reopenBtn); } }); } @@ -320,24 +296,6 @@ rejected: 'Cookies rejected.' }; - requestTrackingIfNeeded(); - setUpReopenTrigger(); - - var banner = document.getElementById('frcn-cookie-notice'); - - if (!banner) { - return; - } - - if (hideBannerIfDecided(banner)) { - // Already decided: nothing left to wire up. - return; - } - - var acceptBtn = banner.querySelector('[data-frcn-cookie-action="accept"]'); - var rejectBtn = banner.querySelector('[data-frcn-cookie-action="reject"]'); - var isPopup = banner.classList.contains('frcn-cookie-notice--popup'); - var previouslyFocused = document.activeElement; var announcer = document.getElementById('frcn-cookie-notice-announcer'); /** @@ -353,45 +311,213 @@ } } - revealBanner(); + // Hoisted out of any "if (banner) { ... }" gate: the preferences panel + // (and the reopen trigger that opens it) must keep working even when + // the main banner markup isn't rendered at all — e.g. on the + // configured cookie policy page — so the decision-recording flow + // below can't depend on the banner existing. + var decided = false; - if (acceptBtn) { - // preventDefault() is what stops the button's form="..." submit - // attribute (the no-JS fallback — see render_banner_markup() and - // log_consent_form_callback()) from actually navigating the page - // away when JavaScript can run: this handles the decision instead, - // entirely client-side. - acceptBtn.addEventListener('click', function (event) { - event.preventDefault(); - handleDecision('accepted'); - }); + function setConsentCookie(decision) { + var maxAge = parseInt(frcnCookieNotice.expirationDays, 10) * 24 * 60 * 60; + var secure = window.location.protocol === 'https:' ? '; Secure' : ''; + + document.cookie = frcnCookieNotice.cookieName + '=' + decision + + '; path=' + frcnCookieNotice.cookiePath + '; max-age=' + maxAge + '; SameSite=Lax' + secure; } - if (rejectBtn) { - rejectBtn.addEventListener('click', function (event) { - event.preventDefault(); - handleDecision('rejected'); + /** + * 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'; + + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || function () { + window.dataLayer.push(arguments); + }; + window.gtag('consent', 'update', { + ad_storage: granted, + ad_user_data: granted, + ad_personalization: granted, + analytics_storage: granted }); } - var customizeBtn = banner.querySelector('[data-frcn-cookie-action="customize"]'); + function dispatchConsentEvent(decision) { + var event; - if (customizeBtn) { - // Deliberately not routed through handleDecision(): clicking it isn't a - // decision by itself, just a request to see more detail. An add-on - // listens for this to open its own categories dialog. - customizeBtn.addEventListener('click', function () { - var event; + try { + event = new CustomEvent('frcnCookieConsent', { detail: { consent: decision } }); + } catch (e) { + event = document.createEvent('CustomEvent'); + event.initCustomEvent('frcnCookieConsent', true, true, { consent: decision }); + } - try { - event = new CustomEvent('frcnCookieNoticeCustomize'); - } catch (e) { - event = document.createEvent('CustomEvent'); - event.initCustomEvent('frcnCookieNoticeCustomize', true, true, null); - } + document.dispatchEvent(event); + } - document.dispatchEvent(event); - }); + /** + * Records a decision through the exact same AJAX endpoint regardless + * of which control triggered it (Accept/Reject in the banner, or + * Accept all/Reject all/Save changes in the preferences panel) — the + * optional `categories` map is only ever additive: log_consent_callback() + * runs it through the frcn_cookie_consent_categories filter and never + * requires it for the binary accepted/rejected cookie to keep working. + */ + function logDecision(decision, categories) { + var nonceForm = new FormData(); + nonceForm.append('action', 'frcn_get_cookie_notice_log_nonce'); + + fetch(frcnCookieNotice.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: nonceForm + }) + .then(function (response) { + return response.json(); + }) + .then(function (response) { + if (!response || !response.success || !response.data) { + return; + } + + var formData = new FormData(); + formData.append('action', 'frcn_log_cookie_consent'); + formData.append('nonce', response.data.nonce); + formData.append('decision', decision); + + if (categories) { + formData.append('categories', JSON.stringify(categories)); + } + + return fetch(frcnCookieNotice.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: formData + }); + }) + .catch(function () { + // Best-effort: the aggregate stat is not critical to the consent flow. + }); + } + + /** + * The single decision-recording code path: sets the consent cookie, + * updates Google Consent Mode, announces the outcome, hides the + * banner and preferences panel (whichever are open), reveals the + * reopen trigger, and logs the decision — used identically by the + * banner's own Accept/Reject buttons and by the preferences panel's + * Accept all/Reject all/Save changes actions, per the "no parallel + * consent logic" requirement. + * + * @param {string} decision 'accepted' or 'rejected'. + * @param {Object} categories Optional per-category map (PRO extension point). + */ + function handleDecision(decision, categories) { + if (decided) { + return; + } + + 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(); + closePreferencesPanel(); + setUpReopenTrigger(openPreferencesPanel); + dispatchConsentEvent(decision); + logDecision(decision, categories); + + if (decision === 'accepted') { + fetchAndInjectScripts(); + } + } + + var banner = document.getElementById('frcn-cookie-notice'); + var isPopup = !!banner && banner.classList.contains('frcn-cookie-notice--popup'); + var bannerPreviouslyFocused = document.activeElement; + var bannerModalKeydownBound = false; + + function getFocusableElements(container) { + return Array.prototype.slice.call( + container.querySelectorAll('a[href], button, input, [tabindex]:not([tabindex="-1"])') + ); + } + + function trapFocus(event) { + var focusable = getFocusableElements(banner); + + if (!focusable.length) { + return; + } + + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + /** + * Keydown handler bound only while the popup (true modal) layout is + * open: Tab/Shift+Tab loop within the dialog's own focusable elements + * (trapFocus()) and Escape is treated as an explicit reject — a valid, + * equivalent consent decision, not a silent dismissal — so a keyboard + * user always has a way out that doesn't just abandon the dialog + * without a decision being recorded (see handleDecision()). + * + * Deliberately a no-op while the preferences panel is open on top of + * it — that dialog owns Tab/Escape while it's the visible one; see + * isPreferencesPanelOpen(). + */ + function handleModalKeydown(event) { + if (isPreferencesPanelOpen()) { + return; + } + + if (event.key === 'Tab') { + trapFocus(event); + } else if (event.key === 'Escape' || event.key === 'Esc') { + event.preventDefault(); + handleDecision('rejected'); + } } /** @@ -418,28 +544,66 @@ if (isPopup) { document.body.classList.add('frcn-cookie-notice-lock-scroll'); + var acceptBtnForFocus = banner.querySelector('[data-frcn-cookie-action="accept"]'); + // A true modal (the popup layout blocks the rest of the // page) must move focus into itself on open — the first // focusable control is the reject button in DOM order, // but the accept button is used here to keep the // pre-existing default behavior/tests unchanged. - if (acceptBtn) { - acceptBtn.focus({ preventScroll: true }); + if (acceptBtnForFocus) { + acceptBtnForFocus.focus({ preventScroll: true }); } document.addEventListener('keydown', handleModalKeydown); + bannerModalKeydownBound = true; } }, delay); } - function getFocusableElements() { - return Array.prototype.slice.call( - banner.querySelectorAll('a[href], button, input, [tabindex]:not([tabindex="-1"])') - ); + function hideBannerIfPresent() { + if (!banner) { + return; + } + + banner.classList.add('frcn-cookie-notice--hidden'); + document.body.classList.remove('frcn-cookie-notice-lock-scroll'); + + if (bannerModalKeydownBound) { + document.removeEventListener('keydown', handleModalKeydown); + bannerModalKeydownBound = false; + } + + if (isPopup && bannerPreviouslyFocused && typeof bannerPreviouslyFocused.focus === 'function') { + bannerPreviouslyFocused.focus({ preventScroll: true }); + } + + window.setTimeout(function () { + if (banner.parentNode) { + banner.parentNode.removeChild(banner); + } + }, 300); } - function trapFocus(event) { - var focusable = getFocusableElements(); + // --- Preferences panel: dialog opened from the "Customize cookie + // settings" button (in the banner) or the persistent reopen trigger. + // Always looked up (even when the main banner didn't render, e.g. on + // the policy page) since CookieNotice::render_preferences_panel() + // prints it unconditionally, independently of the banner markup. + var preferencesPanel = document.getElementById('frcn-cookie-preferences'); + var preferencesPanelOpener = null; + var preferencesPanelKeydownBound = false; + + function isPreferencesPanelOpen() { + return !!preferencesPanel && !preferencesPanel.hidden; + } + + function getPreferencesFocusable() { + return getFocusableElements(preferencesPanel); + } + + function trapPreferencesFocus(event) { + var focusable = getPreferencesFocusable(); if (!focusable.length) { return; @@ -457,128 +621,272 @@ } } - /** - * Keydown handler bound only while the popup (true modal) layout is - * open: Tab/Shift+Tab loop within the dialog's own focusable elements - * (trapFocus()) and Escape is treated as an explicit reject — a valid, - * equivalent consent decision, not a silent dismissal — so a keyboard - * user always has a way out that doesn't just abandon the dialog - * without a decision being recorded (see handleDecision()). - */ - function handleModalKeydown(event) { + function handlePreferencesKeydown(event) { if (event.key === 'Tab') { - trapFocus(event); + trapPreferencesFocus(event); } else if (event.key === 'Escape' || event.key === 'Esc') { event.preventDefault(); - handleDecision('rejected'); + closePreferencesPanel(); } } - var decided = false; - - function handleDecision(decision) { - if (decided) { + /** + * 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) { return; } - decided = true; + var accepted = readCookie(frcnCookieNotice.cookieName) === 'accepted'; + var storedCategoriesRaw = readCookie(frcnCookieNotice.categoriesCookieName); + var storedCategories = null; - setConsentCookie(decision); - updateConsentMode(decision); - announce(decision === 'accepted' ? i18n.accepted : i18n.rejected); - hideBanner(); - setUpReopenTrigger(); - dispatchConsentEvent(decision); - logDecision(decision); + if (storedCategoriesRaw) { + try { + var parsed = JSON.parse(storedCategoriesRaw); - if (decision === 'accepted') { - fetchAndInjectScripts(); + 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; + } } - } - function updateConsentMode(decision) { - var granted = decision === 'accepted' ? 'granted' : 'denied'; + var toggles = preferencesPanel.querySelectorAll('[data-frcn-category]'); - window.dataLayer = window.dataLayer || []; - window.gtag = window.gtag || function () { - window.dataLayer.push(arguments); - }; - window.gtag('consent', 'update', { - ad_storage: granted, - ad_user_data: granted, - ad_personalization: granted, - analytics_storage: granted + Array.prototype.forEach.call(toggles, function (toggle) { + 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; + } }); } - function setConsentCookie(decision) { - var maxAge = parseInt(frcnCookieNotice.expirationDays, 10) * 24 * 60 * 60; - var secure = window.location.protocol === 'https:' ? '; Secure' : ''; + function openPreferencesPanel(trigger) { + if (!preferencesPanel) { + return; + } - document.cookie = frcnCookieNotice.cookieName + '=' + decision + - '; path=' + frcnCookieNotice.cookiePath + '; max-age=' + maxAge + '; SameSite=Lax' + secure; + // A fresh session of "changing their mind": lets a visitor who + // already decided make (and log) a new decision from the + // reopened panel instead of being silently blocked by the + // same-page debounce above. + decided = false; + syncPreferencesToggles(); + preferencesPanelOpener = trigger || document.activeElement; + preferencesPanel.hidden = false; + document.body.classList.add('frcn-cookie-notice-lock-scroll'); + + var focusable = getPreferencesFocusable(); + + if (focusable.length) { + focusable[0].focus({ preventScroll: true }); + } + + if (!preferencesPanelKeydownBound) { + document.addEventListener('keydown', handlePreferencesKeydown); + preferencesPanelKeydownBound = true; + } + + announce(i18n.bannerOpened); } - function hideBanner() { - banner.classList.add('frcn-cookie-notice--hidden'); - document.body.classList.remove('frcn-cookie-notice-lock-scroll'); - document.removeEventListener('keydown', handleModalKeydown); + function closePreferencesPanel() { + if (!preferencesPanel || preferencesPanel.hidden) { + return; + } + + preferencesPanel.hidden = true; - if (isPopup && previouslyFocused && typeof previouslyFocused.focus === 'function') { - previouslyFocused.focus({ preventScroll: true }); + if (!isPopup || !banner || banner.classList.contains('frcn-cookie-notice--hidden')) { + document.body.classList.remove('frcn-cookie-notice-lock-scroll'); } - window.setTimeout(function () { - if (banner.parentNode) { - banner.parentNode.removeChild(banner); - } - }, 300); + if (preferencesPanelKeydownBound) { + document.removeEventListener('keydown', handlePreferencesKeydown); + preferencesPanelKeydownBound = false; + } + + if (preferencesPanelOpener && typeof preferencesPanelOpener.focus === 'function') { + preferencesPanelOpener.focus({ preventScroll: true }); + } + + preferencesPanelOpener = null; } - function dispatchConsentEvent(decision) { - var event; + /** + * Every non-necessary category toggle a PRO add-on rendered on + * frcn_cookie_preferences_categories (see render_preferences_panel()) + * is expected to carry a `data-frcn-category=""` attribute on a + * checkable control — read back here so "Save changes" can compute + * both the categories map and the binary decision it implies. The + * Free tier renders none, so this is always just { necessary: true } + * until an add-on extends the panel. + */ + function collectPreferencesCategories() { + var categories = { necessary: true }; - try { - event = new CustomEvent('frcnCookieConsent', { detail: { consent: decision } }); - } catch (e) { - event = document.createEvent('CustomEvent'); - event.initCustomEvent('frcnCookieConsent', true, true, { consent: decision }); + if (!preferencesPanel) { + return categories; } - document.dispatchEvent(event); + var toggles = preferencesPanel.querySelectorAll('[data-frcn-category]'); + + Array.prototype.forEach.call(toggles, function (toggle) { + categories[toggle.getAttribute('data-frcn-category')] = !!toggle.checked; + }); + + return categories; } - function logDecision(decision) { - var nonceForm = new FormData(); - nonceForm.append('action', 'frcn_get_cookie_notice_log_nonce'); + if (preferencesPanel) { + var closeBtn = preferencesPanel.querySelector('[data-frcn-cookie-action="close-preferences"]'); + var panelAcceptBtn = preferencesPanel.querySelector('[data-frcn-cookie-action="accept"]'); + var panelRejectBtn = preferencesPanel.querySelector('[data-frcn-cookie-action="reject"]'); + var panelSaveBtn = preferencesPanel.querySelector('[data-frcn-cookie-action="save"]'); - fetch(frcnCookieNotice.ajaxUrl, { - method: 'POST', - credentials: 'same-origin', - body: nonceForm - }) - .then(function (response) { - return response.json(); - }) - .then(function (response) { - if (!response || !response.success || !response.data) { - return; - } + if (closeBtn) { + closeBtn.addEventListener('click', function () { + closePreferencesPanel(); + }); + } - var formData = new FormData(); - formData.append('action', 'frcn_log_cookie_consent'); - formData.append('nonce', response.data.nonce); - formData.append('decision', decision); + if (panelAcceptBtn) { + panelAcceptBtn.addEventListener('click', function () { + var categories = collectPreferencesCategories(); - return fetch(frcnCookieNotice.ajaxUrl, { - method: 'POST', - credentials: 'same-origin', - body: formData + Object.keys(categories).forEach(function (key) { + categories[key] = true; }); - }) - .catch(function () { - // Best-effort: the aggregate stat is not critical to the consent flow. + + handleDecision('accepted', categories); + }); + } + + if (panelRejectBtn) { + panelRejectBtn.addEventListener('click', function () { + // 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 }); + }); + } + + if (panelSaveBtn) { + panelSaveBtn.addEventListener('click', function () { + var categories = collectPreferencesCategories(); + var hasOptionalConsent = Object.keys(categories).some(function (key) { + return key !== 'necessary' && categories[key]; + }); + + handleDecision(hasOptionalConsent ? 'accepted' : 'rejected', categories); }); + } + } + + requestTrackingIfNeeded(); + setUpReopenTrigger(openPreferencesPanel); + + if (!banner) { + return; + } + + if (hideBannerIfDecided()) { + // Already decided: nothing left to wire up on the banner itself + // (the preferences panel and reopen trigger stay available). + return; + } + + var acceptBtn = banner.querySelector('[data-frcn-cookie-action="accept"]'); + var rejectBtn = banner.querySelector('[data-frcn-cookie-action="reject"]'); + var customizeBtn = banner.querySelector('[data-frcn-cookie-action="customize"]'); + + function hideBannerIfDecided() { + var consent = readCookie(frcnCookieNotice.cookieName); + + // An add-on tracking per-category consent (analytics vs. marketing) can + // define this to say "the categories cookie is stale — e.g. the site + // admin just added a new integration — so re-prompt even though the + // legacy accepted/rejected cookie here still looks decided." + if (typeof window.frcnCookieNoticeIsConsentStale === 'function' && window.frcnCookieNoticeIsConsentStale()) { + return false; + } + + if (consent === 'accepted' || consent === 'rejected') { + banner.style.display = 'none'; + return true; + } + + return false; + } + + revealBanner(); + + if (acceptBtn) { + // preventDefault() is what stops the button's form="..." submit + // attribute (the no-JS fallback — see render_banner_markup() and + // log_consent_form_callback()) from actually navigating the page + // away when JavaScript can run: this handles the decision instead, + // entirely client-side. + acceptBtn.addEventListener('click', function (event) { + event.preventDefault(); + handleDecision('accepted'); + }); + } + + if (rejectBtn) { + rejectBtn.addEventListener('click', function (event) { + event.preventDefault(); + handleDecision('rejected'); + }); + } + + if (customizeBtn) { + // Opens the same preferences panel the reopen trigger uses — not + // routed through handleDecision(): clicking it isn't a decision by + // itself, just a request to see more detail. A CustomEvent is + // still dispatched afterwards so any existing listener bound to it + // keeps working. + customizeBtn.addEventListener('click', function () { + openPreferencesPanel(customizeBtn); + + var event; + + try { + event = new CustomEvent('frcnCookieNoticeCustomize'); + } catch (e) { + event = document.createEvent('CustomEvent'); + event.initCustomEvent('frcnCookieNoticeCustomize', true, true, null); + } + + document.dispatchEvent(event); + }); } } })(); diff --git a/assets/cookie-notice/frontconsent-cookie-notice.min.css b/assets/cookie-notice/frontconsent-cookie-notice.min.css index 6497c98..bc894b5 100644 --- a/assets/cookie-notice/frontconsent-cookie-notice.min.css +++ b/assets/cookie-notice/frontconsent-cookie-notice.min.css @@ -1 +1 @@ -.frcn-cookie-notice{--frcn-cookie-accent:#687df9;--frcn-cookie-accent-contrast:#ffffff;--frcn-cookie-accent-on-light:#687df9;--frcn-cookie-bg:#ffffff;--frcn-cookie-text:#1f2937;--frcn-cookie-radius:12px;position:fixed;z-index:999999;box-sizing:border-box;font-family:inherit;line-height:1.5;opacity:1;transition:opacity .3s,transform .3s}.frcn-cookie-notice *{box-sizing:border-box}.frcn-cookie-notice--hidden,.frcn-cookie-notice--init{opacity:0;pointer-events:none}.frcn-cookie-notice--bar.frcn-cookie-notice--init{transform:translateY(100%)}.frcn-cookie-notice--box.frcn-cookie-notice--init{transform:translateX(100%)}.frcn-cookie-notice--box.frcn-cookie-notice--left.frcn-cookie-notice--init{transform:translateX(-100%)}.frcn-cookie-notice--popup.frcn-cookie-notice--init{transform:scale(.96)}.frcn-cookie-notice__panel{background-color:var(--frcn-cookie-bg);color:var(--frcn-cookie-text);border-radius:var(--frcn-cookie-radius);overflow:hidden;box-shadow:none}.frcn-cookie-notice__message{margin:0;font-size:14px;color:var(--frcn-cookie-text)}.frcn-cookie-notice__link{color:var(--frcn-cookie-accent-on-light);background-color:transparent;text-decoration:underline}.frcn-cookie-notice__link:focus,.frcn-cookie-notice__link:hover{color:var(--frcn-cookie-accent-on-light);background-color:transparent}.frcn-cookie-notice__link:focus-visible{outline:2px solid var(--frcn-cookie-accent-on-light);outline-offset:2px}.frcn-cookie-notice__actions{display:flex;flex-wrap:wrap;gap:12px;flex-shrink:0}.frcn-cookie-notice__button{appearance:none;border-radius:6px;padding:10px 20px;font-size:14px;font-weight:600;line-height:1.2;cursor:pointer;transition:filter .15s,transform .15s;white-space:nowrap}.frcn-cookie-notice__button:hover{filter:brightness(.92)}.frcn-cookie-notice__button:active{transform:scale(.97)}.frcn-cookie-notice__button:focus-visible{outline:2px solid var(--frcn-cookie-accent-on-light);outline-offset:2px}.frcn-cookie-notice__button--accept{background-color:var(--frcn-cookie-accent);border:1px solid var(--frcn-cookie-accent);color:var(--frcn-cookie-accent-contrast)}.frcn-cookie-notice__button--reject{background-color:transparent;border:1px solid #9ca3af;color:var(--frcn-cookie-text)}.frcn-cookie-notice--bar{left:20px;right:20px;bottom:20px;max-height:calc(100vh - 40px);overflow-y:auto}.frcn-cookie-notice--bar.frcn-cookie-notice--generatepress{left:max(20px,calc((100vw - var(--frcn-cookie-content-width))/ 2));right:max(20px,calc((100vw - var(--frcn-cookie-content-width))/ 2))}.frcn-cookie-notice--bar .frcn-cookie-notice__panel{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:16px 24px}.frcn-cookie-notice--bar .frcn-cookie-notice__message{flex:1 1 320px}.frcn-cookie-notice--box{bottom:20px;max-width:380px;width:calc(100% - 40px);max-height:calc(100vh - 40px);overflow-y:auto}.frcn-cookie-notice--box.frcn-cookie-notice--right{right:20px}.frcn-cookie-notice--box.frcn-cookie-notice--left{left:20px}.frcn-cookie-notice--box .frcn-cookie-notice__panel{padding:20px;display:flex;flex-direction:column;gap:16px}.frcn-cookie-notice--box .frcn-cookie-notice__actions{flex-direction:column}.frcn-cookie-notice--box .frcn-cookie-notice__button{width:100%;text-align:center}.frcn-cookie-notice--popup{inset:0;display:flex;align-items:center;justify-content:center;padding:20px;overflow-y:auto;background-color:transparent}.frcn-cookie-notice--popup .frcn-cookie-notice__panel{padding:40px 32px 32px;max-width:420px;width:100%;max-height:calc(100vh - 40px);overflow-y:auto;display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;box-shadow:none}.frcn-cookie-notice__icon{display:flex;align-items:center;justify-content:center;width:64px;height:64px;flex-shrink:0;border-radius:50%;background-color:var(--frcn-cookie-accent)}.frcn-cookie-notice__icon::before{content:'';display:block;width:32px;height:32px;background-color:var(--frcn-cookie-accent-contrast);-webkit-mask-image:var(--frcn-cookie-icon-url);mask-image:var(--frcn-cookie-icon-url);-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center}.frcn-cookie-notice--popup .frcn-cookie-notice__actions{justify-content:center;width:100%}body.frcn-cookie-notice-lock-scroll{overflow:hidden}@media (max-width:600px){.frcn-cookie-notice--bar .frcn-cookie-notice__panel{padding:16px}.frcn-cookie-notice--bar .frcn-cookie-notice__actions,.frcn-cookie-notice--popup .frcn-cookie-notice__actions{flex-direction:column;width:100%}.frcn-cookie-notice--bar .frcn-cookie-notice__button,.frcn-cookie-notice--popup .frcn-cookie-notice__button{width:100%;text-align:center}.frcn-cookie-notice--box,.frcn-cookie-notice--popup .frcn-cookie-notice__panel{width:calc(100% - 24px);max-width:none}.frcn-cookie-notice--popup .frcn-cookie-notice__panel{max-height:calc(100vh - 24px)}.frcn-cookie-notice--bar,.frcn-cookie-notice--box{left:12px;right:12px;bottom:12px;max-height:calc(100vh - 24px)}}@media (prefers-reduced-motion:reduce){.frcn-cookie-notice,.frcn-cookie-notice__button,.frcn-cookie-reopen{transition:none}}.frcn-cookie-notice-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.frcn-cookie-reopen{position:fixed;z-index:999998;left:16px;bottom:16px;display:flex;align-items:center;justify-content:center;width:44px;height:44px;appearance:none;border:none;border-radius:50%;background-color:var(--frcn-cookie-accent);box-shadow:0 1px 3px rgba(0,0,0,.12);cursor:pointer}.frcn-cookie-reopen .frcn-cookie-notice__icon{width:100%;height:100%;background-color:transparent}.frcn-cookie-reopen .frcn-cookie-notice__icon::before{width:22px;height:22px;background-color:var(--frcn-cookie-accent-contrast)}.frcn-cookie-reopen:hover{filter:brightness(.92)}.frcn-cookie-reopen:focus-visible{outline:2px solid var(--frcn-cookie-accent);outline-offset:2px}.frcn-cookie-reopen[hidden]{display:none} \ No newline at end of file +.frcn-cookie-notice{--frcn-cookie-accent:#687df9;--frcn-cookie-accent-contrast:#ffffff;--frcn-cookie-accent-on-light:#687df9;--frcn-cookie-bg:#ffffff;--frcn-cookie-text:#1f2937;--frcn-cookie-radius:12px;position:fixed;z-index:999999;box-sizing:border-box;font-family:inherit;line-height:1.5;opacity:1;transition:opacity .3s,transform .3s}.frcn-cookie-notice *{box-sizing:border-box}.frcn-cookie-notice--hidden,.frcn-cookie-notice--init{opacity:0;pointer-events:none}.frcn-cookie-notice--bar.frcn-cookie-notice--init{transform:translateY(100%)}.frcn-cookie-notice--box.frcn-cookie-notice--init{transform:translateX(100%)}.frcn-cookie-notice--box.frcn-cookie-notice--left.frcn-cookie-notice--init{transform:translateX(-100%)}.frcn-cookie-notice--popup.frcn-cookie-notice--init{transform:scale(.96)}.frcn-cookie-notice__panel{background-color:var(--frcn-cookie-bg);color:var(--frcn-cookie-text);border-radius:var(--frcn-cookie-radius);overflow:hidden;box-shadow:none}.frcn-cookie-notice__message{margin:0;font-size:14px;color:var(--frcn-cookie-text)}.frcn-cookie-notice__link{color:var(--frcn-cookie-accent-on-light);background-color:transparent;text-decoration:underline}.frcn-cookie-notice__link:focus,.frcn-cookie-notice__link:hover{color:var(--frcn-cookie-accent-on-light);background-color:transparent}.frcn-cookie-notice__link:focus-visible{outline:2px solid var(--frcn-cookie-accent-on-light);outline-offset:2px}.frcn-cookie-notice__actions{display:flex;flex-wrap:wrap;gap:12px;flex-shrink:0}.frcn-cookie-notice__button{appearance:none;border-radius:6px;padding:10px 20px;font-size:14px;font-weight:600;line-height:1.2;cursor:pointer;transition:filter .15s,transform .15s;white-space:nowrap}.frcn-cookie-notice__button:hover{filter:brightness(.92)}.frcn-cookie-notice__button:active{transform:scale(.97)}.frcn-cookie-notice__button:focus-visible{outline:2px solid var(--frcn-cookie-accent-on-light);outline-offset:2px}.frcn-cookie-notice__button--accept{background-color:var(--frcn-cookie-accent);border:1px solid var(--frcn-cookie-accent);color:var(--frcn-cookie-accent-contrast)}.frcn-cookie-notice__button--reject{background-color:transparent;border:1px solid #9ca3af;color:var(--frcn-cookie-text)}.frcn-cookie-notice__button--customize{display:inline-flex;align-items:center;gap:8px;background-color:transparent;border:1px solid var(--frcn-cookie-accent-on-light);color:var(--frcn-cookie-text)}.frcn-cookie-notice__button-icon{display:block;width:16px;height:16px;flex-shrink:0;background-color:var(--frcn-cookie-accent-on-light);-webkit-mask-image:var(--frcn-cookie-icon-url);mask-image:var(--frcn-cookie-icon-url);-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center}.frcn-cookie-notice--bar{left:20px;right:20px;bottom:20px;max-height:calc(100vh - 40px);overflow-y:auto}.frcn-cookie-notice--bar.frcn-cookie-notice--generatepress{left:max(20px,calc((100vw - var(--frcn-cookie-content-width))/ 2));right:max(20px,calc((100vw - var(--frcn-cookie-content-width))/ 2))}.frcn-cookie-notice--bar .frcn-cookie-notice__panel{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:16px 24px}.frcn-cookie-notice--bar .frcn-cookie-notice__message{flex:1 1 320px}.frcn-cookie-notice--box{bottom:20px;max-width:380px;width:calc(100% - 40px);max-height:calc(100vh - 40px);overflow-y:auto}.frcn-cookie-notice--box.frcn-cookie-notice--right{right:20px}.frcn-cookie-notice--box.frcn-cookie-notice--left{left:20px}.frcn-cookie-notice--box .frcn-cookie-notice__panel{padding:20px;display:flex;flex-direction:column;gap:16px}.frcn-cookie-notice--box .frcn-cookie-notice__actions{flex-direction:column}.frcn-cookie-notice--box .frcn-cookie-notice__button{width:100%;text-align:center}.frcn-cookie-notice--popup{inset:0;display:flex;align-items:center;justify-content:center;padding:20px;overflow-y:auto;background-color:transparent}.frcn-cookie-notice--popup .frcn-cookie-notice__panel{padding:40px 32px 32px;max-width:420px;width:100%;max-height:calc(100vh - 40px);overflow-y:auto;display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;box-shadow:none}.frcn-cookie-notice__icon{display:flex;align-items:center;justify-content:center;width:64px;height:64px;flex-shrink:0;border-radius:50%;background-color:var(--frcn-cookie-accent)}.frcn-cookie-notice__icon::before{content:'';display:block;width:32px;height:32px;background-color:var(--frcn-cookie-accent-contrast);-webkit-mask-image:var(--frcn-cookie-icon-url);mask-image:var(--frcn-cookie-icon-url);-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center}.frcn-cookie-notice--popup .frcn-cookie-notice__actions{justify-content:center;width:100%}body.frcn-cookie-notice-lock-scroll{overflow:hidden}@media (max-width:600px){.frcn-cookie-notice--bar .frcn-cookie-notice__panel{padding:16px}.frcn-cookie-notice--bar .frcn-cookie-notice__actions,.frcn-cookie-notice--popup .frcn-cookie-notice__actions{flex-direction:column;width:100%}.frcn-cookie-notice--bar .frcn-cookie-notice__button,.frcn-cookie-notice--popup .frcn-cookie-notice__button{width:100%;text-align:center}.frcn-cookie-notice--box,.frcn-cookie-notice--popup .frcn-cookie-notice__panel{width:calc(100% - 24px);max-width:none}.frcn-cookie-notice--popup .frcn-cookie-notice__panel{max-height:calc(100vh - 24px)}.frcn-cookie-notice--bar,.frcn-cookie-notice--box{left:12px;right:12px;bottom:12px;max-height:calc(100vh - 24px)}}@media (prefers-reduced-motion:reduce){.frcn-cookie-notice,.frcn-cookie-notice__button,.frcn-cookie-reopen{transition:none}}.frcn-cookie-notice-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.frcn-cookie-reopen{position:fixed;z-index:999998;left:16px;bottom:16px;display:flex;align-items:center;justify-content:center;width:44px;height:44px;appearance:none;border:none;border-radius:50%;background-color:var(--frcn-cookie-accent);box-shadow:0 1px 3px rgba(0,0,0,.12);cursor:pointer}.frcn-cookie-reopen .frcn-cookie-notice__icon{width:100%;height:100%;background-color:transparent}.frcn-cookie-reopen .frcn-cookie-notice__icon::before{width:22px;height:22px;background-color:var(--frcn-cookie-accent-contrast)}.frcn-cookie-reopen:hover{filter:brightness(.92)}.frcn-cookie-reopen:focus-visible{outline:2px solid var(--frcn-cookie-accent);outline-offset:2px}.frcn-cookie-reopen[hidden]{display:none}.frcn-cookie-preferences{--frcn-cookie-accent:#687df9;--frcn-cookie-accent-contrast:#ffffff;--frcn-cookie-accent-on-light:#687df9;--frcn-cookie-bg:#ffffff;--frcn-cookie-text:#1f2937;--frcn-cookie-radius:12px;position:fixed;z-index:1000000;inset:0;display:flex;align-items:center;justify-content:center;padding:20px;overflow-y:auto;background-color:rgba(17,24,39,.5);font-family:inherit;line-height:1.5;box-sizing:border-box}.frcn-cookie-preferences *{box-sizing:border-box}.frcn-cookie-preferences[hidden]{display:none}.frcn-cookie-preferences__panel{background-color:var(--frcn-cookie-bg);color:var(--frcn-cookie-text);border-radius:var(--frcn-cookie-radius);max-width:480px;width:100%;max-height:calc(100vh - 40px);overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:16px}.frcn-cookie-preferences__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.frcn-cookie-preferences__title{margin:0;font-size:18px;font-weight:600;color:var(--frcn-cookie-text)}.frcn-cookie-preferences__close{appearance:none;background:0 0;border:none;cursor:pointer;font-size:20px;line-height:1;padding:4px 8px;color:var(--frcn-cookie-text)}.frcn-cookie-preferences__close:focus-visible{outline:2px solid var(--frcn-cookie-accent-on-light);outline-offset:2px}.frcn-cookie-preferences__message{margin:0;font-size:14px;color:var(--frcn-cookie-text)}.frcn-cookie-preferences__category{border:1px solid rgba(31,41,55,.12);border-radius:calc(var(--frcn-cookie-radius)/ 2);padding:12px 16px}.frcn-cookie-preferences__category-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.frcn-cookie-preferences__category-title{font-weight:600;font-size:14px}.frcn-cookie-preferences__category-badge{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.02em;color:var(--frcn-cookie-accent-on-light)}.frcn-cookie-preferences__category-toggle{width:20px;height:20px;flex-shrink:0;accent-color:var(--frcn-cookie-accent);cursor:pointer}.frcn-cookie-preferences__category-toggle:focus-visible{outline:2px solid var(--frcn-cookie-accent);outline-offset:2px}.frcn-cookie-preferences__category-description{margin:8px 0 0;font-size:13px;color:var(--frcn-cookie-text)}.frcn-cookie-preferences__actions{display:flex;flex-wrap:wrap;gap:12px;justify-content:flex-end}.frcn-cookie-preferences__actions .frcn-cookie-notice__button{flex:1 1 auto}@media (max-width:600px){.frcn-cookie-preferences__panel{max-height:calc(100vh - 24px)}.frcn-cookie-preferences__actions{flex-direction:column}.frcn-cookie-preferences__actions .frcn-cookie-notice__button{width:100%;text-align:center}} \ No newline at end of file diff --git a/assets/cookie-notice/frontconsent-cookie-notice.min.js b/assets/cookie-notice/frontconsent-cookie-notice.min.js index e48e341..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 o(){if(!window.frcnCookieNoticeBootstrapped){window.frcnCookieNoticeInject=window.frcnCookieNoticeInject||function(e,n,o,t){var i=function(e){return!t||!!t[e]};if(e&&i("analytics")){window.dataLayer=window.dataLayer||[],window.dataLayer.push({"gtm.start":(new Date).getTime(),event:"gtm.js"});var c=document.createElement("script");c.async=!0,c.src="https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(e),document.head.appendChild(c)}if(n&&i("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(o)||(o=[]),o.forEach(function(e){var n=e&&e.type?e.type:"",o=e&&e.id?e.id:"",t=e&&e.category?e.category:"marketing";if(o&&i(t))if("clientify_analytics_plus"===n){var c=document.createElement("script");c.defer=!0,c.src="https://analyticsplusdev.clientify.net/analytics_plus/pixel/"+encodeURIComponent(o),document.head.appendChild(c)}else if("clientify_analytics_classic"===n)!function(e,n,o,t){n[t]=n[t]||function(){(n[t].q=n[t].q||[]).push(arguments)};var i=e.createElement("script"),c=e.getElementsByTagName("script")[0];i.async=1,i.src="https://analytics.clientify.net/tracker.js",c.parentNode.insertBefore(i,c)}(document,window,0,"ana"),window.ana("setTrackerUrl","https://analytics.clientify.net"),window.ana("setTrackingCode",o),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:o}])}else if("openai_chatgpt_ads"===n){if(!window.oaiq){window.oaiq=function(){window.oaiq.q.push(arguments)},window.oaiq.q=[];var d=document.createElement("script");d.async=!0,d.src="https://bzrcdn.openai.com/sdk/oaiq.min.js",document.head.appendChild(d)}window.oaiq("init",{pixelId:o,debug:!0})}else"function"==typeof window.frcnCookieNoticeInjectIntegration?window.frcnCookieNoticeInjectIntegration(e):(window.frcnCookieNoticePendingIntegrations=window.frcnCookieNoticePendingIntegrations||[],window.frcnCookieNoticePendingIntegrations.push(e))})};var o="function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale();"accepted"!==e(frcnCookieNotice.cookieName)||o||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",o={ad_storage:n,ad_user_data:n,ad_personalization:n,analytics_storage:n};if("function"==typeof window.frcnCookieNoticeConsentModeState){var t=window.frcnCookieNoticeConsentModeState();t&&(o=t)}"function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale()&&(o={ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});window.gtag("consent","default",o)}(),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",c):c();var t=!1;function i(){var n=document.getElementById("frcn-cookie-reopen");if(n){var o=e(frcnCookieNotice.cookieName);"accepted"!==o&&"rejected"!==o||(n.hidden=!1,t||(t=!0,n.addEventListener("click",function(){document.cookie=frcnCookieNotice.cookieName+"=; path="+frcnCookieNotice.cookiePath+"; max-age=0; SameSite=Lax",frcnCookieNotice.isPolicyPage&&frcnCookieNotice.homeUrl?window.location.href=frcnCookieNotice.homeUrl:window.location.reload()})))}}function c(){if("undefined"!=typeof frcnCookieNotice){var t="undefined"!=typeof frcnCookieNoticeA11y?frcnCookieNoticeA11y:{bannerOpened:"Cookie consent banner opened.",accepted:"Cookies accepted.",rejected:"Cookies rejected."};o(),i();var c=document.getElementById("frcn-cookie-notice");if(c&&!function(n){var o=e(frcnCookieNotice.cookieName);return!("function"==typeof window.frcnCookieNoticeIsConsentStale&&window.frcnCookieNoticeIsConsentStale()||!n||"accepted"!==o&&"rejected"!==o||(n.style.display="none",0))}(c)){var a,d=c.querySelector('[data-frcn-cookie-action="accept"]'),r=c.querySelector('[data-frcn-cookie-action="reject"]'),s=c.classList.contains("frcn-cookie-notice--popup"),f=document.activeElement,u=document.getElementById("frcn-cookie-notice-announcer");a=s?150:20,window.setTimeout(function(){c.classList.remove("frcn-cookie-notice--init"),p(t.bannerOpened),s&&(document.body.classList.add("frcn-cookie-notice-lock-scroll"),d&&d.focus({preventScroll:!0}),document.addEventListener("keydown",k))},a),d&&d.addEventListener("click",function(e){e.preventDefault(),g("accepted")}),r&&r.addEventListener("click",function(e){e.preventDefault(),g("rejected")});var w=c.querySelector('[data-frcn-cookie-action="customize"]');w&&w.addEventListener("click",function(){var e;try{e=new CustomEvent("frcnCookieNoticeCustomize")}catch(n){(e=document.createEvent("CustomEvent")).initCustomEvent("frcnCookieNoticeCustomize",!0,!0,null)}document.dispatchEvent(e)});var l=!1}}function p(e){u&&(u.textContent=e)}function m(e){var n=Array.prototype.slice.call(c.querySelectorAll('a[href], button, input, [tabindex]:not([tabindex="-1"])'));if(n.length){var o=n[0],t=n[n.length-1];e.shiftKey&&document.activeElement===o?(e.preventDefault(),t.focus()):e.shiftKey||document.activeElement!==t||(e.preventDefault(),o.focus())}}function k(e){"Tab"===e.key?m(e):"Escape"!==e.key&&"Esc"!==e.key||(e.preventDefault(),g("rejected"))}function g(e){l||(l=!0,function(e){var n=24*parseInt(frcnCookieNotice.expirationDays,10)*60*60,o="https:"===window.location.protocol?"; Secure":"";document.cookie=frcnCookieNotice.cookieName+"="+e+"; path="+frcnCookieNotice.cookiePath+"; max-age="+n+"; SameSite=Lax"+o}(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),p("accepted"===e?t.accepted:t.rejected),function(){c.classList.add("frcn-cookie-notice--hidden"),document.body.classList.remove("frcn-cookie-notice-lock-scroll"),document.removeEventListener("keydown",k),s&&f&&"function"==typeof f.focus&&f.focus({preventScroll:!0});window.setTimeout(function(){c.parentNode&&c.parentNode.removeChild(c)},300)}(),i(),function(e){var n;try{n=new CustomEvent("frcnCookieConsent",{detail:{consent:e}})}catch(o){(n=document.createEvent("CustomEvent")).initCustomEvent("frcnCookieConsent",!0,!0,{consent:e})}document.dispatchEvent(n)}(e),function(e){var n=new FormData;n.append("action","frcn_get_cookie_notice_log_nonce"),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:n}).then(function(e){return e.json()}).then(function(n){if(n&&n.success&&n.data){var o=new FormData;return o.append("action","frcn_log_cookie_consent"),o.append("nonce",n.data.nonce),o.append("decision",e),fetch(frcnCookieNotice.ajaxUrl,{method:"POST",credentials:"same-origin",body:o})}}).catch(function(){})}(e),"accepted"===e&&n())}}}(); \ 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 new file mode 100644 index 0000000..6093b4b --- /dev/null +++ b/docs/preferences-panel-hooks.md @@ -0,0 +1,207 @@ +# Cookie preferences panel: hooks and filters + +This documents the extension points added for the "Customize cookie settings" +button and the cookie preferences panel it (and the persistent reopen +trigger) opens — see GitHub issue #11. They exist so **FrontConsent PRO** can +add per-category consent (Necessary, Preferences, Analytics, Marketing, …) +into the same panel without forking any markup, and so any other add-on can +do the same. + +All code lives in `includes/Frontend/CookieNotice.php`. + +## `frcn_cookie_notice_before_actions` (action, existing) + +```php +do_action( 'frcn_cookie_notice_before_actions', $options ); +``` + +Fires inside the banner's actions row, immediately before the Reject/Accept +buttons. This hook already existed before issue #11 (added for the reopen +trigger a11y work) and is exactly where the Free tier's own "Customize cookie +settings" button (`CookieNotice::render_customize_button()`) is printed — +registered on it unconditionally in the constructor. A PRO add-on can hook +the same action, at an earlier or later priority, to print its own trigger +here instead, or alongside it. + +- **When it fires:** while `render_banner_markup()` renders the banner, once + per page load, for every visitor (the output is cache-neutral — see the + class docblock). +- **Data available:** `$options` — the full `frontconsent_settings` option + array. + +## `frcn_cookie_preferences_categories` (action, new) + +```php +do_action( 'frcn_cookie_preferences_categories', $options ); +``` + +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 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. ``) +carrying a `data-frcn-category=""` attribute — +`frontconsent-cookie-notice.js`'s `collectPreferencesCategories()` reads every +element matching `[data-frcn-category]` inside the panel and builds the +category map from their `checked` state. + +- **When it fires:** while `render_preferences_panel()` renders the panel, + once per page load, for every visitor (also cache-neutral). +- **Data available:** `$options` — the full `frontconsent_settings` option + array. + +## `frcn_cookie_consent_categories` (filter, new) + +```php +$categories = apply_filters( 'frcn_cookie_consent_categories', $categories, $decision ); +``` + +Applied inside `log_consent_callback()` (the AJAX endpoint that records a +visitor's decision) around the per-category consent map, right before it's +returned in the JSON response as `data.categories`. This is the extension +point that lets an add-on **persist and read** per-category state alongside +the existing binary accepted/rejected cookie. + +- **When it fires:** every time a decision is recorded — from the banner's + own Accept/Reject buttons or from the preferences panel's Accept + all/Reject all/Save changes buttons, since they all funnel through the + same `log_consent_callback()` (no parallel consent-recording logic). +- **Data available:** + - `$categories` (`array`) — whatever the visitor's browser + submitted in the `categories` POST field (a JSON object, e.g. + `{"analytics": true, "marketing": false}`), decoded and sanitized to + `sanitize_key()`'d string keys and boolean values. Empty when nothing + was submitted (e.g. the binary Accept/Reject buttons, which never send a + `categories` field). + - `$decision` (`string`) — `'accepted'` or `'rejected'`, the binary + decision being recorded alongside it. +- **Forward compatibility:** the existing binary accepted/rejected cookie and + aggregate stat keep working completely unmodified whether or not anything + is hooked here — the category map is optional and purely additive, never + required. + +## `frcn_cookie_customize_button_label` (filter, new) + +```php +$label = apply_filters( 'frcn_cookie_customize_button_label', __( 'Customize cookie settings', 'frontconsent' ), $options ); +``` + +Filters the "Customize cookie settings" button's visible label. Runs inside +`render_customize_button()`. + +## `frcn_cookie_customize_button_enabled` (filter, new) + +```php +$enabled = apply_filters( 'frcn_cookie_customize_button_enabled', true, $options ); +``` + +Filters whether the "Customize cookie settings" button is printed at all. +Return `false` to hide it — e.g. a PRO tier that replaces it with its own +trigger printed on the same `frcn_cookie_notice_before_actions` action. Runs +inside `render_customize_button()`, before the label filter above. + +## Example: a minimal PRO-style add-on + +```php +// Render a single "Analytics" toggle inside the preferences panel. +add_action( 'frcn_cookie_preferences_categories', function ( $options ) { + ?> + + ` 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 +and hidden by default (`hidden` attribute); the script: + +- Opens it from either the "Customize cookie settings" button + (`[data-frcn-cookie-action="customize"]`) or the persistent reopen trigger + (`#frcn-cookie-reopen`), moving focus inside and trapping Tab/Shift+Tab + within the panel's own controls. +- Closes it on Escape or its close button + (`[data-frcn-cookie-action="close-preferences"]`), restoring focus to + whichever trigger opened it. +- Routes its Accept all (`[data-frcn-cookie-action="accept"]`), Reject all + (`[data-frcn-cookie-action="reject"]`) and Save changes + (`[data-frcn-cookie-action="save"]`) buttons through the exact same + decision-recording function (`handleDecision()`) the banner's own + Accept/Reject buttons use — same cookie, same Consent Mode update, same + logging AJAX call. +- "Save changes" reads every `[data-frcn-category]` control in the panel and + submits it as the `categories` field alongside the binary decision it + implies (accepted if any non-necessary category is checked, rejected + otherwise). + +## No-JS behavior + +Without JavaScript, opening/trapping/closing a dialog isn't possible, so the +"Customize cookie settings" button is hidden entirely via a `