From 98dc04b7dee9d3dd61e208e81221c5a3adb37b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 24 Apr 2026 11:13:43 +0400 Subject: [PATCH] Add Global Privacy Control (GPC) support to cookie banner SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When navigator.globalPrivacyControl is true and no prior consent exists, auto-reject all non-necessary cookies with action "GPC", skip showing the banner, and display an "Opt-Out Preference Signal Honored" badge on the settings button (CPRA compliance). GPC labels are hardcoded in the SDK for en/fr/de/es. Users can still override via the preference panel. Signed-off-by: Émile Ré --- packages/cookie-banner/src/client.ts | 26 +++++++++++++++++++ packages/cookie-banner/src/components/base.ts | 1 + .../src/components/cookie-banner-root.ts | 6 ++++- .../src/components/settings-button.ts | 21 ++++++++++++++- packages/cookie-banner/src/i18n.ts | 11 ++++++++ .../src/themed-banner/themed-banner.ts | 9 +++++-- 6 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index f4a9d6b41..6e66ac15a 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -87,6 +87,7 @@ export class CookieBannerClient { private consent: VisitorConsent | null = null; private observer: MutationObserver | null = null; private detector: CookieDetector | null = null; + private _gpcApplied = false; constructor(config: CookieBannerClientOptions) { let base = config.baseUrl; @@ -152,6 +153,11 @@ export class CookieBannerClient { this.consent = null; } + if (!this.consent && this.gpcDetected) { + this.gpc(); + this._gpcApplied = true; + } + void flush(this.bannerId); } @@ -170,6 +176,26 @@ export class CookieBannerClient { return this.consent !== null; } + get gpcDetected(): boolean { + return typeof navigator !== "undefined" && + (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl === true; + } + + get gpcApplied(): boolean { + return this._gpcApplied; + } + + gpc(): void { + const cfg = this.config; + + const consentData: Record = {}; + for (const cat of cfg.categories) { + consentData[cat.slug] = cat.kind === "NECESSARY"; + } + + this.recordConsent("GPC", consentData); + } + acceptAll(): void { const cfg = this.config; diff --git a/packages/cookie-banner/src/components/base.ts b/packages/cookie-banner/src/components/base.ts index b5210b2d0..bdfbc194e 100644 --- a/packages/cookie-banner/src/components/base.ts +++ b/packages/cookie-banner/src/components/base.ts @@ -66,6 +66,7 @@ export interface ProboRootElement extends ProboElement { readonly state: ProboState; readonly reopenWidget: string; readonly consentDraft: ConsentDraft; + readonly gpcApplied: boolean; setState(state: ProboState): void; updateDraft(category: string, value: boolean): void; } diff --git a/packages/cookie-banner/src/components/cookie-banner-root.ts b/packages/cookie-banner/src/components/cookie-banner-root.ts index bbb59e15c..973acca82 100644 --- a/packages/cookie-banner/src/components/cookie-banner-root.ts +++ b/packages/cookie-banner/src/components/cookie-banner-root.ts @@ -53,6 +53,10 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem return this._draft; } + get gpcApplied(): boolean { + return this._client?.gpcApplied ?? false; + } + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { if (name === "reopen-widget" && oldValue !== newValue) { this.dispatchEvent( @@ -150,7 +154,7 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem new CustomEvent("probo-ready", { bubbles: true, composed: true, - detail: { config: this._config }, + detail: { config: this._config, gpcApplied: this.gpcApplied }, }), ); diff --git a/packages/cookie-banner/src/components/settings-button.ts b/packages/cookie-banner/src/components/settings-button.ts index 5238ab12c..e28600953 100644 --- a/packages/cookie-banner/src/components/settings-button.ts +++ b/packages/cookie-banner/src/components/settings-button.ts @@ -26,7 +26,7 @@ export class ProboSettingsButton extends HTMLElement { } static get observedAttributes(): string[] { - return ["position", "aria-settings-label"]; + return ["position", "aria-settings-label", "gpc-label"]; } attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { @@ -36,6 +36,9 @@ export class ProboSettingsButton extends HTMLElement { btn.setAttribute("aria-label", newValue); } } + if (name === "gpc-label") { + this.updateGpcBadge(newValue); + } } private get position(): string { @@ -73,10 +76,19 @@ export class ProboSettingsButton extends HTMLElement { } button:hover { opacity: 0.85; } .icon { display: flex; flex-shrink: 0; } + .gpc-badge { + display: none; + font-size: 11px; + line-height: 1; + font-weight: 600; + white-space: nowrap; + } + :host([gpc-label]) .gpc-badge { display: inline; } ::slotted(*) { display: contents; } `; @@ -141,4 +153,11 @@ export class ProboSettingsButton extends HTMLElement { if (!this.root) return; this.root.setState("panel"); }; + + private updateGpcBadge(label: string | null): void { + const badge = this.shadow.querySelector(".gpc-badge"); + if (badge) { + badge.textContent = label ?? ""; + } + } } diff --git a/packages/cookie-banner/src/i18n.ts b/packages/cookie-banner/src/i18n.ts index 172a4131b..15170d2f4 100644 --- a/packages/cookie-banner/src/i18n.ts +++ b/packages/cookie-banner/src/i18n.ts @@ -52,3 +52,14 @@ const COOKIE_DETAIL_LABELS: Record> = { export function getCookieDetailLabels(lang: string): Record { return COOKIE_DETAIL_LABELS[lang] ?? COOKIE_DETAIL_LABELS.en; } + +const GPC_LABELS: Record = { + en: "Opt-Out Preference Signal Honored", + fr: "Signal de préférence de désinscription respecté", + de: "Opt-Out-Präferenzsignal beachtet", + es: "Señal de exclusión respetada", +}; + +export function getGpcLabel(lang: string): string { + return GPC_LABELS[lang] ?? GPC_LABELS.en; +} diff --git a/packages/cookie-banner/src/themed-banner/themed-banner.ts b/packages/cookie-banner/src/themed-banner/themed-banner.ts index ba046a323..3b626b557 100644 --- a/packages/cookie-banner/src/themed-banner/themed-banner.ts +++ b/packages/cookie-banner/src/themed-banner/themed-banner.ts @@ -15,7 +15,7 @@ import { registerComponents } from "../components"; import type { ProboCookieBannerRoot } from "../components/cookie-banner-root"; import type { BannerConfig } from "../client"; -import { interpolate } from "../i18n"; +import { getGpcLabel, interpolate } from "../i18n"; import { BRANDING, CHEVRON_DOWN, CLOSE_ICON } from "../html"; import { THEMED_STYLES } from "./styles"; @@ -127,13 +127,18 @@ export class ProboThemedBanner extends HTMLElement { const root = this.shadow.querySelector("probo-cookie-banner-root") as ProboCookieBannerRoot; root.addEventListener("probo-ready", (e: Event) => { - const config = (e as CustomEvent).detail.config as BannerConfig; + const detail = (e as CustomEvent).detail; + const config = detail.config as BannerConfig; this.applyTexts(config); if (!config.show_branding) { this.shadow.querySelectorAll("[data-branding]").forEach(el => { (el as HTMLElement).setAttribute("hidden", ""); }); } + if (detail.gpcApplied) { + const settingsBtn = this.shadow.querySelector("probo-settings-button"); + settingsBtn?.setAttribute("gpc-label", getGpcLabel(config.language)); + } }); this.shadow.querySelector("[data-action=back]")?.addEventListener("click", () => {