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:
@@ -87,6 +87,7 @@ export class CookieBannerClient {
|
|||||||
private consent: VisitorConsent | null = null;
|
private consent: VisitorConsent | null = null;
|
||||||
private observer: MutationObserver | null = null;
|
private observer: MutationObserver | null = null;
|
||||||
private detector: CookieDetector | null = null;
|
private detector: CookieDetector | null = null;
|
||||||
|
private _gpcApplied = false;
|
||||||
|
|
||||||
constructor(config: CookieBannerClientOptions) {
|
constructor(config: CookieBannerClientOptions) {
|
||||||
let base = config.baseUrl;
|
let base = config.baseUrl;
|
||||||
@@ -152,6 +153,11 @@ export class CookieBannerClient {
|
|||||||
this.consent = null;
|
this.consent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.consent && this.gpcDetected) {
|
||||||
|
this.gpc();
|
||||||
|
this._gpcApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
void flush(this.bannerId);
|
void flush(this.bannerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +176,26 @@ export class CookieBannerClient {
|
|||||||
return this.consent !== null;
|
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 {
|
acceptAll(): void {
|
||||||
const cfg = this.config;
|
const cfg = this.config;
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface ProboRootElement extends ProboElement {
|
|||||||
readonly state: ProboState;
|
readonly state: ProboState;
|
||||||
readonly reopenWidget: string;
|
readonly reopenWidget: string;
|
||||||
readonly consentDraft: ConsentDraft;
|
readonly consentDraft: ConsentDraft;
|
||||||
|
readonly gpcApplied: boolean;
|
||||||
setState(state: ProboState): void;
|
setState(state: ProboState): void;
|
||||||
updateDraft(category: string, value: boolean): void;
|
updateDraft(category: string, value: boolean): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
|
|||||||
return this._draft;
|
return this._draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get gpcApplied(): boolean {
|
||||||
|
return this._client?.gpcApplied ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
||||||
if (name === "reopen-widget" && oldValue !== newValue) {
|
if (name === "reopen-widget" && oldValue !== newValue) {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
@@ -150,7 +154,7 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
|
|||||||
new CustomEvent("probo-ready", {
|
new CustomEvent("probo-ready", {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
detail: { config: this._config },
|
detail: { config: this._config, gpcApplied: this.gpcApplied },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class ProboSettingsButton extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static get observedAttributes(): string[] {
|
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 {
|
attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void {
|
||||||
@@ -36,6 +36,9 @@ export class ProboSettingsButton extends HTMLElement {
|
|||||||
btn.setAttribute("aria-label", newValue);
|
btn.setAttribute("aria-label", newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (name === "gpc-label") {
|
||||||
|
this.updateGpcBadge(newValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get position(): string {
|
private get position(): string {
|
||||||
@@ -73,10 +76,19 @@ export class ProboSettingsButton extends HTMLElement {
|
|||||||
}
|
}
|
||||||
button:hover { opacity: 0.85; }
|
button:hover { opacity: 0.85; }
|
||||||
.icon { display: flex; flex-shrink: 0; }
|
.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; }
|
::slotted(*) { display: contents; }
|
||||||
</style>
|
</style>
|
||||||
<button part="button" aria-label="Cookie settings">
|
<button part="button" aria-label="Cookie settings">
|
||||||
<span class="icon" part="icon" aria-hidden="true">${COOKIE_ICON}</span>
|
<span class="icon" part="icon" aria-hidden="true">${COOKIE_ICON}</span>
|
||||||
|
<span class="gpc-badge" part="gpc-badge"></span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
@@ -141,4 +153,11 @@ export class ProboSettingsButton extends HTMLElement {
|
|||||||
if (!this.root) return;
|
if (!this.root) return;
|
||||||
this.root.setState("panel");
|
this.root.setState("panel");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private updateGpcBadge(label: string | null): void {
|
||||||
|
const badge = this.shadow.querySelector(".gpc-badge");
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = label ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,3 +52,14 @@ const COOKIE_DETAIL_LABELS: Record<string, Record<string, string>> = {
|
|||||||
export function getCookieDetailLabels(lang: string): Record<string, string> {
|
export function getCookieDetailLabels(lang: string): Record<string, string> {
|
||||||
return COOKIE_DETAIL_LABELS[lang] ?? COOKIE_DETAIL_LABELS.en;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { registerComponents } from "../components";
|
import { registerComponents } from "../components";
|
||||||
import type { ProboCookieBannerRoot } from "../components/cookie-banner-root";
|
import type { ProboCookieBannerRoot } from "../components/cookie-banner-root";
|
||||||
import type { BannerConfig } from "../client";
|
import type { BannerConfig } from "../client";
|
||||||
import { interpolate } from "../i18n";
|
import { getGpcLabel, interpolate } from "../i18n";
|
||||||
import { BRANDING, CHEVRON_DOWN, CLOSE_ICON } from "../html";
|
import { BRANDING, CHEVRON_DOWN, CLOSE_ICON } from "../html";
|
||||||
import { THEMED_STYLES } from "./styles";
|
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;
|
const root = this.shadow.querySelector("probo-cookie-banner-root") as ProboCookieBannerRoot;
|
||||||
|
|
||||||
root.addEventListener("probo-ready", (e: Event) => {
|
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);
|
this.applyTexts(config);
|
||||||
if (!config.show_branding) {
|
if (!config.show_branding) {
|
||||||
this.shadow.querySelectorAll("[data-branding]").forEach(el => {
|
this.shadow.querySelectorAll("[data-branding]").forEach(el => {
|
||||||
(el as HTMLElement).setAttribute("hidden", "");
|
(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", () => {
|
this.shadow.querySelector("[data-action=back]")?.addEventListener("click", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user