Files
descrybe/apps/web/src/lib/analytics.ts
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

196 lines
5.7 KiB
TypeScript

/**
* Client analytics: GTM bootstrap, Consent Mode v2, dataLayer helpers.
*
* Setup (ops) — also documented on PUBLIC_GTM_ID in root `.env.example`:
* 1. Create a GA4 property in Google Analytics.
* 2. Create a GTM web container; set PUBLIC_GTM_ID=GTM-XXXX in root `.env`.
* 3. In GTM: GA4 Configuration tag with Consent Settings requiring
* analytics_storage (and ad_* for ads tags); publish the container.
* 4. SPA page views: Custom Event trigger `page_view` (from afterNavigate).
* Do NOT also enable GTM History Change / enhanced measurement page_view —
* that would double-count.
*
* Primary loading is GTM only — do not hard-code a GA measurement ID here.
*/
import { browser } from "$app/environment";
import { env } from "$env/dynamic/public";
import {
CONSENT_STORAGE_KEY,
CONSENT_WAIT_FOR_UPDATE_MS,
DENIED_CONSENT_DEFAULTS,
parseStoredConsent,
preferencesToConsentSignals,
type ConsentModeSignals,
type ConsentPreferences
} from "./analytics/consent-mode";
import { resolveGtmId } from "./analytics/gtm-id";
import {
toEcommerceObject,
type Ga4EcommerceFields
} from "./analytics/ecommerce";
export type { ConsentModeSignals, ConsentPreferences };
export type { Ga4EcommerceFields, Ga4EcommerceItem, Ga4ItemCategory } from "./analytics/ecommerce";
export {
CONSENT_STORAGE_KEY,
CONSENT_VERSION,
acceptAllPreferences,
estimatedSkusBucket,
parseStoredConsent,
preferencesToConsentSignals,
rejectNonEssentialPreferences,
serializeConsent
} from "./analytics/consent-mode";
export { resolveGtmId } from "./analytics/gtm-id";
export {
buildCreditPackEcommerce,
buildSubscriptionEcommerce,
claimPurchaseTracking,
resolveCheckoutEcommerceFromParams,
safeCheckoutSessionId
} from "./analytics/ecommerce";
type DataLayer = Array<Record<string, unknown> | IArguments | unknown[]>;
declare global {
interface Window {
dataLayer?: DataLayer;
gtag?: (...args: unknown[]) => void;
__descrybeGtmLoaded?: string;
__descrybeConsentDefaulted?: boolean;
}
}
function ensureDataLayer(): DataLayer {
if (!browser) return [];
window.dataLayer = window.dataLayer ?? [];
return window.dataLayer;
}
function ensureGtag(): void {
if (!browser) return;
ensureDataLayer();
if (typeof window.gtag === "function") return;
window.gtag = function gtag(...args: unknown[]) {
ensureDataLayer().push(args);
};
}
/** True when the user granted analytics_storage via the CMP. */
export function isAnalyticsGranted(): boolean {
if (!browser) return false;
try {
const stored = parseStoredConsent(localStorage.getItem(CONSENT_STORAGE_KEY));
return stored?.analytics === true;
} catch {
return false;
}
}
/** Call before GTM injects — EU/EEA default denied until CMP update. */
export function ensureConsentDefaults(): void {
if (!browser || window.__descrybeConsentDefaulted) return;
ensureGtag();
window.gtag?.("consent", "default", {
...DENIED_CONSENT_DEFAULTS,
wait_for_update: CONSENT_WAIT_FOR_UPDATE_MS
});
window.__descrybeConsentDefaulted = true;
}
export function updateConsentMode(prefs: ConsentPreferences): void {
if (!browser) return;
ensureGtag();
ensureConsentDefaults();
const signals = preferencesToConsentSignals(prefs);
window.gtag?.("consent", "update", signals);
pushDataLayer({
event: "consent_update",
analytics_storage: signals.analytics_storage,
ad_storage: signals.ad_storage,
ad_user_data: signals.ad_user_data,
ad_personalization: signals.ad_personalization
});
}
export function pushDataLayer(payload: Record<string, unknown>): void {
if (!browser) return;
ensureDataLayer().push(payload);
}
/**
* Custom event helper. No-ops until analytics_storage is granted via CMP.
* Never pass email, password, tokens, names, phone, SKU/GTIN/title, or API keys.
*/
export function trackEvent(event: string, params?: Record<string, unknown>): void {
if (!browser) return;
if (!isAnalyticsGranted()) return;
const name = event.trim();
if (!name) return;
pushDataLayer({
event: name,
...(params ?? {})
});
}
/**
* GA4 ecommerce custom event (GTM). Clears prior ecommerce, then pushes
* `{ event, ecommerce: { currency?, value?, transaction_id?, items } }`.
* Consent-gated like trackEvent. Never pass PII (email, cus_*, names).
*/
export function trackEcommerceEvent(
event: string,
ecommerce: Ga4EcommerceFields,
extra?: Record<string, unknown>
): void {
if (!browser) return;
if (!isAnalyticsGranted()) return;
const name = event.trim();
if (!name) return;
pushDataLayer({ ecommerce: null });
pushDataLayer({
event: name,
ecommerce: toEcommerceObject(ecommerce),
...(extra ?? {})
});
}
/**
* SPA page_view for GTM (Custom Event trigger: page_view).
* Gated on analytics consent. Prefer this over GTM History Change.
*/
export function trackPageview(path: string, opts?: { title?: string; location?: string }): void {
if (!browser) return;
if (!isAnalyticsGranted()) return;
pushDataLayer({
event: "page_view",
page_path: path,
page_title: opts?.title ?? document.title,
page_location: opts?.location ?? window.location.href
});
}
export function resolveConfiguredGtmId(): string | null {
return resolveGtmId(env.PUBLIC_GTM_ID);
}
/** Load GTM container once when PUBLIC_GTM_ID is a valid GTM-XXXX id. */
export function loadGoogleTagManager(gtmId = resolveConfiguredGtmId()): string | null {
if (!browser) return null;
const id = resolveGtmId(gtmId);
if (!id) return null;
if (window.__descrybeGtmLoaded === id) return id;
ensureConsentDefaults();
ensureDataLayer().push({ "gtm.start": Date.now(), event: "gtm.js" });
const script = document.createElement("script");
script.async = true;
script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(id)}`;
document.head.appendChild(script);
window.__descrybeGtmLoaded = id;
return id;
}