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.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user