Add Global Privacy Control (GPC) support to cookie banner SDK

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-24 11:13:43 +04:00
parent 2b6f131f43
commit 98dc04b7de
6 changed files with 70 additions and 4 deletions

View File

@@ -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<string, boolean> = {};
for (const cat of cfg.categories) {
consentData[cat.slug] = cat.kind === "NECESSARY";
}
this.recordConsent("GPC", consentData);
}
acceptAll(): void {
const cfg = this.config;

View File

@@ -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;
}

View File

@@ -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 },
}),
);

View File

@@ -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; }
</style>
<button part="button" aria-label="Cookie settings">
<span class="icon" part="icon" aria-hidden="true">${COOKIE_ICON}</span>
<span class="gpc-badge" part="gpc-badge"></span>
<slot></slot>
</button>
`;
@@ -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 ?? "";
}
}
}

View File

@@ -52,3 +52,14 @@ const COOKIE_DETAIL_LABELS: Record<string, Record<string, string>> = {
export function getCookieDetailLabels(lang: string): Record<string, string> {
return COOKIE_DETAIL_LABELS[lang] ?? COOKIE_DETAIL_LABELS.en;
}
const GPC_LABELS: Record<string, string> = {
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;
}

View File

@@ -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", () => {