/** * useConsentTracking * * Reads `app.config.xMarketing.tracking` and dynamically injects * scripts (GTM, GA4, Clarity, Meta Pixel, etc.) only after the * matching consent category is granted. * * Decoupled from any specific UI — the cookie consent component * fires a `consent:updated` window event with the granted category * map, and this composable listens + reacts. Auto-runs on plugin * load so re-visitors get scripts immediately (no flash of banner). * * Consent storage is shared with `` via * the `xMarketing.consent` localStorage key (JSON shape: * `{ necessary: true, analytics: bool, marketing: bool, preferences: bool }`). */ import { computed, ref, watch } from "vue"; export type ConsentCategory = "necessary" | "analytics" | "marketing" | "preferences"; export type ConsentState = Record; const STORAGE_KEY = "xMarketing.consent"; const EVENT_NAME = "consent:updated"; /** Default state — visitor hasn't decided yet. */ const DEFAULT_STATE: ConsentState = { necessary: true, // always true; required for the site to function analytics: false, marketing: false, preferences: false, }; /** SSR-safe no-op shared ref so server + client first-render agree. */ const consentState = ref(DEFAULT_STATE); let initialized = false; /** * Read consent from localStorage and re-fire any tracking scripts the * granted categories unlock. Called by the plugin on client mount. */ export function useConsentTracking() { const appConfig = useAppConfig(); const tracking = computed(() => appConfig.xMarketing?.tracking ?? null); const hasDecision = computed(() => typeof window !== "undefined" && window.localStorage.getItem(STORAGE_KEY) !== null, ); function readStored(): ConsentState | null { if (typeof window === "undefined") return null; const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { const parsed = JSON.parse(raw); return { ...DEFAULT_STATE, ...parsed }; } catch { return null; } } function writeStored(state: ConsentState) { if (typeof window === "undefined") return; window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } /** * Apply a new consent decision — writes to storage, updates the * shared ref, fires scripts, and notifies listeners via window event. */ function setConsent(next: ConsentState) { const merged = { ...DEFAULT_STATE, ...next, necessary: true }; consentState.value = merged; writeStored(merged); injectForState(merged); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: merged })); } } function acceptAll(): ConsentState { const next: ConsentState = { necessary: true, analytics: true, marketing: true, preferences: true, }; setConsent(next); return next; } function rejectAll(): ConsentState { const next: ConsentState = { necessary: true, analytics: false, marketing: false, preferences: false, }; setConsent(next); return next; } /** Re-apply stored consent — used on revisit to fire scripts immediately. */ function rehydrate() { if (typeof window === "undefined") return; const stored = readStored(); if (!stored) return; consentState.value = stored; injectForState(stored); } /** True when the visitor has granted consent for the given category. */ function hasConsent(category: ConsentCategory): boolean { return Boolean(consentState.value[category]); } // ---------------------------------------------------------------- // Script injection // ---------------------------------------------------------------- const injectedIds = new Set(); function isInjected(id: string): boolean { return injectedIds.has(id); } function markInjected(id: string) { injectedIds.add(id); } function buildScriptList(): Array<{ id: string; src?: string; inline?: string; category: ConsentCategory; attrs?: Record; }> { const cfg = tracking.value; if (!cfg) return []; const list: Array<{ id: string; src?: string; inline?: string; category: ConsentCategory; attrs?: Record; }> = []; // GTM (loaded head + body noscript; GA4 / Clarity ride inside GTM // when both are set, so we skip the standalone snippet to avoid // double counting). if (cfg.gtmId) { const hasGtmHostsOthers = Boolean(cfg.ga4Id || cfg.clarityId); if (!hasGtmHostsOthers) { // No other IDs — still load GTM itself, but skip standalone GA4/Clarity below. } list.push({ id: `gtm-${cfg.gtmId}`, src: `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(cfg.gtmId)}`, category: "analytics", attrs: { async: "", id: "gtm-script" }, }); } // GA4 — only emit the standalone snippet if GTM isn't already // managing it (avoids double page-view). if (cfg.ga4Id && !cfg.gtmId) { list.push({ id: `ga4-${cfg.ga4Id}`, src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(cfg.ga4Id)}`, category: "analytics", attrs: { async: "" }, }); list.push({ id: `ga4-init-${cfg.ga4Id}`, inline: `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '${cfg.ga4Id}', { anonymize_ip: true });`, category: "analytics", }); } // Microsoft Clarity — only if GTM isn't managing it. if (cfg.clarityId && !cfg.gtmId) { list.push({ id: `clarity-${cfg.clarityId}`, inline: `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window, document, "clarity", "script", "${cfg.clarityId}");`, category: "analytics", }); } // User-defined scripts. `Array.isArray` + iteration narrows the // element type to `never` when the layer default is `[]`, so cast // to the augmented type explicitly. const customScripts = (cfg.scripts ?? []) as Array<{ id: string; src?: string; inline?: string; category?: "necessary" | "analytics" | "marketing"; attrs?: Record; }>; for (const s of customScripts) { if (!s || !s.id) continue; list.push({ id: s.id, src: s.src, inline: s.inline, category: s.category ?? "analytics", attrs: s.attrs, }); } return list; } function injectScript(entry: ReturnType[number]) { if (typeof document === "undefined") return; if (isInjected(entry.id)) return; const el = document.createElement("script"); if (entry.attrs) { for (const [k, v] of Object.entries(entry.attrs)) { if (v === "") { // boolean attribute (e.g. `async`) (el as unknown as Record)[k] = true; el.setAttribute(k, ""); } else { el.setAttribute(k, v); } } } if (entry.src) { el.src = entry.src; } else if (entry.inline) { el.textContent = entry.inline; } document.head.appendChild(el); markInjected(entry.id); } function injectForState(state: ConsentState) { if (typeof window === "undefined") return; if (tracking.value?.autoInject === false) return; for (const entry of buildScriptList()) { if (state[entry.category]) injectScript(entry); } } // ---------------------------------------------------------------- // Plugin init — listen for events from the banner + auto-load on revisit // ---------------------------------------------------------------- function init() { if (typeof window === "undefined" || initialized) return; initialized = true; rehydrate(); window.addEventListener(EVENT_NAME, ((ev: CustomEvent) => { consentState.value = ev.detail; injectForState(ev.detail); }) as EventListener); } // React to app.config changes — if the site adds a tracking ID at // runtime (e.g. via HMR), honor it without requiring a full reload. if (typeof window !== "undefined") { watch( () => [ tracking.value?.gtmId, tracking.value?.ga4Id, tracking.value?.clarityId, tracking.value?.scripts, ], () => { if (hasDecision.value) { injectForState(consentState.value); } }, { deep: true }, ); } return { /** Reactive consent state — necessary/analytics/marketing/preferences. */ state: consentState, /** Has the visitor made any decision yet? */ hasDecision, /** Apply a full consent map. */ setConsent, /** Accept all categories. */ acceptAll, /** Reject non-essential categories. */ rejectAll, /** Re-read storage and fire scripts (no event broadcast). */ rehydrate, /** Has consent been granted for a single category? */ hasConsent, /** Initialize listeners + rehydrate. Called by the client plugin. */ init, }; }