diff --git a/CLAUDE.md b/CLAUDE.md index cdc3614..8eb38e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,15 +87,27 @@ Forms whose Privacy settings put a category on "On consent" load no scripts for it until the host page reports the visitor's answer: ```js -window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true }); +window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true }); ``` `consent.ts` holds the answer in module state and notifies `src/index.ts`, which relays `surface:consent` to every Surface iframe (and re-sends it on each `SEND_DATA` handshake, for forms that mount after the banner was answered). -Omitted categories count as not granted. The categories mirror the form-render -gate in `surface_forms` (`lib/client/thirdParty/`) — keep the message shape in -sync with its `hostConsent.ts`. +Every call is a complete snapshot: omitted categories count as not granted. The +categories mirror the form-render gate in `surface_forms` +(`lib/client/thirdParty/`) — keep the message shape in sync with its +`hostConsent.ts`. + +Each answer is also written to `window.__SURFACE_CONSENT__` and dispatched as a +`surface:consent` DOM event, which is how a Forms SDK form on the same page hears +it; the tag reads a snapshot the SDK left there if the SDK loaded first. + +`cookieTracking` also gates the tag's own host-side work, but only when the +` + +``` + +With the attribute, the tag does no visitor recognition, sets no journey cookies +and forwards no page cookies to Surface forms until `cookieTracking` is granted. +Form rendering and submission work regardless. Without the attribute the tag +behaves exactly as before. See `CLAUDE.md` for the message contract. diff --git a/src/consent/consent.test.ts b/src/consent/consent.test.ts index 579d449..282b16a 100644 --- a/src/consent/consent.test.ts +++ b/src/consent/consent.test.ts @@ -20,9 +20,16 @@ describe("surface consent", () => { expect(getSurfaceConsent()).toEqual({ adTracking: true, surfaceAnalytics: false, + cookieTracking: false, }); }); + it("treats each answer as a complete snapshot, so an older two-field call denies cookies", () => { + setSurfaceConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true }); + setSurfaceConsent({ adTracking: true, surfaceAnalytics: true }); + expect(getSurfaceConsent()?.cookieTracking).toBe(false); + }); + it("ignores non-boolean values", () => { setSurfaceConsent({ adTracking: "yes" as unknown as boolean }); expect(getSurfaceConsent()?.adTracking).toBe(false); @@ -34,9 +41,22 @@ describe("surface consent", () => { expect(getSurfaceConsent()).toEqual({ adTracking: false, surfaceAnalytics: true, + cookieTracking: false, }); }); + it("shares each answer with an SDK in the same document", () => { + const heard: unknown[] = []; + const listener = (event: Event) => heard.push((event as CustomEvent).detail); + window.addEventListener("surface:consent", listener); + setSurfaceConsent({ cookieTracking: true }); + window.removeEventListener("surface:consent", listener); + + const snapshot = { adTracking: false, surfaceAnalytics: false, cookieTracking: true }; + expect(heard).toEqual([snapshot]); + expect((window as Window & { __SURFACE_CONSENT__?: unknown }).__SURFACE_CONSENT__).toEqual(snapshot); + }); + it("notifies the relay on every answer", () => { const onChange = vi.fn(); onSurfaceConsentChange(onChange); diff --git a/src/consent/consent.ts b/src/consent/consent.ts index 9c8f39c..e5ae85b 100644 --- a/src/consent/consent.ts +++ b/src/consent/consent.ts @@ -3,16 +3,36 @@ export const SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent"; /** - * Categories of third-party calls a Surface form can be told to wait for. They + * Categories of optional tracking a Surface form can be told to wait for. They * mirror the form's Privacy settings: a category set to "On consent" there stays * off until this page reports it as granted. + * + * `cookieTracking` also gates this tag's own host-side work — visitor + * recognition, the journey cookies and forwarding the page's cookies — when the + * script is loaded with `data-consent-mode`. */ export interface SurfaceConsent { adTracking: boolean; surfaceAnalytics: boolean; + cookieTracking: boolean; } -let consent: SurfaceConsent | null = null; +// Shared with the Forms SDK when both run in one document: the latest snapshot +// lives on `window.__SURFACE_CONSENT__` and every change dispatches this event. +export const SURFACE_CONSENT_EVENT = "surface:consent"; +type ConsentWindow = Window & { __SURFACE_CONSENT__?: SurfaceConsent }; + +const normalize = (granted: Partial | undefined): SurfaceConsent => ({ + adTracking: granted?.adTracking === true, + surfaceAnalytics: granted?.surfaceAnalytics === true, + cookieTracking: granted?.cookieTracking === true, +}); + +// An SDK that loaded first may already hold the page's answer. +let consent: SurfaceConsent | null = + typeof window !== "undefined" && (window as ConsentWindow).__SURFACE_CONSENT__ + ? normalize((window as ConsentWindow).__SURFACE_CONSENT__) + : null; let onChange: (() => void) | null = null; /** Null until the page has answered — forms treat that as nothing granted. */ @@ -23,19 +43,22 @@ export const onSurfaceConsentChange = (callback: () => void): void => { }; /** - * Public API — call from a consent banner once the visitor answers: + * Public API — call from a consent banner once the visitor answers, and again + * whenever the answer changes: * * ```js - * window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true }); + * window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true }); * ``` * - * Omitted categories count as not granted. Calling again with `false` stops - * further tracking, but cannot unload vendor scripts a form already started. + * Every call is a complete snapshot: omitted categories count as not granted. + * Calling again with `false` stops further tracking, but cannot unload vendor + * scripts a form already started. */ export const setSurfaceConsent = (granted: Partial): void => { - consent = { - adTracking: granted?.adTracking === true, - surfaceAnalytics: granted?.surfaceAnalytics === true, - }; + consent = normalize(granted); + if (typeof window !== "undefined") { + (window as ConsentWindow).__SURFACE_CONSENT__ = { ...consent }; + window.dispatchEvent(new CustomEvent(SURFACE_CONSENT_EVENT, { detail: { ...consent } })); + } onChange?.(); }; diff --git a/src/index.ts b/src/index.ts index 521ecf2..0d1fca2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,8 +34,10 @@ w.SurfaceSetConsent = setSurfaceConsent; // Relay a consent answer to the forms on the page. The store push goes with it // so a form that was blocked until now still gets the parent URL params it -// needs to fire conversions in first-party context. +// needs to fire conversions in first-party context. Under data-consent-mode the +// tag's own recognition and journey work start or stop here too. onSurfaceConsentChange(() => { + SurfaceTagStore.applyConsent(); SurfaceTagStore.sendConsentToIframes(); SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE"); }); diff --git a/src/lead/identify.ts b/src/lead/identify.ts index b2b4c28..eb1278e 100644 --- a/src/lead/identify.ts +++ b/src/lead/identify.ts @@ -29,6 +29,10 @@ export function setLeadDataWithTTL(data: Omit): void { localStorage.setItem("surfaceLeadData", JSON.stringify(item)); } +export function clearLeadData(): void { + localStorage.removeItem("surfaceLeadData"); +} + export function getLeadDataWithTTL(): LeadData | null { const itemStr = localStorage.getItem("surfaceLeadData"); if (!itemStr) return null; diff --git a/src/runtime-config.ts b/src/runtime-config.ts index 2c36beb..dacfc05 100644 --- a/src/runtime-config.ts +++ b/src/runtime-config.ts @@ -6,6 +6,10 @@ import { } from "./constants"; export const CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain"; +// Present on the