@aranova/tracking-react 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sales.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sales.ts","../../tracking-core/src/capabilities.ts","../../tracking-core/src/phone.ts","../../tracking-core/src/user-data.ts","../../tracking-core/src/consent.ts","../../tracking-core/src/gtag.ts","../../tracking-core/src/resources/conversion-firing.ts","../../../contracts/autoleap-booking-links.json","../../tracking-core/src/integrations-public.ts","../../tracking-core/src/uuid.ts","../../tracking-core/src/session.ts","../../tracking-core/src/resources/http/errors.ts","../../tracking-core/src/resources/intl/date.ts","../../tracking-core/src/resources/sales/money.ts","../../tracking-core/src/resources/tracking-config-runtime.ts","../../tracking-core/src/ingest.ts","../../tracking-core/src/resources/http/request.ts","../../tracking-core/src/resources/sales/transport.ts","../../tracking-core/src/resources/sales/bookings.ts","../../tracking-core/src/resources/sales/client.ts","../../tracking-core/src/resources/sales/schema.ts","../../tracking-core/src/resources/sales/generated/zod.gen.ts","../../tracking-core/src/resources/services.ts"],"sourcesContent":["// Server-safe sales SDK entry — `@aranova/tracking-react/sales`. React-free; the\n// recommended import for server/serverless modules (API routes, Node scripts).\n// The surface is authored once in tracking-core (see sales-public.ts); this file\n// exists only as the per-package tsup build entry.\nexport * from \"../../tracking-core/src/sales-public\";\n","// Capability self-report registry.\n//\n// Every SDK construct registers itself here at construction time, and the\n// registered set rides out on `TrackingClientContext.capabilities`, which the\n// backend persists on the session row. The dashboard derives what a client site\n// ACTUALLY has installed from those sessions, rather than trusting an operator\n// checkbox — see contracts/tracking-capabilities.json, which pins this id list\n// against the backend enum and the dashboard type union.\n//\n// Browser-only by design: a server-side client (calendar bookings) emits no\n// session, and its artifact is better evidence than a self-report anyway.\n//\n// This module deliberately imports nothing: it is the one thing every other\n// resource can import without adding an edge that could close a cycle.\n\n/**\n * Every capability an install can report. Pinned by\n * `contracts/tracking-capabilities.json` — adding one here without a catalog\n * entry fails the drift test.\n */\nexport const TRACKING_CAPABILITIES = [\n \"ad_tags_google\",\n \"ad_tags_meta\",\n \"base_tracking\",\n \"blog_rendering\",\n \"booking_handoff\",\n \"booking_integration\",\n \"calendar_read\",\n \"calendar_write\",\n \"consent_controls\",\n \"conversion_goals_auto\",\n \"conversion_goals_manual\",\n \"cross_site_linker\",\n \"cta_click_capture\",\n \"form_capture\",\n \"phone_click_capture\",\n \"phone_fields\",\n] as const;\n\nexport type TrackingCapability = (typeof TRACKING_CAPABILITIES)[number];\n\n/**\n * DOM attribute a server-rendered surface stamps to report itself.\n *\n * Blog rendering never calls the Aranova API — it reads the CDN directly — so\n * neither the ingest payload nor the request-header path can see it. The\n * renderer stamps this attribute instead and the browser context collector\n * picks it up on the next batch.\n */\nexport const CAPABILITY_DOM_ATTRIBUTE = \"data-aranova-capability\";\n\nconst registered = new Set<TrackingCapability>();\n\n/**\n * Record that a capability is present in this runtime.\n *\n * Idempotent and side-effect free — safe to call on every construction,\n * including React strict-mode double mounts.\n */\nexport function registerCapability(capability: TrackingCapability): void {\n registered.add(capability);\n}\n\n/**\n * Capabilities registered so far in this runtime, sorted for a stable wire\n * value (so the backend can hash the array to skip redundant writes).\n *\n * Also collects `data-aranova-capability` markers from the DOM, which is how\n * server-rendered surfaces that never call our API report themselves.\n */\nexport function getRegisteredCapabilities(): TrackingCapability[] {\n const all = new Set<TrackingCapability>(registered);\n for (const marker of readDomMarkers()) all.add(marker);\n return Array.from(all).sort();\n}\n\nfunction readDomMarkers(): TrackingCapability[] {\n if (typeof document === \"undefined\") return [];\n const known = new Set<string>(TRACKING_CAPABILITIES);\n const found: TrackingCapability[] = [];\n // Unknown values are dropped rather than forwarded: the backend rejects ids\n // outside its enum, and a stray attribute on a client's page must never be\n // able to fail an otherwise-valid ingest batch.\n for (const node of document.querySelectorAll(`[${CAPABILITY_DOM_ATTRIBUTE}]`)) {\n const value = node.getAttribute(CAPABILITY_DOM_ATTRIBUTE);\n if (value && known.has(value)) found.push(value as TrackingCapability);\n }\n return found;\n}\n\n/** Test-only: drop everything registered so far. */\nexport function resetRegisteredCapabilitiesForTests(): void {\n registered.clear();\n}\n","// Framework-agnostic phone-number utilities — isomorphic (browser + Node/RSC),\n// zero React. Bundled `libphonenumber-js` (standard metadata) so clients add no\n// dependency. The transmitted value is ALWAYS E.164; display is the only knob.\n\nimport { AsYouType, parsePhoneNumberFromString, type CountryCode } from \"libphonenumber-js\";\n\nexport type { CountryCode };\n\n/**\n * How a phone number is shown in the UI. The transmitted value is always E.164\n * and is deliberately NOT part of this — only the display format is configurable.\n * A function form covers the long tail (`(parsed) => string`).\n */\nexport type PhoneDisplayFormat =\n | \"national\"\n | \"international\"\n | \"e164\"\n | ((parsed: ParsedPhone) => string);\n\nexport interface ParsedPhone {\n /** E.164 (`\"+14165550199\"`) or `null` when the input isn't a valid number. This is what gets transmitted. */\n e164: string | null;\n /** National display form (`\"(416) 555-0199\"`); empty string when unparseable. */\n national: string;\n /** International display form (`\"+1 416 555 0199\"`); empty string when unparseable. */\n international: string;\n /** ISO-3166 country resolved by libphonenumber, or `null`. */\n country: CountryCode | null;\n isValid: boolean;\n}\n\n/** Region assumed for numbers typed without a country code. */\nexport const DEFAULT_PHONE_COUNTRY: CountryCode = \"CA\";\n\n/** Parse a raw/display string into every representation at once (one parse → display + wire never drift). */\nexport function parsePhone(raw: string, country?: CountryCode): ParsedPhone {\n const region = country ?? DEFAULT_PHONE_COUNTRY;\n const parsed = parsePhoneNumberFromString(raw ?? \"\", region);\n if (!parsed) {\n return { e164: null, national: \"\", international: \"\", country: region, isValid: false };\n }\n const isValid = parsed.isValid();\n return {\n // E.164 is only surfaced for a *valid* number — a possible-but-invalid input\n // (e.g. too few digits) still parses but must not be transmitted.\n e164: isValid ? parsed.number : null,\n national: parsed.formatNational(),\n international: parsed.formatInternational(),\n country: parsed.country ?? region,\n isValid,\n };\n}\n\n/** Normalize any raw/display value to E.164, or `null` if it isn't a valid number. */\nexport function toE164(raw: string, country?: CountryCode): string | null {\n return parsePhone(raw, country).e164;\n}\n\n/** Format a value for display. Defaults to `'national'`. Never affects the wire value. */\nexport function formatPhone(\n value: string,\n format: PhoneDisplayFormat = \"national\",\n country?: CountryCode,\n): string {\n const parsed = parsePhone(value, country);\n if (typeof format === \"function\") return format(parsed);\n switch (format) {\n case \"international\":\n return parsed.international || value;\n case \"e164\":\n return parsed.e164 ?? value;\n case \"national\":\n default:\n return parsed.national || value;\n }\n}\n\n/** Live, incremental formatting for an `<input>` as the user types (`AsYouType`). */\nexport function formatPhoneAsTyped(raw: string, country?: CountryCode): string {\n return new AsYouType(country ?? DEFAULT_PHONE_COUNTRY).input(raw ?? \"\");\n}\n","// Enhanced-conversions user_data bridge (Google Ads \"user-provided data\").\n//\n// Why this exists: Google's automatic user-provided-data capture demonstrably\n// misses phone fields on client sites (verified live: a filled tel input never\n// produced a `pn` hit parameter), and our offline sale uploads are phone-hash\n// keyed — without a phone↔click pairing built at conversion time, none of them\n// can match. This module makes the SDK the deterministic capture path: it\n// extracts the visitor's own email/phone from `form_submit` fields (or an\n// explicit override / a recorded sale's customer fields), normalizes them\n// (E.164 phone, lowercased email), and sets them as PLAINTEXT\n// `gtag('set','user_data', …)` immediately before a conversion fires. gtag.js\n// normalizes further and SHA-256 hashes IN THE BROWSER — plaintext never\n// leaves the page, and we never own Google's hashing contract.\n//\n// Privacy posture: the stash is module-level memory only — never persisted to\n// any storage, never added to our own ingest payloads (the form fields were\n// already part of the site's `form_submit` metadata; we only read them).\n// Consent gating happens at the egress in `fireConversionWithConsent` (the\n// same gate every conversion already passes through); an explicit consent\n// denial additionally clears the stash and nulls gtag's page state (see\n// consent.ts). This module must NOT import consent.ts — consent.ts imports us\n// for that clearing hook, and the reverse edge would be a cycle.\n\nimport { toE164, type CountryCode } from \"./phone\";\n\n/** Raw identifiers a caller may hand us (normalized before use). */\nexport interface ConversionUserData {\n email?: string | null;\n phone?: string | null;\n}\n\n/** Normalized identifiers as gtag's `user_data` expects them. */\nexport interface NormalizedUserData {\n email: string | null;\n /** E.164 (`\"+16477836797\"`) — the format Google's parser reliably accepts. */\n phoneNumber: string | null;\n}\n\nconst EMAIL_SHAPE = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/;\nconst EMAIL_NAME_HINT = /e[-_]?mail/i;\n// `\\b` won't do: underscores are word chars, so `customer_phone` would miss.\n// The leading guard keeps bare \"tel\" from matching e.g. \"hotel\".\nconst PHONE_NAME_HINT = /(^|[^a-z])(phone|tel|mobile|cell)/i;\n\n// Page-lifetime, memory-only. Freshest non-null value wins per identifier.\nlet stash: NormalizedUserData = { email: null, phoneNumber: null };\n\n/**\n * Trim + lowercase + shape-check. Deliberately NO gmail dot/plus\n * canonicalization — gtag.js applies Google's own normalization before\n * hashing, and owning that contract here would be drift waiting to happen.\n */\nexport function normalizeEmail(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const cleaned = raw.trim().toLowerCase();\n return EMAIL_SHAPE.test(cleaned) ? cleaned : null;\n}\n\ninterface FieldLike {\n name?: unknown;\n type?: unknown;\n label?: unknown;\n value?: unknown;\n}\n\nfunction fieldText(field: FieldLike, key: \"name\" | \"type\" | \"label\"): string {\n const value = field[key];\n return typeof value === \"string\" ? value : \"\";\n}\n\n/**\n * Deterministic extraction from `form_submit` `fields[]`: for each identifier,\n * a typed field (`type=\"email\"` / `type=\"tel\"`) wins over a name/label match\n * (`user_email`, `customer_phone`, …), in document order. A field whose value\n * fails normalization does NOT consume the slot — scanning continues, so one\n * malformed phone field can't mask a later valid one. Never throws.\n */\nexport function extractUserDataFromFormFields(\n fields: unknown,\n country?: CountryCode,\n): NormalizedUserData {\n const result: NormalizedUserData = { email: null, phoneNumber: null };\n if (!Array.isArray(fields)) return result;\n const passes: Array<(field: FieldLike, hint: RegExp, type: string) => boolean> = [\n (field, _hint, type) => fieldText(field, \"type\").toLowerCase() === type,\n (field, hint) => hint.test(fieldText(field, \"name\")) || hint.test(fieldText(field, \"label\")),\n ];\n for (const matches of passes) {\n for (const raw of fields) {\n if (raw === null || typeof raw !== \"object\") continue;\n const field = raw as FieldLike;\n if (typeof field.value !== \"string\" || field.value.length === 0) continue;\n if (result.email === null && matches(field, EMAIL_NAME_HINT, \"email\")) {\n result.email = normalizeEmail(field.value);\n }\n if (result.phoneNumber === null && matches(field, PHONE_NAME_HINT, \"tel\")) {\n try {\n result.phoneNumber = toE164(field.value, country);\n } catch {\n // libphonenumber choked on garbage — leave the slot open\n }\n }\n }\n if (result.email !== null && result.phoneNumber !== null) break;\n }\n return result;\n}\n\n/** Normalize + merge into the stash (non-null values win over existing). */\nexport function stashUserData(data: ConversionUserData, country?: CountryCode): void {\n const email = normalizeEmail(data.email);\n let phoneNumber: string | null = null;\n if (typeof data.phone === \"string\" && data.phone.length > 0) {\n try {\n phoneNumber = toE164(data.phone, country);\n } catch {\n phoneNumber = null;\n }\n }\n stash = {\n email: email ?? stash.email,\n phoneNumber: phoneNumber ?? stash.phoneNumber,\n };\n}\n\n/** Extract from `form_submit` fields and merge into the stash. Never throws. */\nexport function stashUserDataFromFormFields(fields: unknown, country?: CountryCode): void {\n try {\n const extracted = extractUserDataFromFormFields(fields, country);\n stash = {\n email: extracted.email ?? stash.email,\n phoneNumber: extracted.phoneNumber ?? stash.phoneNumber,\n };\n } catch {\n // extraction must never break event tracking\n }\n}\n\n/** Snapshot of the current stash (copy — mutations don't leak back). */\nexport function getStashedUserData(): NormalizedUserData {\n return { ...stash };\n}\n\n/** Drop everything (consent denial, tests). */\nexport function clearStashedUserData(): void {\n stash = { email: null, phoneNumber: null };\n}\n\n/**\n * Set gtag's per-page `user_data` right before a conversion fires: explicit\n * values win per-key over the stash; an empty merge sets nothing at all.\n * Browser-gated and throw-proof — the conversion must fire regardless.\n * NOT consent-checked here: the single caller (`fireConversionWithConsent`)\n * has already returned on denial before reaching this.\n *\n * Returns whether a `gtag('set','user_data', …)` call was made.\n */\nexport function applyUserDataForConversion(\n explicit?: ConversionUserData | null,\n country?: CountryCode,\n): boolean {\n if (typeof window === \"undefined\" || typeof window.gtag !== \"function\") return false;\n let email = stash.email;\n let phoneNumber = stash.phoneNumber;\n if (explicit) {\n const normalizedEmail = normalizeEmail(explicit.email);\n if (normalizedEmail) email = normalizedEmail;\n if (typeof explicit.phone === \"string\" && explicit.phone.length > 0) {\n try {\n phoneNumber = toE164(explicit.phone, country) ?? phoneNumber;\n } catch {\n // keep the stashed phone\n }\n }\n }\n if (email === null && phoneNumber === null) return false;\n try {\n window.gtag(\"set\", \"user_data\", {\n ...(email !== null ? { email } : {}),\n ...(phoneNumber !== null ? { phone_number: phoneNumber } : {}),\n });\n return true;\n } catch {\n return false;\n }\n}\n","import type { ConsentState } from \"./types\";\nimport { clearStashedUserData } from \"./user-data\";\n\n/**\n * localStorage key for the visitor's explicit consent choice.\n */\nexport const CONSENT_STATE_KEY = \"consent_state\";\n\n/**\n * localStorage key for the ISO timestamp when consent was last changed.\n */\nexport const CONSENT_TIMESTAMP_KEY = \"consent_timestamp\";\n\n/**\n * localStorage key for the ISO timestamp when an explicit decline expires.\n *\n * Written alongside a `denied` choice; absent for grants (they never expire).\n * Computed at WRITE time so every reader — including the inline\n * `beforeInteractive` scripts that can't import this module — only has to\n * compare a stored ISO string against the clock.\n */\nexport const CONSENT_EXPIRES_AT_KEY = \"consent_expires_at\";\n\n/**\n * How long an explicit decline is honored before the visitor reverts to the\n * default-granted state. Explicit grants never expire.\n */\nexport const DEFAULT_DECLINE_TTL_DAYS = 90;\n\nconst DAY_MS = 86_400_000;\n\n/**\n * Google Consent Mode value sent to `gtag('consent', 'update', ...)`.\n */\nexport type GtagConsentValue = \"granted\" | \"denied\";\n\n/**\n * Consent Mode v2 payload sent to Google Ads when consent changes.\n */\nexport interface ConsentUpdatePayload {\n ad_storage: GtagConsentValue;\n ad_user_data: GtagConsentValue;\n ad_personalization: GtagConsentValue;\n analytics_storage: GtagConsentValue;\n}\n\n/** Effective consent state — the opt-out model has no \"pending\". */\nexport type ConsentChoiceState = \"granted\" | \"denied\";\n\n/**\n * Where the effective state came from: `explicit` when the visitor made a\n * stored, still-valid choice; `default` otherwise (no choice, expired decline,\n * storage blocked, SSR).\n */\nexport type ConsentSource = \"default\" | \"explicit\";\n\n/**\n * The effective consent decision plus its provenance.\n */\nexport interface ConsentChoice {\n state: ConsentChoiceState;\n source: ConsentSource;\n /** ISO timestamp of the explicit choice; null for the default state. */\n updatedAt: string | null;\n /** ISO expiry of an unexpired decline; null for grants and the default state. */\n expiresAt: string | null;\n}\n\n/** Options for explicit consent writes. */\nexport interface SetConsentOptions {\n /** Days an explicit decline is honored. Defaults to {@link DEFAULT_DECLINE_TTL_DAYS}. */\n declineTtlDays?: number;\n}\n\nconst DEFAULT_CHOICE: ConsentChoice = {\n state: \"granted\",\n source: \"default\",\n updatedAt: null,\n expiresAt: null,\n};\n\ntype ConsentChangeListener = (choice: ConsentChoice) => void;\nconst changeListeners = new Set<ConsentChangeListener>();\n\n/**\n * Subscribe to consent changes (opt-in, opt-out, reset) made in THIS tab.\n * Cross-tab changes surface via the browser's `storage` event instead.\n * Returns an unsubscribe function.\n */\nexport function onConsentChange(listener: ConsentChangeListener): () => void {\n changeListeners.add(listener);\n return () => {\n changeListeners.delete(listener);\n };\n}\n\n// Takes the intended choice rather than re-reading storage: when localStorage\n// is blocked, a re-read would report default-granted right after an opt-out\n// click even though the live gtag/fbq revoke DID apply for this page load.\nfunction notifyConsentChanged(choice: ConsentChoice): void {\n for (const listener of changeListeners) {\n try {\n listener(choice);\n } catch {\n // a listener must never break the consent flow\n }\n }\n}\n\n/**\n * Build the Google Consent Mode v2 update payload for a single consent state.\n */\nexport function buildConsentPayload(state: GtagConsentValue): ConsentUpdatePayload {\n return {\n ad_storage: state,\n ad_user_data: state,\n ad_personalization: state,\n analytics_storage: state,\n };\n}\n\n/**\n * Resolve the visitor's effective consent (opt-out model).\n *\n * Default is GRANTED. Only a stored, unexpired explicit decline yields\n * `denied`. SSR, blocked storage, and expired declines all resolve to the\n * default. A legacy decline stored by the opt-in-era SDK (no expiry key) stays\n * denied and gets a fresh 90-day expiry backfilled, keeping this reader\n * consistent with the inline scripts (which treat a missing expiry as denied).\n */\nexport function getConsentChoice(): ConsentChoice {\n if (typeof window === \"undefined\") return DEFAULT_CHOICE;\n\n try {\n const stored = window.localStorage.getItem(CONSENT_STATE_KEY);\n const updatedAt = window.localStorage.getItem(CONSENT_TIMESTAMP_KEY);\n\n if (stored === \"granted\")\n return { state: \"granted\", source: \"explicit\", updatedAt, expiresAt: null };\n\n if (stored === \"denied\") {\n let expiresAt = window.localStorage.getItem(CONSENT_EXPIRES_AT_KEY);\n if (!expiresAt) {\n expiresAt = new Date(Date.now() + DEFAULT_DECLINE_TTL_DAYS * DAY_MS).toISOString();\n try {\n window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);\n } catch {\n // backfill is best-effort; the decline is still honored this read\n }\n }\n // A garbled expiry parses to NaN, the comparison is false, and the decline\n // stays honored (fail closed on the decliner's side). Only a parseable\n // expiry in the past reverts to the default.\n if (!(Date.parse(expiresAt) <= Date.now()))\n return { state: \"denied\", source: \"explicit\", updatedAt, expiresAt };\n }\n } catch {\n // localStorage blocked (sandboxed iframe / storage disabled) — default\n }\n\n return DEFAULT_CHOICE;\n}\n\n/**\n * Read the visitor's effective consent state.\n *\n * Opt-out model: returns `granted` unless a stored, unexpired explicit decline\n * exists. Never returns `pending` — that value survives in {@link ConsentState}\n * only so pre-v2 call sites keep compiling.\n */\nexport function getConsentState(): ConsentState {\n return getConsentChoice().state;\n}\n\nfunction pushConsentToPlatforms(state: GtagConsentValue): void {\n if (typeof window === \"undefined\") return;\n\n if (typeof window.gtag === \"function\")\n window.gtag(\"consent\", \"update\", buildConsentPayload(state));\n\n // Mirror to the Meta Pixel so one control gates both ad platforms.\n if (typeof window.fbq === \"function\")\n window.fbq(\"consent\", state === \"granted\" ? \"grant\" : \"revoke\");\n}\n\n/**\n * Persist an explicit visitor consent choice and push it live to Google\n * Consent Mode and the Meta Pixel.\n *\n * Declines expire after {@link DEFAULT_DECLINE_TTL_DAYS} days (override via\n * `options.declineTtlDays`); grants never expire.\n */\nexport function setConsentState(state: GtagConsentValue, options?: SetConsentOptions): void {\n if (typeof window === \"undefined\") return;\n\n // Compute the full intended choice BEFORE touching storage so (a) a garbage\n // TTL can't throw mid-write (state written, expiry not), and (b) listeners\n // get the visitor's actual choice even when storage is blocked.\n const requestedTtl = options?.declineTtlDays;\n const ttlDays =\n typeof requestedTtl === \"number\" && Number.isFinite(requestedTtl) && requestedTtl > 0\n ? requestedTtl\n : DEFAULT_DECLINE_TTL_DAYS;\n const updatedAt = new Date().toISOString();\n const expiresAt =\n state === \"denied\" ? new Date(Date.now() + ttlDays * DAY_MS).toISOString() : null;\n\n try {\n window.localStorage.setItem(CONSENT_STATE_KEY, state);\n window.localStorage.setItem(CONSENT_TIMESTAMP_KEY, updatedAt);\n if (expiresAt !== null) {\n window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);\n } else {\n window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);\n }\n } catch {\n // localStorage blocked — still push the live consent update below so the\n // visitor's click takes effect for this page load even if it can't persist\n }\n\n pushConsentToPlatforms(state);\n if (state === \"denied\") {\n // A mid-session revoke also scrubs enhanced-conversions page state: drop\n // the stashed identifiers and best-effort null gtag's user_data.\n clearStashedUserData();\n try {\n if (typeof window.gtag === \"function\") window.gtag(\"set\", \"user_data\", null);\n } catch {\n // scrubbing is best-effort; the consent update above already gates fires\n }\n }\n notifyConsentChanged({ state, source: \"explicit\", updatedAt, expiresAt });\n}\n\n/**\n * Explicitly opt the visitor in to ad tracking (never expires).\n */\nexport function optIn(): void {\n setConsentState(\"granted\");\n}\n\n/**\n * Explicitly opt the visitor out of ad tracking for\n * {@link DEFAULT_DECLINE_TTL_DAYS} days (override via `options.declineTtlDays`).\n *\n * This is the primitive a client site's footer \"cookie preferences\" control\n * should call — the packages ship no consent UI of their own.\n */\nexport function optOut(options?: SetConsentOptions): void {\n setConsentState(\"denied\", options);\n}\n\n/**\n * Clear the stored explicit choice, returning the visitor to the\n * default-granted state, and push that state live to gtag + Meta.\n *\n * Power a \"Cookie preferences\" reset in a footer:\n *\n * ```tsx\n * const { reset } = useCookiePreferences();\n * <button onClick={reset}>Reset cookie preferences</button>\n * ```\n */\nexport function resetConsent(): void {\n if (typeof window === \"undefined\") return;\n\n try {\n window.localStorage.removeItem(CONSENT_STATE_KEY);\n window.localStorage.removeItem(CONSENT_TIMESTAMP_KEY);\n window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);\n } catch {\n // localStorage blocked — nothing to clear\n }\n\n pushConsentToPlatforms(\"granted\");\n notifyConsentChanged(DEFAULT_CHOICE);\n}\n\n/**\n * Re-apply a stored explicit consent choice to Google Consent Mode + Meta.\n *\n * @deprecated The v2 consent-default path ({@link applyDefaultConsentState} /\n * `createConsentDefaultScript`) already applies the effective state at\n * bootstrap, making this redundant. Pushes platform updates directly — it must\n * NOT route through {@link setConsentState}, which would renew the decline's\n * 90-day expiry on every page load.\n */\nexport function restoreStoredConsent(): ConsentState {\n const choice = getConsentChoice();\n\n if (choice.source === \"explicit\") pushConsentToPlatforms(choice.state);\n\n return choice.state;\n}\n\n/**\n * Inline-JS fragment that resolves the effective consent value into\n * `var aranovaConsent = 'granted' | 'denied'`.\n *\n * Single source of the expiry logic for every inline `<script>` builder (gtag\n * and Meta), so the runtime reader and the inline readers can't drift. Must\n * stay dependency-free ES5 and never throw (localStorage access throws in\n * sandboxed iframes).\n */\nexport function createEffectiveConsentSnippet(): string {\n // IIFE keeps the temporaries off the page's global scope — only\n // `aranovaConsent` (the value consumed by the rest of the script) leaks.\n return `\nvar aranovaConsent = (function () {\n try {\n if (window.localStorage.getItem('${CONSENT_STATE_KEY}') === 'denied') {\n var exp = window.localStorage.getItem('${CONSENT_EXPIRES_AT_KEY}');\n var expMs = exp ? Date.parse(exp) : NaN;\n if (!(expMs <= Date.now())) return 'denied';\n }\n } catch (e) {}\n return 'granted';\n})();\n`.trim();\n}\n","// This module loads the Google tag (gtag.js), applies Consent Mode, and fires\n// `gtag('config', …)` (remarketing) plus real-time `gtag('event','conversion')`\n// via `fireGtagConversion` (GAP28). Consent v2 is OPT-OUT: the effective state\n// is granted unless the visitor stored an explicit, unexpired decline, so tags\n// run from first paint for the default visitor.\n// See docs/google-ads-deployment/gtags-and-conversion-tracking.md.\n\nimport { buildConsentPayload, createEffectiveConsentSnippet, getConsentState } from \"./consent\";\nimport type { GtagEnvironmentMap } from \"./types\";\n\n/**\n * Google Tag Manager script host used for gtag loading.\n */\nexport const GTAG_SCRIPT_HOST = \"https://www.googletagmanager.com/gtag/js\";\n\n/**\n * Attribute used to mark scripts inserted by the tracking SDK.\n */\nexport const TRACKING_SCRIPT_ATTRIBUTE = \"data-aranova-tracking\";\n\nfunction getScriptMarker(id: string): string {\n return `aranova-${id}`;\n}\n\n/**\n * Pattern matching valid Google Ads / GA4 tag IDs.\n *\n * Accepted formats: `AW-123456789`, `G-XXXXXXXXXX`, `GT-XXXXXXX`, `DC-XXXXXXX`.\n * IDs that don't match are silently dropped to prevent script injection when\n * interpolated into inline `<script>` content.\n */\nconst GTAG_ID_PATTERN = /^[A-Z]{1,3}-[A-Za-z0-9_-]+$/;\n\nexport function isValidGtagId(id: string): boolean {\n return GTAG_ID_PATTERN.test(id);\n}\n\n/**\n * Create an inline script that initializes gtag with the visitor's EFFECTIVE\n * consent as the Consent Mode default (opt-out model).\n *\n * Resolves granted/denied synchronously from localStorage in one shot — a\n * decliner never gets a granted window and the default visitor never gets a\n * denied one, so no `wait_for_update` and no follow-up restore script is\n * needed. Run it `beforeInteractive` so the default lands before gtag.js.\n */\nexport function createConsentDefaultScript(): string {\n return `\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\nwindow.gtag = gtag;\n${createEffectiveConsentSnippet()}\ngtag('consent', 'default', {\n ad_storage: aranovaConsent,\n ad_user_data: aranovaConsent,\n ad_personalization: aranovaConsent,\n analytics_storage: aranovaConsent\n});\n`.trim();\n}\n\n/**\n * Create an inline script that initializes gtag for a Google Ads tag id.\n */\nexport function createGtagInitScript(gtagId: string): string {\n if (!isValidGtagId(gtagId)) return \"\";\n return `\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\nwindow.gtag = gtag;\ngtag('js', new Date());\ngtag('config', '${gtagId}');\n`.trim();\n}\n\n/**\n * Create an inline script that pushes the effective consent as an update.\n *\n * @deprecated Redundant since consent v2 — `createConsentDefaultScript()`\n * already resolves the effective state (including decline expiry) into the\n * Consent Mode default, so no restore pass is needed. Kept expiry-aware for\n * integrations that still render it.\n */\nexport function createConsentRestoreScript(): string {\n return `\n${createEffectiveConsentSnippet()}\nif (window.gtag) {\n window.gtag('consent', 'update', {\n ad_storage: aranovaConsent,\n ad_user_data: aranovaConsent,\n ad_personalization: aranovaConsent,\n analytics_storage: aranovaConsent\n });\n}\n`.trim();\n}\n\n/**\n * Ensure `window.gtag` and `window.dataLayer` exist and return the gtag shim.\n */\nexport function ensureGtagFunction(): NonNullable<Window[\"gtag\"]> {\n window.dataLayer = window.dataLayer || [];\n\n if (typeof window.gtag === \"function\") return window.gtag;\n\n // CRITICAL: push the `arguments` object, NOT a rest-param array. gtag.js only\n // treats the canonical `dataLayer.push(arguments)` form as a command — a plain\n // array pushed to the dataLayer is ignored. With the array form, every\n // gtag('config' | 'consent' | 'event') call silently no-ops: the account never\n // registers, consent mode never applies, and conversions never fire (verified\n // live — the array form sends zero network hits; the arguments form sends the\n // pagead/conversion ping). Mirror Google's canonical inline snippet exactly.\n function gtag(): void {\n // eslint-disable-next-line prefer-rest-params\n window.dataLayer?.push(arguments);\n }\n window.gtag = gtag;\n\n return window.gtag;\n}\n\nconst SEND_TO_RE = /^AW-[A-Za-z0-9]+\\/[A-Za-z0-9_-]+$/;\n\n/** A gtag `send_to` target, e.g. `AW-123456789/AbC-D_efg`. */\nexport function isValidSendTo(sendTo: string): boolean {\n return SEND_TO_RE.test(sendTo);\n}\n\nexport interface GtagConversionInput {\n /** `AW-<id>/<label>` — the conversion action's firing target. */\n sendTo: string;\n /** Conversion value in MAJOR units; omit to let Google apply the action's default. */\n value?: number | null;\n currency?: string | null;\n transactionId?: string | null;\n}\n\n/**\n * Fire a real-time on-site conversion — `gtag('event','conversion',{ send_to, … })`\n * (GAP28). Browser-gated and never throws: `record()` is isomorphic and runs server-side\n * under a secret key where `window`/`gtag` are absent, so this no-ops there. Consent\n * gating + de-dup are the caller's responsibility (see `fireConversionWithConsent`).\n * Returns whether the event was actually pushed.\n */\nexport function fireGtagConversion(input: GtagConversionInput): boolean {\n if (typeof window === \"undefined\" || typeof window.gtag !== \"function\") return false;\n if (!isValidSendTo(input.sendTo)) return false;\n const params: Record<string, unknown> = { send_to: input.sendTo };\n if (input.value != null) params.value = input.value;\n if (input.currency) params.currency = input.currency;\n if (input.transactionId) params.transaction_id = input.transactionId;\n try {\n window.gtag(\"event\", \"conversion\", params);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Apply the visitor's EFFECTIVE consent as the Consent Mode default\n * (opt-out model): granted unless a stored, unexpired decline exists.\n *\n * Reading via `getConsentState()` also backfills the expiry key for\n * legacy opt-in-era declines, keeping later inline reads consistent.\n */\nexport function applyDefaultConsentState(): void {\n const gtag = ensureGtagFunction();\n gtag(\n \"consent\",\n \"default\",\n buildConsentPayload(getConsentState() === \"denied\" ? \"denied\" : \"granted\"),\n );\n}\n\n/**\n * Load the external gtag script once for the provided Google Ads tag id.\n */\nexport function loadGtagScript(gtagId: string): void {\n if (typeof document === \"undefined\") return;\n\n const marker = getScriptMarker(\"gtag-loader\");\n const existingScript = document.querySelector<HTMLScriptElement>(\n `script[${TRACKING_SCRIPT_ATTRIBUTE}=\"${marker}\"]`,\n );\n if (existingScript) return;\n\n const script = document.createElement(\"script\");\n script.async = true;\n script.src = `${GTAG_SCRIPT_HOST}?id=${encodeURIComponent(gtagId)}`;\n script.setAttribute(TRACKING_SCRIPT_ATTRIBUTE, marker);\n document.head.append(script);\n}\n\n/**\n * Send the initial `gtag('js')` and `gtag('config')` calls.\n */\nexport function initializeGtag(gtagId: string): void {\n const gtag = ensureGtagFunction();\n gtag(\"js\", new Date());\n gtag(\"config\", gtagId);\n}\n\n/**\n * Push the current effective consent to gtag as an update.\n *\n * @deprecated Redundant since consent v2 — `applyDefaultConsentState()`\n * already applies the effective state at bootstrap. Pushes directly (no\n * storage writes), so it never renews a decline's expiry.\n */\nexport function restoreConsentState(): void {\n if (typeof window === \"undefined\") return;\n\n const consentState = getConsentState();\n if (consentState === \"granted\" || consentState === \"denied\")\n window.gtag?.(\"consent\", \"update\", buildConsentPayload(consentState));\n}\n\n/**\n * Load and initialize Google Ads tracking with Consent Mode support.\n *\n * Used by framework components and browser-script initialization.\n */\nexport function bootstrapGoogleAdsTracking(gtagId: string): void {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n if (!isValidGtagId(gtagId)) return;\n\n applyDefaultConsentState();\n loadGtagScript(gtagId);\n initializeGtag(gtagId);\n}\n\n/**\n * Load and initialize Google Ads tracking for ALL labelled gtag IDs.\n *\n * Every ID in the map gets a `gtag('config', ...)` call — gtag natively\n * supports multiple configured tags on the same page. The script loader\n * only runs once (for the first ID); subsequent IDs reuse the shared\n * `dataLayer`.\n */\nexport function bootstrapMultipleGtags(gtagIds: GtagEnvironmentMap): void {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n const ids = Object.values(gtagIds).filter(\n (id): id is string => typeof id === \"string\" && isValidGtagId(id),\n );\n if (ids.length === 0) return;\n\n applyDefaultConsentState();\n loadGtagScript(ids[0]!);\n\n // Call gtag('js') once, then gtag('config') per ID. initializeGtag\n // calls both, but gtag('js') should only fire once per page load.\n const gtag = ensureGtagFunction();\n gtag(\"js\", new Date());\n for (const id of ids) {\n gtag(\"config\", id);\n }\n}\n\n/**\n * Create an inline script that initializes gtag for multiple Google Ads tag ids.\n *\n * Used by the Next.js `GoogleAdsTracking` component when rendering `<Script>`\n * tags for a multi-gtag configuration.\n */\nexport function createGtagInitScriptMulti(gtagIds: string[]): string {\n const safeIds = gtagIds.filter(isValidGtagId);\n const configs = safeIds.map((id) => `gtag('config', '${id}');`).join(\"\\n\");\n return `\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\nwindow.gtag = gtag;\ngtag('js', new Date());\n${configs}\n`.trim();\n}\n","// Consent-gated, de-duped orchestration around the raw `fireGtagConversion` (GAP28).\n//\n// Consent v2 (opt-out model): fire unless the visitor's effective consent is an\n// explicit, unexpired decline. There is no \"pending\" state and therefore no\n// queue — the default visitor converts in real time.\n//\n// `fireGtagConversion` is itself browser-gated and never throws, so this is safe to call\n// from the isomorphic sales `record()` — server-side it simply no-ops.\n\nimport { getConsentState } from \"../consent\";\nimport { fireGtagConversion, isValidSendTo, type GtagConversionInput } from \"../gtag\";\nimport { applyUserDataForConversion, type ConversionUserData } from \"../user-data\";\n\nconst DEDUP_PREFIX = \"_aranova_conv_\";\n\nexport type ConversionFireOutcome = \"fired\" | \"denied\" | \"duplicate\" | \"invalid\" | \"retryable\";\n\nfunction dedupKey(input: GtagConversionInput): string {\n return `${DEDUP_PREFIX}${input.transactionId ?? \"\"}:${input.sendTo}`;\n}\n\n// Has this (transaction_id, send_to) already fired? Read-only — the key is written ONLY\n// after a successful fire (see `fireOnce`), so a fire that never happened (e.g. gtag not\n// ready yet) is never suppressed on retry. Best-effort: a blocked sessionStorage reads as\n// \"not fired\" so the conversion still gets a chance.\nfunction alreadyFired(input: GtagConversionInput): boolean {\n if (!input.transactionId || typeof window === \"undefined\") return false;\n try {\n return window.sessionStorage.getItem(dedupKey(input)) !== null;\n } catch {\n return false;\n }\n}\n\nfunction markFired(input: GtagConversionInput): void {\n if (!input.transactionId || typeof window === \"undefined\") return;\n try {\n window.sessionStorage.setItem(dedupKey(input), \"1\");\n } catch {\n // sessionStorage unavailable — Google's counting_type is the authoritative backstop\n }\n}\n\n/**\n * Fire a conversion through the consent gate + de-dup guard.\n *\n * Enhanced conversions: right before the fire (past the consent gate AND the\n * de-dup guard — a duplicate that won't emit an event must not mutate gtag's\n * page-level user_data either), gtag's `user_data` is set from the explicit\n * `options.userData` merged over the form-submit stash — this is the single\n * choke point all firing paths (recordSale, trackConversion, autofire) flow\n * through, so every conversion hit carries the visitor's hashed identifiers\n * when any are known. Re-setting before each fire is idempotent (gtag scopes\n * user_data to the page), so a later autofire on the same page matches too.\n * The de-dup key is written only AFTER a successful fire, so a fire that\n * never happened (gtag not ready) is never suppressed on retry.\n */\nexport function fireConversionWithConsent(\n input: GtagConversionInput,\n options?: { userData?: ConversionUserData | null },\n): ConversionFireOutcome {\n if (getConsentState() === \"denied\") return \"denied\";\n if (alreadyFired(input)) return \"duplicate\";\n if (!isValidSendTo(input.sendTo)) return \"invalid\";\n try {\n applyUserDataForConversion(options?.userData);\n if (!fireGtagConversion(input)) return \"retryable\";\n markFired(input);\n return \"fired\";\n } catch {\n return \"retryable\";\n }\n}\n\n/**\n * Flush conversions queued while consent was pending.\n *\n * @deprecated No-op since consent v2 — there is no \"pending\" state, so nothing\n * is ever queued. Kept exported so pre-v2 integrations keep compiling.\n */\nexport function flushPendingConversions(): void {\n // intentionally empty\n}\n","{\n \"hosts\": [\"myalp.io\"]\n}\n","// Provider-agnostic booking read over the published config (`integrations.*.booking`).\n// React-free: the hooks and the link live in `booking-react.tsx`.\n\nimport bookingLinks from \"../../../contracts/autoleap-booking-links.json\";\nimport { parsePhone } from \"./phone\";\nimport type {\n BookingDayWindows,\n BookingMode,\n BookingRulesConfig,\n ConversionIntegrations,\n} from \"./resources/conversion-config\";\n\n/** Hosts a handoff link may point at: the backend's short-link allowlist, from one contract. */\nconst HANDOFF_HOSTS = new Set<string>(bookingLinks.hosts);\n\nexport type BookingProvider = \"autoleap\";\n\n/** One service choice on the booking form, by the site's own key. */\nexport interface BookingOption {\n key: string;\n label: string;\n /** The shop's service category, for a heading over its choices; null when it has none. */\n group: string | null;\n /** Optional helper text to show under the label. */\n description: string | null;\n}\n\n/** One heading on the form and the choices under it; `group` null holds the ungrouped ones. */\nexport interface BookingOptionGroup {\n group: string | null;\n options: BookingOption[];\n}\n\n/** The booking rules in camelCase, all in the shop's wall time. */\nexport interface BookingRules {\n minDaysAhead: number;\n maxDaysAhead: number;\n /** Start hours per weekday, `monday` … `sunday`; both lists empty = closed. */\n hours: Record<string, BookingDayWindows>;\n /** `YYYY-MM-DD` shop dates with no bookings although the weekday is open. */\n closedDates: string[];\n /** `YYYY-MM-DD` shop dates whose start hours replace their weekday's. */\n specialHours: Record<string, BookingDayWindows>;\n vehicleRequired: boolean;\n /** The vehicle may come after booking: send it with `bookings.updateBooking`. */\n vehicleDeferred: boolean;\n /** `waitForIt` / `dropOff`; empty when the shop hides the question. */\n dropOffOptions: string[];\n}\n\nexport interface BookingIntegration {\n provider: BookingProvider;\n /** False: render only the handoff link. Never block the customer on it. */\n enabled: boolean;\n /** The shop's own booking page. */\n fallbackUrl: string;\n /** True when the site may post a booking: `enabled`, rules captured and options mapped. */\n acceptsBookings: boolean;\n /** The shop's IANA timezone; every date and hour here is its wall time. */\n timezone: string;\n /** Sent back with each booking; a stale one is refused and the config refetched. */\n configVersion: number | null;\n rules: BookingRules | null;\n options: BookingOption[];\n /**\n * Where a booking from this page goes: `live` in production; on a development config `test`\n * (the shop's test lead source) or `simulated` (never sent). Show a banner when not `live`.\n */\n mode: BookingMode;\n}\n\n/** What the visitor already typed, carried onto the shop's booking page. */\nexport interface BookingHandoffPrefill {\n /** A single full-name field; split on the first space when `firstName` is not given. */\n name?: string | null;\n firstName?: string | null;\n lastName?: string | null;\n /** Any format; sent as national digits (no country code), as the shop's portal stores it. */\n phone?: string | null;\n email?: string | null;\n}\n\n/** The enabled-or-fallback booking provider in a config's integrations, if any. */\nexport function getBookingIntegration(\n integrations: ConversionIntegrations | null | undefined,\n): BookingIntegration | null {\n const booking = integrations?.autoleap?.booking;\n if (!booking) return null;\n const rules = booking.rules ? camelRules(booking.rules) : null;\n return {\n provider: \"autoleap\",\n enabled: booking.enabled,\n fallbackUrl: booking.fallback_url,\n acceptsBookings: booking.accepts_bookings && rules !== null && booking.config_version !== null,\n timezone: booking.timezone,\n configVersion: booking.config_version,\n rules,\n options: booking.options.map((option) => ({\n key: option.key,\n label: option.label,\n group: option.group,\n description: option.description,\n })),\n mode: booking.mode ?? \"live\",\n };\n}\n\nfunction camelRules(rules: BookingRulesConfig): BookingRules {\n return {\n minDaysAhead: rules.min_days_ahead,\n maxDaysAhead: rules.max_days_ahead,\n hours: rules.hours,\n closedDates: rules.closed_dates,\n specialHours: rules.special_hours,\n vehicleRequired: rules.vehicle_required,\n vehicleDeferred: rules.vehicle_deferred,\n dropOffOptions: rules.drop_off_options,\n };\n}\n\nconst WEEKDAYS = [\"sunday\", \"monday\", \"tuesday\", \"wednesday\", \"thursday\", \"friday\", \"saturday\"];\n\n/** One bookable day: its start hours split as the shop's portal shows them. */\nexport interface BookableDay {\n /** `YYYY-MM-DD`, shop-local. */\n date: string;\n /** `monday` … `sunday`. */\n weekday: string;\n /** Start hours before noon (8 = 08:00). */\n morning: number[];\n /** Start hours from noon on. */\n afternoon: number[];\n}\n\n/** Today's date in `timeZone` as `YYYY-MM-DD`; the server judges notice by the same date. */\nexport function shopToday(timeZone: string, now: Date = new Date()): string {\n const parts = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n }).formatToParts(now);\n const part = (type: string) => parts.find((p) => p.type === type)?.value ?? \"\";\n return `${part(\"year\")}-${part(\"month\")}-${part(\"day\")}`;\n}\n\nfunction addDays(isoDate: string, days: number): string {\n const [year, month, day] = isoDate.split(\"-\").map(Number);\n const date = new Date(Date.UTC(year, month - 1, day + days));\n return date.toISOString().slice(0, 10);\n}\n\n/**\n * Every day a customer may book, from the minimum notice to the end of the window, with its\n * start hours. Exactly what the server accepts: closed dates removed, special hours applied.\n */\nexport function bookableSlots(\n rules: BookingRules,\n { timeZone, now = new Date() }: { timeZone: string; now?: Date },\n): BookableDay[] {\n const today = shopToday(timeZone, now);\n const days: BookableDay[] = [];\n const closed = new Set(rules.closedDates);\n for (let offset = rules.minDaysAhead; offset <= rules.maxDaysAhead; offset += 1) {\n const date = addDays(today, offset);\n if (closed.has(date)) continue;\n const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()];\n const windows = rules.specialHours[date] ?? rules.hours[weekday];\n if (!windows || (windows.morning.length === 0 && windows.afternoon.length === 0)) continue;\n days.push({\n date,\n weekday,\n morning: [...windows.morning],\n afternoon: [...windows.afternoon],\n });\n }\n return days;\n}\n\n/**\n * The handoff URL with the visitor's details prefilled, or null when `fallbackUrl` is not an\n * https link on the shop's booking host (a config value never becomes an arbitrary redirect).\n */\nexport function buildBookingHandoffUrl(\n fallbackUrl: string,\n prefill: BookingHandoffPrefill = {},\n): string | null {\n let url: URL;\n try {\n url = new URL(fallbackUrl);\n } catch {\n return null;\n }\n if (\n url.protocol !== \"https:\" ||\n !HANDOFF_HOSTS.has(url.hostname) ||\n url.username ||\n url.password ||\n url.port\n ) {\n return null;\n }\n const [firstName, lastName] = prefill.firstName?.trim()\n ? [prefill.firstName, prefill.lastName]\n : splitFullName(prefill.name);\n const values: Array<[string, string | null | undefined]> = [\n [\"firstName\", firstName?.trim()],\n [\"lastName\", lastName?.trim()],\n [\"phone\", prefill.phone ? nationalDigits(prefill.phone) : \"\"],\n [\"email\", prefill.email?.trim()],\n ];\n for (const [key, value] of values) {\n if (value) url.searchParams.set(key, value);\n }\n return url.toString();\n}\n\nfunction splitFullName(name: string | null | undefined): [string, string] {\n const [first = \"\", ...rest] = (name ?? \"\").trim().split(/\\s+/);\n return [first, rest.join(\" \")];\n}\n\n/** \"+1 (416) 555-0100\" → \"4165550100\"; an unparseable value keeps its raw digits. */\nfunction nationalDigits(phone: string): string {\n const parsed = parsePhone(phone);\n return (parsed.isValid ? parsed.national : phone).replace(/\\D/g, \"\");\n}\n\n/**\n * The options as headed sections, in published order: one per category, choices in order\n * under it. The API saves them grouped, so a category is never split; this keeps the first run.\n */\nexport function groupBookingOptions(options: readonly BookingOption[]): BookingOptionGroup[] {\n const groups = new Map<string | null, BookingOption[]>();\n for (const option of options) {\n const members = groups.get(option.group);\n if (members) members.push(option);\n else groups.set(option.group, [option]);\n }\n return [...groups].map(([group, members]) => ({ group, options: members }));\n}\n","/** A UUID v4 from Web Crypto, with a `getRandomValues` (then `Math.random`) fallback. */\nexport function uuidV4(): string {\n const cryptoApi = globalThis.crypto;\n if (typeof cryptoApi?.randomUUID === \"function\") return cryptoApi.randomUUID();\n const bytes = new Uint8Array(16);\n if (typeof cryptoApi?.getRandomValues === \"function\") cryptoApi.getRandomValues(bytes);\n else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n","// Visitor + session identity for the tracking SDK.\n//\n// Visitor: persistent localStorage UUID, never expires until the user clears\n// browser storage. Used for cross-session correlation.\n//\n// Session: rolling 30-minute idle window. Regenerated when more than\n// SESSION_IDLE_MS has passed since the last event. Matches the behavior of\n// GA4, PostHog, Mixpanel, etc., so analytics is comparable.\n\nimport { clearLandingRecord } from \"./landing\";\nimport { uuidV4 } from \"./uuid\";\n\n/**\n * localStorage key for the persistent visitor id.\n */\nexport const VISITOR_STORAGE_KEY = \"aranova_tracking_visitor\";\n\n/**\n * localStorage key for the rolling session id state.\n */\nexport const SESSION_STORAGE_KEY = \"aranova_tracking_session\";\n\n/**\n * Idle window before a new session id is created.\n */\nexport const SESSION_IDLE_MS = 30 * 60 * 1000;\n\n/**\n * Serialized session state stored in localStorage.\n */\nexport interface StoredSession {\n /** Client-generated session UUID. */\n id: string;\n /** Unix timestamp in milliseconds for the most recent event/session touch. */\n last_event_at: number;\n}\n\nfunction readLocalStorage(key: string): string | null {\n try {\n return window.localStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeLocalStorage(key: string, value: string): void {\n try {\n window.localStorage.setItem(key, value);\n } catch {\n // Storage may be denied (private mode, blocked cookies, quota). Caller is\n // responsible for degrading gracefully.\n }\n}\n\n/**\n * Return the persistent visitor id for this browser profile.\n *\n * Creates and stores a new id when one does not already exist. During SSR,\n * returns an ephemeral id because browser storage is unavailable.\n */\nexport function getVisitorId(): string {\n if (typeof window === \"undefined\") return uuidV4();\n\n const existing = readLocalStorage(VISITOR_STORAGE_KEY);\n if (existing && existing.length > 0) return existing;\n\n const fresh = uuidV4();\n writeLocalStorage(VISITOR_STORAGE_KEY, fresh);\n return fresh;\n}\n\n/** Adopt `visitorId` only when this browser has none — an existing id is never orphaned. */\nexport function adoptVisitorId(visitorId: string): boolean {\n if (typeof window === \"undefined\" || visitorId.length === 0) return false;\n const existing = readLocalStorage(VISITOR_STORAGE_KEY);\n if (existing && existing.length > 0) return false;\n writeLocalStorage(VISITOR_STORAGE_KEY, visitorId);\n return readLocalStorage(VISITOR_STORAGE_KEY) === visitorId;\n}\n\n/**\n * Result from `getOrRotateSessionId()`.\n */\nexport interface SessionIdResult {\n /** Current session id. */\n id: string;\n /** Whether this call created a new session. */\n isNew: boolean;\n}\n\n/**\n * Return the current session id, rotating it after the idle window expires.\n *\n * Also refreshes `last_event_at` for active sessions.\n */\nexport function getOrRotateSessionId(now: number = Date.now()): SessionIdResult {\n if (typeof window === \"undefined\") return { id: uuidV4(), isNew: true };\n\n const raw = readLocalStorage(SESSION_STORAGE_KEY);\n if (raw) {\n try {\n const parsed = JSON.parse(raw) as Partial<StoredSession>;\n if (typeof parsed.id === \"string\" && typeof parsed.last_event_at === \"number\") {\n if (now - parsed.last_event_at <= SESSION_IDLE_MS) {\n const refreshed: StoredSession = { id: parsed.id, last_event_at: now };\n writeLocalStorage(SESSION_STORAGE_KEY, JSON.stringify(refreshed));\n return { id: parsed.id, isNew: false };\n }\n }\n } catch {\n // Fall through to a fresh session.\n }\n }\n\n const fresh: StoredSession = { id: uuidV4(), last_event_at: now };\n writeLocalStorage(SESSION_STORAGE_KEY, JSON.stringify(fresh));\n return { id: fresh.id, isNew: true };\n}\n\n/** This browser's visitor id, read without creating one; `null` before tracking has run. */\nexport function peekVisitorId(): string | null {\n if (typeof window === \"undefined\") return null;\n const existing = readLocalStorage(VISITOR_STORAGE_KEY);\n return existing && existing.length > 0 ? existing : null;\n}\n\n/**\n * The live session id, read without refreshing or rotating it; `null` once the idle window\n * has lapsed, since the next event would start a new session anyway.\n */\nexport function peekSessionId(now: number = Date.now()): string | null {\n if (typeof window === \"undefined\") return null;\n const raw = readLocalStorage(SESSION_STORAGE_KEY);\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw) as Partial<StoredSession>;\n if (typeof parsed.id !== \"string\" || typeof parsed.last_event_at !== \"number\") return null;\n return now - parsed.last_event_at <= SESSION_IDLE_MS ? parsed.id : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Clear visitor and session identity from localStorage, including the\n * session-scoped landing record (a landing must never outlive its session).\n *\n * Intended for tests, debugging, and explicit user reset flows.\n */\nexport function resetTrackingIdentity(): void {\n clearLandingRecord();\n if (typeof window === \"undefined\") return;\n try {\n window.localStorage.removeItem(VISITOR_STORAGE_KEY);\n window.localStorage.removeItem(SESSION_STORAGE_KEY);\n } catch {\n // ignored\n }\n}\n","/**\n * Thrown on a non-2xx from any awaited Aranova API call.\n *\n * Lives here rather than inside one resource because more than one resource\n * needs it, and a duplicate class would be a genuine hazard: a name star-exported\n * from two modules is silently dropped unless both resolve to the same binding.\n */\nexport class AranovaApiError extends Error {\n readonly status: number;\n readonly code: string | undefined;\n readonly requestId: string | undefined;\n /** The error envelope's machine-readable `context`, when the API sent one. */\n readonly context: Record<string, unknown> | undefined;\n\n constructor(\n message: string,\n options: {\n status: number;\n code?: string;\n requestId?: string;\n context?: Record<string, unknown>;\n },\n ) {\n super(message);\n this.name = \"AranovaApiError\";\n this.status = options.status;\n this.code = options.code;\n this.requestId = options.requestId;\n this.context = options.context;\n }\n}\n","/**\n * Format an ISO timestamp in a specific IANA time zone (e.g. `America/Toronto`),\n * so dashboards stop hand-rolling `Intl`. Defaults to a short date-time; pass\n * `opts` to override fields. The `timeZone` is always forced to the argument.\n */\nexport function formatDateInTz(\n iso: string,\n timeZone: string,\n opts?: Intl.DateTimeFormatOptions,\n locale?: string,\n): string {\n const date = new Date(iso);\n // Guard malformed input: Intl.format(Invalid Date) throws a RangeError.\n if (Number.isNaN(date.getTime())) return iso;\n return new Intl.DateTimeFormat(locale, {\n year: \"numeric\",\n month: \"short\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n ...opts,\n timeZone,\n }).format(date);\n}\n","import type { SupportedCurrency } from \"./schema\";\n\n/**\n * Money conversion helpers.\n *\n * The API speaks **integer minor units (cents)** exclusively. These helpers move\n * between a human-facing major amount (dollars) and cents, and format cents for\n * display — so a consumer always has both representations without float math.\n */\n\n// Minor-unit exponent per supported currency (USD/CAD = 2 decimal places).\nconst MINOR_UNIT_EXPONENT: Record<SupportedCurrency, number> = {\n USD: 2,\n CAD: 2,\n};\n\nfunction exponentFor(currency: SupportedCurrency): number {\n return MINOR_UNIT_EXPONENT[currency] ?? 2;\n}\n\n/**\n * Convert a major amount (dollars `250.5`) to integer minor units (`25050`).\n *\n * Convenience only — the wire is always integer cents. Uses float multiply +\n * `Math.round`, so values that aren't exactly representable in binary float\n * (e.g. `1.005`) can round to the neighbouring cent. If you already hold an\n * exact cents integer, pass it straight through and skip this helper.\n */\nexport function toMinor(amount: number, currency: SupportedCurrency): number {\n return Math.round(amount * 10 ** exponentFor(currency));\n}\n\n/** Convert integer minor units (`25050`) to a major amount (`250.5`). */\nexport function fromMinor(cents: number, currency: SupportedCurrency): number {\n return cents / 10 ** exponentFor(currency);\n}\n\n/**\n * Format integer minor units as a localized currency string (e.g. `\"$250.50\"`).\n * Uses the built-in `Intl.NumberFormat` — no extra dependency.\n */\nexport function formatMoney(cents: number, currency: SupportedCurrency, locale?: string): string {\n return new Intl.NumberFormat(locale, { style: \"currency\", currency }).format(\n fromMinor(cents, currency),\n );\n}\n\n// Hoisted to `resources/intl/date` so the calendar resource shares the same\n// binding; still public API via `sales-public.ts`.\nexport { formatDateInTz } from \"../intl/date\";\n","import { stripLinkParam } from \"../cross-site-link\";\nimport { applyDefaultConsentState, isValidGtagId, loadGtagScript } from \"../gtag\";\nimport {\n parseConversionConfig,\n type ConversionConfig,\n type ConversionGoal,\n type ConversionIntegrations,\n} from \"./conversion-config\";\nimport { fireConversionWithConsent } from \"./conversion-firing\";\nimport { getAutomaticTransactionId } from \"./automatic-transaction\";\nimport { automaticThresholdMet } from \"./automatic-trigger\";\nimport { fromMinor } from \"./sales/money\";\nimport type { SupportedCurrency } from \"./sales/schema\";\n\n/**\n * Where a business's published tracking config lives.\n *\n * `businessId` + `environment` are the stable identity; the ORIGIN it is fetched\n * from is deployment-specific (production CDN vs. a local object store), which is\n * why `cdnBaseUrl` exists. Codegen bakes only the identity, so one committed\n * generated file works in every environment and only an env var changes.\n *\n * `cdnUrl` remains supported and WINS when present — every client repo generated\n * before `cdnBaseUrl` passes one, and those must keep working untouched.\n */\nexport interface TrackingConfigReference {\n /**\n * Fully-resolved object URL. Optional: omit it and pass `cdnBaseUrl` (or rely\n * on the production default) to have it composed from the identity below.\n */\n cdnUrl?: string;\n /**\n * Origin (optionally with a path prefix) the config object is served from, e.g.\n * `https://demos.aranova.io` in production or\n * `http://localhost:9100/aranova-demos` against a local MinIO. Ignored when\n * `cdnUrl` is set; blank or omitted falls back to {@link DEFAULT_CDN_BASE_URL}.\n * `tracking-cli gen` wires this to an env var so retargeting is config, not code.\n */\n cdnBaseUrl?: string;\n businessId: string;\n /**\n * The site whose config object to load (ENG-284). Selects the per-site\n * `tracking-config/v1/sites/` key; omitted (older generated files) resolves\n * the legacy per-business key, which is the primary site's config.\n */\n siteId?: string;\n environment: \"production\" | \"test\";\n}\n\n/** Production object-storage origin — the default when nothing overrides it. */\nexport const DEFAULT_CDN_BASE_URL = \"https://demos.aranova.io\";\n\n/**\n * Object key for a config, mirroring the backend's\n * `core/storage.py::tracking_config_key` (per-site) and its legacy per-business\n * key. The backend stays the authority: it\n * publishes at this key and returns the absolute URL, which is what the CLI bakes\n * as the legacy `cdnUrl`. This is the composition path for references that carry\n * identity instead of a URL — the `v1` prefix is the schema MAJOR and must move in\n * lockstep with the backend if it is ever cut to `v2`.\n */\nfunction trackingConfigKey(ref: TrackingConfigReference): string {\n if (ref.siteId) return `tracking-config/v1/sites/${ref.siteId}-${ref.environment}.json`;\n return `tracking-config/v1/${ref.businessId}-${ref.environment}.json`;\n}\n\n/**\n * The URL a reference resolves to. An explicit `cdnUrl` wins (back-compat);\n * otherwise compose from `cdnBaseUrl` (or the production default).\n *\n * A blank/whitespace `cdnBaseUrl` is treated as UNSET rather than composed: the\n * generated module reads it from an env var, and a var that is present-but-empty\n * (a stray `NEXT_PUBLIC_ARANOVA_CDN_BASE_URL=` line) would otherwise produce\n * `/tracking-config/...` — a same-origin request to the client's own site.\n */\nexport function resolveTrackingConfigUrl(ref: TrackingConfigReference): string {\n if (ref.cdnUrl) return ref.cdnUrl;\n const configured = ref.cdnBaseUrl?.trim();\n const base = (configured || DEFAULT_CDN_BASE_URL).replace(/\\/+$/, \"\");\n return `${base}/${trackingConfigKey(ref)}`;\n}\n\ntype RuntimeState = \"unconfirmed\" | \"active\" | \"tombstone\";\n\ninterface CachedEntry {\n etag: string | null;\n config: ConversionConfig;\n}\n\ninterface QueuedConversion {\n key: string;\n value?: number | null;\n currency?: string | null;\n transactionId?: string | null;\n}\n\nexport type TrackingConfigConversionOptions = Omit<QueuedConversion, \"key\">;\n\ninterface QueuedPageView {\n href: string;\n title: string | null;\n referrer: string | null;\n}\n\ninterface QueuedAutomaticEvent {\n eventType: string;\n metadata: Record<string, unknown>;\n transactionPath: string;\n transactionScope: string;\n}\n\nconst CACHE_PREFIX = \"_aranova_cfg_runtime_\";\nconst AUTHORITY_TTL_MS = 60_000;\n// Queues are bounded rather than cleared while authority is unconfirmed: the\n// config can appear at any time (a first publish, or R2 recovering), and the\n// most recent items are still worth firing when it does. Unbounded, a site whose\n// config 404s accumulates an entry per page view for the life of the tab.\nconst MAX_QUEUE_LENGTH = 50;\n// While unconfirmed, revalidation is spaced by a growing delay instead of riding\n// every queued item. Without it a missing config produces one no-cache request\n// per page view, forever. `focus` / `visibilitychange` still revalidate, so\n// recovery stays prompt once the object exists.\nconst RETRY_BASE_MS = 5_000;\nconst RETRY_MAX_MS = 5 * 60_000;\n\nconst runtimes = new Map<string, TrackingConfigRuntime>();\nconst configuredIds = new Set<string>();\nlet scriptLoad: Promise<void> | null = null;\nlet jsInitialized = false;\n\nfunction cacheKey(url: string): string {\n return `${CACHE_PREFIX}${url}`;\n}\n\n/** Append, dropping the oldest entry once the cap is reached. */\nfunction pushBounded<T>(queue: T[], item: T): void {\n queue.push(item);\n if (queue.length > MAX_QUEUE_LENGTH) queue.splice(0, queue.length - MAX_QUEUE_LENGTH);\n}\n\nfunction readCache(url: string): CachedEntry | null {\n if (typeof window === \"undefined\") return null;\n try {\n const raw = window.sessionStorage.getItem(cacheKey(url));\n if (!raw) return null;\n const parsed = JSON.parse(raw) as { etag?: unknown; config?: unknown };\n const config = parseConversionConfig(parsed.config);\n if (!config) return null;\n return {\n etag: typeof parsed.etag === \"string\" ? parsed.etag : null,\n config,\n };\n } catch {\n return null;\n }\n}\n\nfunction writeCache(url: string, entry: CachedEntry): void {\n if (typeof window === \"undefined\") return;\n try {\n window.sessionStorage.setItem(cacheKey(url), JSON.stringify(entry));\n } catch {\n // sessionStorage unavailable — runtime still works, just without a conditional candidate\n }\n}\n\nfunction isTombstone(config: ConversionConfig): boolean {\n return config.google_tracking_state === \"disabled\" || Object.keys(config.gtag_ids).length === 0;\n}\n\nfunction validateConfig(config: ConversionConfig, ref: TrackingConfigReference): boolean {\n return (\n config.business_id === ref.businessId &&\n config.environment === ref.environment &&\n (config.google_tracking_state === \"active\" || config.google_tracking_state === \"disabled\")\n );\n}\n\nfunction pageViewSnapshot(): QueuedPageView | null {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return null;\n return {\n href: stripLinkParam(window.location.href),\n title: document.title || null,\n referrer: document.referrer || null,\n };\n}\n\nfunction ensureScript(gtagId: string): Promise<void> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return Promise.resolve();\n applyDefaultConsentState();\n loadGtagScript(gtagId);\n if (scriptLoad) return scriptLoad;\n scriptLoad = new Promise((resolve) => {\n const script = document.querySelector<HTMLScriptElement>(\n 'script[data-aranova-tracking=\"aranova-gtag-loader\"]',\n );\n if (!script) {\n resolve();\n return;\n }\n if ((script as HTMLScriptElement & { dataset: DOMStringMap }).dataset.loaded === \"true\") {\n resolve();\n return;\n }\n window.setTimeout(resolve, 0);\n script.addEventListener(\n \"load\",\n () => {\n script.dataset.loaded = \"true\";\n resolve();\n },\n { once: true },\n );\n script.addEventListener(\"error\", () => resolve(), { once: true });\n });\n return scriptLoad;\n}\n\nexport class TrackingConfigRuntime {\n private readonly fetchImpl: typeof fetch;\n /** Resolved config URL — see the constructor for why it is computed once. */\n private readonly url: string;\n private current: ConversionConfig | null = null;\n private etag: string | null = null;\n private stateValue: RuntimeState = \"unconfirmed\";\n private confirmedAt = 0;\n private authorityGeneration = 0;\n private inFlight: Promise<void> | null = null;\n private flushInFlight: Promise<void> | null = null;\n private flushRequested = false;\n private retryTimer: number | null = null;\n private started = false;\n /** Consecutive failed authority attempts — drives the revalidate backoff. */\n private authorityFailures = 0;\n /** Epoch ms before which `ensureAuthority` must not issue another request. */\n private nextAuthorityAttemptAt = 0;\n private readonly conversionQueue: QueuedConversion[] = [];\n private readonly automaticQueue: QueuedAutomaticEvent[] = [];\n private readonly pageQueue: QueuedPageView[] = [];\n private readonly listeners = new Set<() => void>();\n\n constructor(\n readonly ref: TrackingConfigReference,\n fetchImpl: typeof fetch = globalThis.fetch,\n ) {\n // Native browser fetch requires Window/WorkerGlobalScope as its receiver.\n // Calling an unbound native fetch stored on `this` throws \"Illegal invocation\".\n this.fetchImpl = fetchImpl.bind(globalThis);\n // Resolved once: the cache key, the fetch target, and the write-back key must\n // all be the same string, or a reference that composes its URL would read a\n // cache entry it never writes.\n this.url = resolveTrackingConfigUrl(ref);\n const cached = readCache(this.url);\n this.current = cached?.config ?? null;\n this.etag = cached?.etag ?? null;\n this.start();\n }\n\n /** The URL this runtime actually fetches (composed or explicit). */\n configUrl(): string {\n return this.url;\n }\n\n /**\n * Explicitly start authority resolution and Google-tag bootstrap.\n *\n * Idempotent so framework effects can call it after hydration without\n * depending on constructor timing.\n */\n start(): void {\n if (this.started) {\n void this.flush();\n return;\n }\n this.started = true;\n void this.revalidate();\n if (typeof window !== \"undefined\") {\n window.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState === \"visible\") void this.revalidate();\n });\n window.addEventListener(\"focus\", () => void this.revalidate());\n }\n }\n\n state(): RuntimeState {\n return this.stateValue;\n }\n\n config(): ConversionConfig | null {\n return this.stateValue === \"active\" || this.stateValue === \"tombstone\" ? this.current : null;\n }\n\n __unsafeExpireAuthorityForTests(): void {\n this.confirmedAt = Number.NEGATIVE_INFINITY;\n this.nextAuthorityAttemptAt = 0;\n }\n\n /** Queue depths — asserted by tests to pin the bound. */\n __queueDepthsForTests(): { conversions: number; automatic: number; pages: number } {\n return {\n conversions: this.conversionQueue.length,\n automatic: this.automaticQueue.length,\n pages: this.pageQueue.length,\n };\n }\n\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n async ensureAuthority(): Promise<boolean> {\n if (this.stateValue !== \"unconfirmed\" && Date.now() - this.confirmedAt < AUTHORITY_TTL_MS) {\n return true;\n }\n // Backoff applies only to this implicit path (driven by queued work). An\n // explicit `revalidate()` — the visibility/focus listeners, or a caller —\n // always goes to the network so recovery is never delayed by the timer.\n if (Date.now() < this.nextAuthorityAttemptAt) return false;\n await this.revalidateAuthority();\n return this.stateValue !== \"unconfirmed\" && Date.now() - this.confirmedAt < AUTHORITY_TTL_MS;\n }\n\n async revalidate(): Promise<void> {\n this.nextAuthorityAttemptAt = 0;\n await this.revalidateAuthority();\n await this.flush();\n }\n\n private async revalidateAuthority(): Promise<void> {\n if (typeof window === \"undefined\" || !this.fetchImpl) return;\n if (this.inFlight) return this.inFlight;\n this.inFlight = this.revalidateNow().finally(() => {\n this.inFlight = null;\n });\n return this.inFlight;\n }\n\n queuePageView(snapshot: QueuedPageView | null = pageViewSnapshot()): void {\n if (!snapshot) return;\n pushBounded(this.pageQueue, snapshot);\n void this.flush();\n }\n\n fireConversion(key: string, options?: TrackingConfigConversionOptions): void {\n pushBounded(this.conversionQueue, { key, ...options });\n void this.flush();\n }\n\n queueAutomaticEvent(\n eventType: string,\n metadata: Record<string, unknown>,\n transactionPath: string,\n transactionScope: string,\n ): void {\n pushBounded(this.automaticQueue, {\n eventType,\n metadata,\n transactionPath,\n transactionScope,\n });\n void this.flush();\n }\n\n listGoals(): ConversionGoal[] {\n return this.current?.goals ?? [];\n }\n\n /** Sibling hostnames for the linker — read from the cached object even while unconfirmed. */\n linkedHostnames(): readonly string[] {\n return this.current?.linked_hostnames ?? [];\n }\n\n /**\n * Integration blocks, from the cached object even while unconfirmed: the booking fallback\n * link is safe to show stale. Same reference until the next adopt, for `useSyncExternalStore`.\n */\n integrations(): ConversionIntegrations | null {\n return this.current?.integrations ?? null;\n }\n\n private async revalidateNow(): Promise<void> {\n try {\n const headers: Record<string, string> = {};\n if (this.etag) headers[\"If-None-Match\"] = this.etag;\n const response = await this.fetchImpl(this.url, {\n method: \"GET\",\n headers,\n cache: \"no-cache\",\n });\n if (response.status === 304 && this.current && validateConfig(this.current, this.ref)) {\n this.confirm(this.current, this.etag);\n return;\n }\n if (!response.ok) {\n this.expireAuthority();\n return;\n }\n const next = parseConversionConfig(await response.json());\n if (!next || !validateConfig(next, this.ref)) {\n this.expireAuthority();\n return;\n }\n if (this.current && next.config_version < this.current.config_version) {\n this.expireAuthority();\n return;\n }\n this.confirm(next, response.headers.get(\"ETag\"));\n } catch {\n this.expireAuthority();\n }\n }\n\n private expireAuthority(): void {\n this.authorityGeneration += 1;\n if (this.stateValue !== \"unconfirmed\") this.stateValue = \"unconfirmed\";\n // Exponential, capped. Queued work keeps accumulating (bounded), so the\n // config appearing later still fires the most recent items.\n this.authorityFailures += 1;\n const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (this.authorityFailures - 1));\n this.nextAuthorityAttemptAt = Date.now() + delay;\n }\n\n private confirm(config: ConversionConfig, etag: string | null): void {\n this.authorityGeneration += 1;\n this.current = config;\n this.etag = etag;\n this.confirmedAt = Date.now();\n this.authorityFailures = 0;\n this.nextAuthorityAttemptAt = 0;\n this.stateValue = isTombstone(config) ? \"tombstone\" : \"active\";\n writeCache(this.url, { etag, config });\n if (this.stateValue === \"tombstone\") {\n if (this.retryTimer !== null) {\n window.clearTimeout(this.retryTimer);\n this.retryTimer = null;\n }\n this.conversionQueue.length = 0;\n this.automaticQueue.length = 0;\n this.pageQueue.length = 0;\n }\n for (const listener of this.listeners) listener();\n }\n\n private async flush(): Promise<void> {\n if (this.flushInFlight) {\n this.flushRequested = true;\n return this.flushInFlight;\n }\n this.flushRequested = false;\n this.flushInFlight = this.flushNow().finally(() => {\n this.flushInFlight = null;\n if (this.flushRequested && this.stateValue === \"active\") void this.flush();\n });\n return this.flushInFlight;\n }\n\n private scheduleRetry(): void {\n if (this.retryTimer !== null || typeof window === \"undefined\") return;\n this.retryTimer = window.setTimeout(() => {\n this.retryTimer = null;\n void this.flush();\n }, 1000);\n }\n\n private async flushNow(): Promise<void> {\n if (!(await this.ensureAuthority())) return;\n if (this.stateValue !== \"active\" || !this.current) return;\n const config = this.current;\n const generation = this.authorityGeneration;\n const ids = Object.values(config.gtag_ids).filter(\n (id): id is string => typeof id === \"string\" && isValidGtagId(id),\n );\n if (ids.length === 0) return;\n await ensureScript(ids[0]!);\n if (\n this.authorityGeneration !== generation ||\n this.stateValue !== \"active\" ||\n this.current !== config\n ) {\n return;\n }\n const gtag = window.gtag;\n if (typeof gtag !== \"function\") return;\n try {\n if (!jsInitialized) {\n gtag(\"js\", new Date());\n jsInitialized = true;\n }\n for (const id of ids) {\n if (configuredIds.has(id)) continue;\n gtag(\"config\", id, { send_page_view: false });\n configuredIds.add(id);\n }\n } catch {\n this.scheduleRetry();\n return;\n }\n while (this.pageQueue.length > 0) {\n const page = this.pageQueue[0]!;\n try {\n gtag(\"event\", \"page_view\", {\n page_location: page.href,\n page_title: page.title ?? undefined,\n page_referrer: page.referrer ?? undefined,\n });\n this.pageQueue.shift();\n } catch {\n this.scheduleRetry();\n return;\n }\n }\n while (this.automaticQueue.length > 0) {\n const event = this.automaticQueue[0]!;\n for (const goal of config.goals) {\n if (goal.kind !== \"event\" || !goal.firing) continue;\n if (!automaticThresholdMet(goal, event.eventType, event.metadata)) continue;\n pushBounded(this.conversionQueue, {\n key: goal.key,\n transactionId: getAutomaticTransactionId(\n event.transactionScope,\n goal.key,\n event.transactionPath,\n ),\n });\n }\n this.automaticQueue.shift();\n }\n while (this.conversionQueue.length > 0) {\n const item = this.conversionQueue[0]!;\n const goal = config.goals.find((g) => g.key === item.key);\n const firing = goal?.firing;\n if (!firing) {\n this.conversionQueue.shift();\n continue;\n }\n const currency = item.currency ?? firing.currency ?? null;\n const value =\n item.value ??\n (firing.value_cents != null && currency\n ? fromMinor(firing.value_cents, currency as SupportedCurrency)\n : null);\n const outcome = fireConversionWithConsent({\n sendTo: firing.send_to,\n value,\n currency,\n transactionId: item.transactionId ?? null,\n });\n if (outcome === \"retryable\") {\n this.scheduleRetry();\n return;\n }\n this.conversionQueue.shift();\n }\n }\n}\n\nexport function getTrackingConfigRuntime(\n ref: TrackingConfigReference,\n fetchImpl?: typeof fetch,\n): TrackingConfigRuntime {\n // Keyed on the RESOLVED url: `{cdnUrl}` and an equivalent\n // `{cdnBaseUrl,businessId,environment}` name the same object and must share one\n // runtime, or each would keep its own authority state and gtag bootstrap.\n const key = `${resolveTrackingConfigUrl(ref)}|${ref.businessId}|${ref.environment}`;\n const existing = runtimes.get(key);\n if (existing) return existing;\n const runtime = new TrackingConfigRuntime(ref, fetchImpl ?? globalThis.fetch);\n runtimes.set(key, runtime);\n return runtime;\n}\n\nexport function resetTrackingConfigRuntimesForTests(): void {\n runtimes.clear();\n configuredIds.clear();\n scriptLoad = null;\n jsInitialized = false;\n}\n","// Tracking ingest client. Queues events, debounce-flushes them to the\n// /tracking/events endpoint, and degrades silently on errors so a broken\n// network never breaks the host site.\n\nimport { getRegisteredCapabilities } from \"./capabilities\";\nimport { getConsentChoice, getConsentState } from \"./consent\";\nimport { consumeInboundLink, stripLinkParam, type OriginLink } from \"./cross-site-link\";\nimport { getEventSemantics } from \"./events/semantics\";\nimport { resetPageViewState } from \"./page-view\";\nimport type { PhoneConfig } from \"./phone-field\";\nimport { captureFbc, getFbcCookie, getFbpCookie } from \"./fbq\";\nimport {\n captureTrackingParamsFromLocation,\n createEmptyTrackingParams,\n getCookieValueFromDocument,\n getTrackingParamsFromCookieReader,\n mergeTrackingParams,\n} from \"./tracking\";\nimport type { TriggerRegistryConfig } from \"./events/registry\";\nimport { buildHeartbeatMetadata } from \"./heartbeat\";\nimport { buildLandingPayloadFields, getOrCaptureLandingParams } from \"./landing\";\nimport { getOrRotateSessionId, getVisitorId } from \"./session\";\nimport { stashUserDataFromFormFields } from \"./user-data\";\nimport type {\n TrackingClientContext,\n TrackingEnvironment,\n TrackingInstallSurface,\n TrackingParams,\n TrackingSessionUpsertPayload,\n} from \"./types\";\n\n/**\n * Default debounce window before queued events are flushed.\n */\nexport const DEFAULT_FLUSH_INTERVAL_MS = 2000;\n\n/**\n * Default queue size that triggers an immediate flush.\n */\nexport const DEFAULT_MAX_QUEUE_SIZE = 10;\n\n/**\n * Hard server-side maximum event count per request body.\n */\nexport const HARD_MAX_BATCH = 50;\n\n/**\n * Ceiling on the exponential backoff between failed delivery attempts.\n */\nexport const MAX_RETRY_BACKOFF_MS = 60_000;\n\n/**\n * Cap on events held in the durable retry buffer. Oldest batches are dropped\n * first once this is exceeded — an unbounded buffer would eventually blow the\n * localStorage quota and take the whole SDK down with it.\n */\nexport const MAX_BUFFERED_EVENTS = 200;\n\n/**\n * localStorage key prefix for the durable retry buffer. Versioned so a future\n * shape change can't be misread as the current one.\n */\nexport const RETRY_BUFFER_KEY_PREFIX = \"aranova_tracking_pending_v1\";\n\n/**\n * What to do with a batch after an attempted delivery.\n *\n * `retry` covers the transport failing and the server saying \"later\" (408, 429,\n * 5xx). `drop` covers a permanent rejection — a 422 from a malformed payload\n * will never succeed, and retrying it forever would wedge every batch behind it.\n */\ntype DeliveryOutcome = \"ok\" | \"retry\" | \"drop\";\n\n/**\n * Header used to authenticate public tracking ingest requests.\n */\nexport const API_KEY_HEADER = \"X-Aranova-Api-Key\";\n\n/**\n * Identity headers stamped on every ingest request. Duplicate fields already\n * present in `session.context` but survive body-parse failures so the backend\n * can attribute 422s to the offending SDK install.\n */\nexport const SDK_VERSION_HEADER = \"X-Aranova-Sdk-Version\";\nexport const SDK_PACKAGE_HEADER = \"X-Aranova-Sdk-Package\";\nexport const SDK_SURFACE_HEADER = \"X-Aranova-Sdk-Surface\";\nexport const SDK_ENVIRONMENT_HEADER = \"X-Aranova-Sdk-Environment\";\n\n/**\n * Configuration for the low-level ingest client.\n *\n * Framework packages usually create this for you through `createTracking()`.\n */\nexport interface TrackingClientConfig {\n /** Public tracking API key issued for the business. */\n apiKey: string;\n /** Tracking endpoint base URL, usually ending in `/tracking`. */\n endpoint: string;\n /** SDK surface creating this client. */\n surface: TrackingInstallSurface;\n /** Package version reported in session context and heartbeat metadata. */\n sdkVersion?: string;\n /** Package name reported in session context and heartbeat metadata. */\n packageName?: string;\n /** Trigger registry so the heartbeat can report registered events. */\n triggers?: TriggerRegistryConfig;\n /** Override the default 2s debounce window. */\n flushIntervalMs?: number;\n /** Override the default 10-event batch trigger. */\n maxQueueSize?: number;\n /** Deployment environment label reported in session context. */\n environment?: TrackingEnvironment;\n /** All active gtag IDs, keyed by label. Included in session context. */\n activeGtagIds?: Record<string, string>;\n /** `siteId` of the tracking-config reference in use; reported on the heartbeat. */\n configSiteId?: string | null;\n /** When true, swallow nothing — useful for tests. */\n debug?: boolean;\n /** Phone-field config, carried for parity; the React hook reads it via context. */\n phone?: PhoneConfig;\n}\n\n/**\n * Input accepted by the low-level stringly-typed client.\n *\n * Prefer the typed `trackEvent(eventName, metadata)` facade exposed by\n * `useTracking()` in React/Next integrations.\n */\nexport interface TrackEventInput {\n /** Event name to enqueue. */\n eventType: string;\n /** URL associated with the event. Defaults to the current page URL. */\n pageUrl?: string | null;\n /** Event-specific metadata. */\n metadata?: Record<string, unknown> | null;\n /** Timestamp override. Defaults to queue time. */\n occurredAt?: Date | string | null;\n}\n\ninterface QueuedEvent {\n event_type: string;\n page_url: string | null;\n metadata: Record<string, unknown> | null;\n occurred_at: string | null;\n}\n\n/**\n * Low-level tracking client responsible for queueing and flushing events.\n */\nexport interface TrackingClient {\n /** Enqueue an event for batched delivery. */\n trackEvent: (input: TrackEventInput) => void;\n /** Flush queued events immediately (fetch, non-keepalive). */\n flush: () => Promise<void>;\n /**\n * Flush queued events through the keepalive transport so the request\n * survives document unload. Use from `pagehide`/`visibilitychange:hidden`\n * handlers — a plain `flush()` there is aborted by the browser on unload.\n */\n flushBeacon: () => void;\n /** Return the current rolling session id. */\n getSessionId: () => string;\n /** Return the persistent visitor id. */\n getVisitorId: () => string;\n /** Remove timers/listeners and prevent future flushes. */\n destroy: () => void;\n}\n\ninterface IngestRequestBody {\n session: TrackingSessionUpsertPayload;\n events: Array<{\n event_type: string;\n page_url: string | null;\n metadata: Record<string, unknown> | null;\n occurred_at: string | null;\n }>;\n}\n\nfunction buildContext(\n surface: TrackingInstallSurface,\n sdkVersion: string | null,\n packageName: string | null,\n environment: TrackingEnvironment,\n activeGtagIds: Record<string, string> | null,\n): TrackingClientContext {\n return {\n surface,\n sdk_version: sdkVersion,\n package_name: packageName,\n site_origin: typeof window === \"undefined\" ? null : window.location.origin,\n page_title: typeof document === \"undefined\" ? null : document.title || null,\n referrer: typeof document === \"undefined\" ? null : document.referrer || null,\n environment,\n active_gtag_ids: activeGtagIds,\n // Rebuilt per flush (this runs inside the payload builder), so a client\n // constructed later on a deeper route still gets reported.\n capabilities: getRegisteredCapabilities(),\n };\n}\n\nfunction readTrackingParams(): TrackingParams {\n if (typeof window === \"undefined\") return createEmptyTrackingParams();\n // Capture from URL on every read so the first event in a session reflects the\n // landing-page params even if the cookie helper hasn't run yet.\n try {\n captureTrackingParamsFromLocation();\n } catch {\n // ignore\n }\n return getTrackingParamsFromCookieReader(getCookieValueFromDocument);\n}\n\nfunction consentSnapshot(): Record<string, unknown> | null {\n try {\n // Effective consent + provenance so the dashboard can tell default-granted\n // (opt-out model, no interaction) apart from an explicit choice.\n const choice = getConsentChoice();\n return {\n state: choice.state,\n source: choice.source,\n updated_at: choice.updatedAt,\n expires_at: choice.expiresAt,\n };\n } catch {\n return null;\n }\n}\n\ninterface IdentityHeaders {\n sdkVersion: string;\n packageName: string;\n surface: string;\n environment: string;\n}\n\nasync function postWithFetch(\n url: string,\n body: string,\n apiKey: string,\n identity: IdentityHeaders,\n keepalive: boolean,\n): Promise<DeliveryOutcome> {\n // No fetch at all (a non-browser runtime): there is nothing to retry against,\n // and scheduling one would spin a timer forever. Give up on the batch.\n if (typeof fetch !== \"function\") return \"drop\";\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n [API_KEY_HEADER]: apiKey,\n [SDK_VERSION_HEADER]: identity.sdkVersion,\n [SDK_PACKAGE_HEADER]: identity.packageName,\n [SDK_SURFACE_HEADER]: identity.surface,\n [SDK_ENVIRONMENT_HEADER]: identity.environment,\n },\n body,\n keepalive,\n // CORS is open on the tracking endpoint; never send cookies.\n credentials: \"omit\",\n mode: \"cors\",\n });\n if (response.ok) return \"ok\";\n if (response.status === 408 || response.status === 429 || response.status >= 500) {\n return \"retry\";\n }\n // 401/403/422 and friends: the payload or the key is wrong, and will still\n // be wrong in 30 seconds.\n return \"drop\";\n } catch {\n // Network-level failure (offline, DNS, CORS preflight, aborted). Never\n // throws to the host site; the batch stays buffered for the next attempt.\n return \"retry\";\n }\n}\n\n/**\n * A batch awaiting delivery. Each one carries its OWN session snapshot: a batch\n * can outlive the session that produced it (buffered across a reload), and\n * re-sending it under whatever session is current would silently misattribute\n * those events.\n */\ninterface PendingBatch {\n session: TrackingSessionUpsertPayload;\n events: QueuedEvent[];\n}\n\nfunction retryBufferKey(apiKey: string, endpoint: string): string {\n return `${RETRY_BUFFER_KEY_PREFIX}:${apiKey}:${endpoint}`;\n}\n\nfunction readPendingBatches(key: string): PendingBatch[] {\n if (typeof window === \"undefined\") return [];\n try {\n const raw = window.localStorage.getItem(key);\n if (!raw) return [];\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed.filter(\n (entry): entry is PendingBatch =>\n typeof entry === \"object\" &&\n entry !== null &&\n \"session\" in entry &&\n Array.isArray((entry as PendingBatch).events),\n );\n } catch {\n // Storage blocked, or a corrupt/foreign value — start clean rather than\n // letting a bad read break the host site.\n return [];\n }\n}\n\n/**\n * Mirror the buffer to storage unconditionally.\n *\n * Deliberately no read-back verification: under partitioned storage a write can\n * succeed for this document and still not be visible to a verifying read, so a\n * verify-gate false-negatives exactly where durability matters most.\n */\nfunction writePendingBatches(key: string, batches: PendingBatch[]): void {\n if (typeof window === \"undefined\") return;\n try {\n if (batches.length === 0) window.localStorage.removeItem(key);\n else window.localStorage.setItem(key, JSON.stringify(batches));\n } catch {\n // Quota exceeded or storage blocked — in-memory retry still works.\n }\n}\n\n/** Drop the oldest batches until the buffer is back under the event cap. */\nfunction trimToBufferCap(batches: PendingBatch[]): PendingBatch[] {\n let total = batches.reduce((sum, batch) => sum + batch.events.length, 0);\n const trimmed = batches.slice();\n while (total > MAX_BUFFERED_EVENTS && trimmed.length > 1) {\n const dropped = trimmed.shift();\n total -= dropped ? dropped.events.length : 0;\n }\n return trimmed;\n}\n\nlet globalClient: TrackingClient | null = null;\nlet globalClientKey: string | null = null;\n\nfunction clientConfigKey(config: TrackingClientConfig): string {\n return `${config.apiKey}@${config.endpoint}#${config.surface}`;\n}\n\n/**\n * Return a page-level singleton tracking client. Creating the client anew on\n * every component mount is wrong — React StrictMode double-mounts dev-only,\n * and destroying+recreating the client between the cleanup and re-run strips\n * away the pushState patch that SPA auto page view relies on. A singleton\n * survives all of that: the client lives for the entire page, and providers\n * just attach/detach auto page view against it.\n *\n * If `apiKey` / `endpoint` / `surface` change between calls, the previous\n * singleton is destroyed and a new one replaces it. This covers hot-config\n * changes without leaking state.\n */\nexport function getOrCreateTrackingClient(config: TrackingClientConfig): TrackingClient {\n const key = clientConfigKey(config);\n if (globalClient !== null && globalClientKey === key) {\n return globalClient;\n }\n if (globalClient !== null) {\n globalClient.destroy();\n // A config swap is a genuinely new client, not a remount of this one — the old client's\n // route already latched the page_view gate, which would otherwise suppress the new client's.\n resetPageViewState();\n }\n globalClient = createTrackingClient(config);\n globalClientKey = key;\n return globalClient;\n}\n\n// Global/delegated captures (document/window listeners: page_exit, scroll_depth,\n// time_on_site, form_start, cta_click, phone_click, bfcache restore) must attach ONCE\n// per page-singleton client — NOT once per React provider mount. A page can mount several\n// <TrackingProvider>s (a supported island pattern: a global provider for page-level\n// triggers plus per-component providers so `useSearchParams()` doesn't opt the whole tree\n// out of static rendering). Since the client is a singleton but each provider runs its own\n// attach effect, N mounts would stack N document listeners and every interaction would emit\n// its event N times. We ref-count attaches against the singleton client's identity: the\n// first mount attaches, later mounts are no-ops, and the last unmount detaches.\ninterface ClientCaptureEntry {\n detach: () => void;\n refCount: number;\n}\nconst clientCaptureRegistry = new WeakMap<TrackingClient, ClientCaptureEntry>();\n\n/**\n * Attach a set of global/delegated captures against a singleton `client` exactly once,\n * ref-counted across provider mounts. `build` performs the actual `document`/`window`\n * listener attachment and returns a single detacher for all of them; it runs only on the\n * first mount for a given client. The returned release decrements the ref-count and runs\n * `build`'s detacher when the last holder releases. Release is idempotent — React\n * StrictMode invokes an effect's cleanup twice in dev, and a double release must not\n * double-decrement.\n */\nexport function attachClientCapturesOnce(\n client: TrackingClient,\n build: () => () => void,\n): () => void {\n let entry = clientCaptureRegistry.get(client);\n if (entry === undefined) {\n entry = { detach: build(), refCount: 0 };\n clientCaptureRegistry.set(client, entry);\n }\n entry.refCount += 1;\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = clientCaptureRegistry.get(client);\n if (current === undefined) return;\n current.refCount -= 1;\n if (current.refCount <= 0) {\n current.detach();\n clientCaptureRegistry.delete(client);\n }\n };\n}\n\n/**\n * Tear down the singleton if any. Primarily an escape hatch for tests where\n * each test should see a fresh client; production code rarely needs this.\n * Also clears the in-session SPA referrer so the next test starts with a\n * fresh referrer chain.\n */\nexport function resetGlobalTrackingClient(): void {\n if (globalClient !== null) {\n // Force-detach any captures still attached to this client so their document/window\n // listeners can't leak into the next test even if a provider didn't unmount.\n const entry = clientCaptureRegistry.get(globalClient);\n if (entry !== undefined) {\n entry.detach();\n clientCaptureRegistry.delete(globalClient);\n }\n globalClient.destroy();\n }\n globalClient = null;\n globalClientKey = null;\n resetPageViewState();\n}\n\n/**\n * Create a low-level ingest client.\n *\n * The client queues events, debounces network flushes, sends an SDK heartbeat\n * once per new session, and swallows network errors so analytics never break\n * the host site.\n */\nexport function createTrackingClient(config: TrackingClientConfig): TrackingClient {\n const flushIntervalMs = config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;\n const maxQueueSize = Math.min(config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE, HARD_MAX_BATCH);\n const sdkVersion = config.sdkVersion ?? null;\n const packageName = config.packageName ?? null;\n // Default to 'production' so the wire payload always carries a valid enum\n // value. Sending null would fail the backend's strict enum validation.\n const environment: TrackingEnvironment = config.environment ?? \"production\";\n const activeGtagIds = config.activeGtagIds ?? null;\n const endpointBase = config.endpoint.replace(/\\/$/, \"\");\n const eventsUrl = `${endpointBase}/events`;\n const identityHeaders: IdentityHeaders = {\n sdkVersion: sdkVersion ?? \"\",\n packageName: packageName ?? \"\",\n surface: config.surface,\n environment,\n };\n\n let queue: QueuedEvent[] = [];\n let flushTimer: ReturnType<typeof setTimeout> | null = null;\n // Batches that have been handed to the network at least once and not yet\n // acknowledged. Mirrored to localStorage so a reload — or a deploy window that\n // 5xxs every request — costs latency instead of data.\n const bufferKey = retryBufferKey(config.apiKey, endpointBase);\n let pending: PendingBatch[] = readPendingBatches(bufferKey);\n // Flushes are serialized through one chain. Batches are no longer removed from\n // the buffer before the response arrives, so two overlapping flushes would\n // send the same batch twice. Chaining (rather than a \"busy\" flag that returns\n // early) keeps `await flush()` meaning \"flushed\" for the caller.\n let flushChain: Promise<void> = Promise.resolve();\n let retryAttempt = 0;\n let firstPage: string | null = null;\n // Attribution captured synchronously at client creation (before any SPA router\n // can strip the query string). Merged into every session payload as a fallback\n // so a blocked-cookie webview or a stripped URL still attributes.\n let initialParams = createEmptyTrackingParams();\n let destroyed = false;\n\n // Consumed BEFORE identity is minted: it may supply the visitor id itself.\n let inboundLink: ReturnType<typeof consumeInboundLink> = null;\n if (typeof window !== \"undefined\") {\n try {\n inboundLink = consumeInboundLink();\n } catch {\n // never break the host site\n }\n }\n // The sibling-site session this page load arrived from; cleared on rotation.\n let originLink: OriginLink | null = inboundLink?.link ?? null;\n\n // Initialize identity early so the first POST has stable values.\n const visitorId = getVisitorId();\n const initialSession = getOrRotateSessionId();\n let sessionId = initialSession.id;\n\n if (typeof window !== \"undefined\") {\n firstPage = stripLinkParam(window.location.href);\n try {\n initialParams = captureTrackingParamsFromLocation();\n } catch {\n // ignore — never break the host site\n }\n // Pin this session's LANDING params now, before any SPA router strips the\n // query string (reuses the stored record when the session already has one).\n getOrCaptureLandingParams(sessionId);\n try {\n captureFbc();\n } catch {\n // ignore\n }\n }\n\n /** The live page URL as stored — never carrying the transport token. */\n function currentPageUrl(): string | null {\n return typeof window === \"undefined\" ? null : stripLinkParam(window.location.href);\n }\n\n // SDK-internal events are queued at construction, before `trackEvent` exists.\n function enqueueInternal(\n eventType: string,\n metadata: Record<string, unknown>,\n pageUrl: string | null,\n ): void {\n queue.push({\n event_type: eventType,\n page_url: pageUrl,\n metadata,\n occurred_at: new Date().toISOString(),\n });\n }\n\n // Re-read per call: a session can rotate mid-page, and the heartbeat reports\n // the page the visitor is on then, not the one the client was built on.\n function enqueueHeartbeat(): void {\n enqueueInternal(\n \"sdk_heartbeat\",\n buildHeartbeatMetadata(\n config.surface,\n sdkVersion,\n packageName,\n config.triggers ?? null,\n activeGtagIds,\n config.configSiteId ?? null,\n ),\n currentPageUrl(),\n );\n }\n\n if (initialSession.isNew) {\n enqueueHeartbeat();\n }\n\n // A rejected token is reported too: the backend never sees one, only this verdict.\n if (inboundLink !== null && typeof window !== \"undefined\") {\n const link = inboundLink.link;\n enqueueInternal(\n \"site_link_arrival\",\n {\n outcome: inboundLink.outcome,\n origin_session_id: link?.sessionId ?? null,\n origin_visitor_id: link?.visitorId ?? null,\n transit_ms: link ? Math.max(0, Date.now() - link.linkedAt) : null,\n adopted_visitor: inboundLink.adoptedVisitor,\n page: { path: window.location.pathname },\n },\n firstPage,\n );\n scheduleFlush();\n }\n\n // Anything left buffered by a previous page load goes out on the normal\n // debounce, ahead of whatever this page produces.\n if (pending.length > 0) {\n scheduleFlush();\n }\n\n function buildSessionPayload(): TrackingSessionUpsertPayload {\n const rotated = getOrRotateSessionId();\n if (rotated.isNew && rotated.id !== sessionId) {\n // Session rotated mid-page (idle > 30min then user returned).\n enqueueHeartbeat();\n originLink = null;\n }\n sessionId = rotated.id;\n // Prefer the live cookie/localStorage read; fall back to the init-time\n // snapshot (covers a webview that blocked the cookie AND a router that\n // already stripped the landing URL by flush time).\n const params = mergeTrackingParams(readTrackingParams(), initialParams);\n const context = buildContext(\n config.surface,\n sdkVersion,\n packageName,\n environment,\n activeGtagIds,\n );\n return {\n session_id: sessionId,\n visitor_id: visitorId,\n gclid: params.gclid,\n wbraid: params.wbraid,\n gbraid: params.gbraid,\n ylpcid: params.ylpcid,\n fbclid: params.fbclid,\n fbc: getFbcCookie(),\n fbp: getFbpCookie(),\n utm_source: params.utm_source,\n utm_medium: params.utm_medium,\n utm_campaign: params.utm_campaign,\n utm_term: params.utm_term,\n utm_content: params.utm_content,\n // Landing params for the CURRENT session id — captured on the spot when\n // the session just rotated (the current URL is the rotated session's\n // landing), reused from the stored record otherwise. Keys are omitted\n // entirely when the landing isn't observable (SSR).\n ...buildLandingPayloadFields(sessionId),\n ...(originLink\n ? {\n origin_session_id: originLink.sessionId,\n origin_visitor_id: originLink.visitorId,\n origin_linked_at: new Date(originLink.linkedAt).toISOString(),\n }\n : {}),\n first_page: firstPage,\n consent_state: consentSnapshot(),\n context,\n };\n }\n\n function scheduleFlush(delayMs: number = flushIntervalMs): void {\n if (flushTimer !== null || destroyed) return;\n flushTimer = setTimeout(() => {\n flushTimer = null;\n void flush();\n }, delayMs);\n }\n\n function clearScheduledFlush(): void {\n if (flushTimer !== null) {\n clearTimeout(flushTimer);\n flushTimer = null;\n }\n }\n\n /** Move everything currently queued into the durable buffer. */\n function bufferQueued(): void {\n if (queue.length === 0) return;\n const events = queue.slice(0, HARD_MAX_BATCH);\n queue = queue.slice(events.length);\n pending = trimToBufferCap([...pending, { session: buildSessionPayload(), events }]);\n writePendingBatches(bufferKey, pending);\n }\n\n /**\n * Drain the durable buffer, oldest batch first.\n *\n * A batch leaves the buffer only on `ok` (acknowledged) or `drop` (permanently\n * rejected). Anything else keeps it, so a 5xx window costs latency, not data.\n * At-least-once: the backend drops a re-sent event by `tracking.events.dedupe_key`.\n */\n function flush(): Promise<void> {\n // `.catch` keeps one unexpected throw from poisoning every later flush.\n flushChain = flushChain.then(runFlush).catch(() => {});\n return flushChain;\n }\n\n async function runFlush(): Promise<void> {\n if (destroyed) return;\n clearScheduledFlush();\n bufferQueued();\n if (pending.length === 0) return;\n\n try {\n while (pending.length > 0) {\n const batch = pending[0]!;\n const body: IngestRequestBody = { session: batch.session, events: batch.events };\n // Plain fetch on a live page so the outcome is observable; only the unload\n // path needs keepalive, which outlives the document but can't be observed.\n const outcome = await postWithFetch(\n eventsUrl,\n JSON.stringify(body),\n config.apiKey,\n identityHeaders,\n false,\n );\n // Destroyed mid-send: the successor client owns the shared buffer now.\n if (destroyed) return;\n if (outcome === \"retry\") {\n retryAttempt += 1;\n return;\n }\n // By identity, not position: an unload during the await may already have\n // taken this batch, and `pending[0]` is then one that was never sent.\n pending = pending.filter((entry) => entry !== batch);\n writePendingBatches(bufferKey, pending);\n retryAttempt = 0;\n }\n } finally {\n if (pending.length > 0) {\n const backoff = Math.min(flushIntervalMs * 2 ** retryAttempt, MAX_RETRY_BACKOFF_MS);\n scheduleFlush(backoff);\n } else if (queue.length > 0) {\n // `bufferQueued` takes at most HARD_MAX_BATCH per pass, so a burst\n // larger than one batch still has events waiting.\n scheduleFlush();\n }\n }\n }\n\n function trackEvent(input: TrackEventInput): void {\n if (destroyed) return;\n if (!input || typeof input.eventType !== \"string\" || input.eventType.length === 0) return;\n\n // Enhanced conversions: a form submit is the one moment the visitor's own\n // email/phone pass through the SDK — stash them (memory only) so the\n // conversion that fires next carries user_data. NOT done for phone_click:\n // its metadata holds the BUSINESS's number, not the visitor's. An explicit\n // consent decline also skips the stash entirely — egress is already gated,\n // but a decliner's identifiers shouldn't sit in page memory either.\n if (input.eventType === \"form_submit\" && getConsentState() !== \"denied\") {\n try {\n const fields = (input.metadata as { form?: { fields?: unknown } } | null)?.form?.fields;\n if (fields) stashUserDataFromFormFields(fields, config.phone?.defaultCountry);\n } catch {\n // user-data capture must never break ingest\n }\n }\n\n const occurredAt =\n input.occurredAt instanceof Date\n ? input.occurredAt.toISOString()\n : typeof input.occurredAt === \"string\"\n ? input.occurredAt\n : new Date().toISOString();\n\n queue.push({\n event_type: input.eventType,\n // A caller's href still carries `_arv` until the surface strips it after hydration.\n page_url: input.pageUrl != null ? stripLinkParam(input.pageUrl) : currentPageUrl(),\n metadata: input.metadata ?? null,\n occurred_at: occurredAt,\n });\n\n // A lead is sent while the page is still alive: a tel: tap or a form submit\n // usually hides or leaves the page next, and the unload path can't retry.\n if (\n queue.length >= maxQueueSize ||\n getEventSemantics(input.eventType)?.outcomeRole === \"lead\"\n ) {\n void flush();\n } else {\n scheduleFlush();\n }\n }\n\n /**\n * Last-gasp keepalive send on pagehide/visibilitychange.\n *\n * Its outcome is unobservable, so the batch leaves the buffer optimistically;\n * re-sending one `runFlush` has in flight is intended (unload may abort that).\n */\n function flushOnUnload(): void {\n if (destroyed) return;\n bufferQueued();\n clearScheduledFlush();\n if (pending.length === 0) return;\n\n const batch = pending[0]!;\n pending = pending.slice(1);\n writePendingBatches(bufferKey, pending);\n\n const body: IngestRequestBody = { session: batch.session, events: batch.events };\n void postWithFetch(eventsUrl, JSON.stringify(body), config.apiKey, identityHeaders, true);\n }\n\n function onVisibilityChange(): void {\n if (document.visibilityState === \"hidden\") flushOnUnload();\n }\n\n if (typeof window !== \"undefined\") {\n window.addEventListener(\"pagehide\", flushOnUnload);\n window.addEventListener(\"visibilitychange\", onVisibilityChange);\n }\n\n return {\n trackEvent,\n flush,\n flushBeacon: flushOnUnload,\n getSessionId: () => sessionId,\n getVisitorId: () => visitorId,\n destroy: () => {\n if (destroyed) return;\n // Keepalive-send what's queued, or StrictMode's dev unmount drops the first page_view.\n if (queue.length > 0) {\n flushOnUnload();\n }\n // Only after that final send: from here the successor owns the shared buffer.\n destroyed = true;\n clearScheduledFlush();\n queue = [];\n if (typeof window !== \"undefined\") {\n window.removeEventListener(\"pagehide\", flushOnUnload);\n window.removeEventListener(\"visibilitychange\", onVisibilityChange);\n }\n },\n };\n}\n","import {\n API_KEY_HEADER,\n SDK_ENVIRONMENT_HEADER,\n SDK_PACKAGE_HEADER,\n SDK_SURFACE_HEADER,\n SDK_VERSION_HEADER,\n} from \"../../ingest\";\nimport { AranovaApiError } from \"./errors\";\n\n/** Shared config for every awaited (non fire-and-forget) API helper. */\nexport interface ApiTransportConfig {\n /** Public (`aranv_pk_…`) or secret (`aranv_sk_…`) API key. */\n apiKey: string;\n /** Base tracking endpoint, e.g. `https://aranovainternal-production.up.railway.app/tracking`. */\n endpoint: string;\n /** Optional SDK identity headers (mirrors the event ingest client). */\n sdkVersion?: string;\n packageName?: string;\n surface?: string;\n environment?: string;\n}\n\nfunction identityHeaders(config: ApiTransportConfig): Record<string, string> {\n const headers: Record<string, string> = { [API_KEY_HEADER]: config.apiKey };\n if (config.sdkVersion) headers[SDK_VERSION_HEADER] = config.sdkVersion;\n if (config.packageName) headers[SDK_PACKAGE_HEADER] = config.packageName;\n if (config.surface) headers[SDK_SURFACE_HEADER] = config.surface;\n if (config.environment) headers[SDK_ENVIRONMENT_HEADER] = config.environment;\n return headers;\n}\n\nfunction joinUrl(endpoint: string, path: string): string {\n return `${endpoint.replace(/\\/$/, \"\")}${path}`;\n}\n\n/**\n * Single awaited request. Unlike the event queue, this surfaces failures: any\n * non-2xx rejects with an {@link AranovaApiError}. Returns `undefined` for 204.\n */\nexport async function apiRequest<T>(\n config: ApiTransportConfig,\n method: string,\n path: string,\n body?: unknown,\n extraHeaders?: Record<string, string>,\n): Promise<T> {\n const headers = { ...identityHeaders(config), ...extraHeaders };\n if (body !== undefined) headers[\"Content-Type\"] = \"application/json\";\n\n const response = await fetch(joinUrl(config.endpoint, path), {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n\n if (!response.ok) {\n let detail: string | undefined;\n let code: string | undefined;\n let context: Record<string, unknown> | undefined;\n try {\n const parsed: unknown = await response.json();\n if (parsed && typeof parsed === \"object\") {\n const record = parsed as Record<string, unknown>;\n if (typeof record.detail === \"string\") detail = record.detail;\n if (typeof record.code === \"string\") code = record.code;\n if (record.context && typeof record.context === \"object\") {\n context = record.context as Record<string, unknown>;\n }\n }\n } catch {\n // non-JSON error body — fall back to status text\n }\n throw new AranovaApiError(detail ?? response.statusText ?? \"Request failed\", {\n status: response.status,\n code,\n requestId: response.headers.get(\"x-request-id\") ?? undefined,\n context,\n });\n }\n\n if (response.status === 204) return undefined as T;\n return (await response.json()) as T;\n}\n","// Hoisted to `resources/http/request`. Both names below are public API via\n// `sales-public.ts`, so they keep their original spellings.\nimport type { ApiTransportConfig } from \"../http/request\";\nimport { apiRequest } from \"../http/request\";\n\nexport type SalesTransportConfig = ApiTransportConfig;\nexport const salesRequest = apiRequest;\n","import { registerCapability } from \"../../capabilities\";\nimport type { FormSubmitMetadata } from \"../../events/form-submit\";\nimport { buildBookingHandoffUrl, getBookingIntegration } from \"../../integrations-public\";\nimport { toE164 } from \"../../phone\";\nimport { peekSessionId } from \"../../session\";\nimport { uuidV4 } from \"../../uuid\";\nimport type { BookingMode, ConversionConfigStore } from \"../conversion-config\";\n\nexport { BOOKING_MODES, type BookingMode } from \"../conversion-config\";\nimport { AranovaApiError } from \"../http/errors\";\nimport { resolveTrackingConfigUrl, type TrackingConfigRuntime } from \"../tracking-config-runtime\";\nimport { salesRequest, type SalesTransportConfig } from \"./transport\";\n\n// The wire vocabulary; `bookings.test.tsx` pins each list to `contracts/tracking-bookings.json`.\nexport const BOOKING_RESPONSE_STATUSES = [\"created\", \"pending\", \"failed\"] as const;\n/** Developer-only outcomes for a simulated create; the production API ignores them. */\nexport const BOOKING_SIMULATIONS = [\"created\", \"rejected\", \"unknown\", \"failed\"] as const;\nexport const BOOKING_SIMULATE_HEADER = \"X-Aranova-Simulate\";\nexport const BOOKING_DROP_OFF_OPTIONS = [\"waitForIt\", \"dropOff\"] as const;\nexport const BOOKING_DETAILS_STATUSES = [\"updated\", \"queued\", \"notified_owner\"] as const;\nexport const BOOKING_ERROR_CODES = {\n unavailable: \"booking_unavailable\",\n stale: \"booking_config_stale\",\n rejected: \"booking_rejected\",\n attemptMismatch: \"booking_attempt_mismatch\",\n detailsConflict: \"idempotency_conflict\",\n} as const;\n\ntype BookingResponseStatus = (typeof BOOKING_RESPONSE_STATUSES)[number];\nexport type BookingDropOff = (typeof BOOKING_DROP_OFF_OPTIONS)[number];\nexport type BookingSimulation = (typeof BOOKING_SIMULATIONS)[number];\nexport type BookingDetailsStatus = (typeof BOOKING_DETAILS_STATUSES)[number];\n\n/** `POST /tracking/bookings` body; its keys are pinned against the contract by the test. */\nexport interface BookingRequestBody {\n idempotency_key: string;\n session_id: string;\n config_version: number;\n start_date: string;\n start_hour: number;\n first_name: string;\n last_name: string;\n phone: string;\n email: string | null;\n vehicle: { year: number; make: string; model: string } | null;\n drop_off: BookingDropOff | null;\n services: string[];\n notes: string | null;\n marketing_sms_consent: boolean;\n page_path: string | null;\n}\n\n/** `POST /tracking/bookings` and `GET /tracking/bookings/{id}` answer. */\nexport interface BookingResponseBody {\n id: string;\n status: BookingResponseStatus;\n request_code: string | null;\n fallback_url: string | null;\n /** Absent from an API older than this SDK: read as `live`. */\n mode?: BookingMode;\n}\n\n/** `POST /tracking/bookings/{id}/details` body; its keys are pinned against the contract. */\nexport interface BookingDetailsRequestBody {\n idempotency_key: string;\n vehicle: { year: number; make: string; model: string } | null;\n notes: string | null;\n heard_via: string | null;\n}\n\n/** `POST /tracking/bookings/{id}/details` answer. */\nexport interface BookingDetailsResponseBody {\n id: string;\n status: BookingDetailsStatus;\n}\n\n/** Added after the booking: the vehicle (when the rules defer it), notes, or both. */\nexport interface BookingDetailsInput {\n vehicle?: { year: number; make: string; model: string } | null;\n notes?: string | null;\n /**\n * The site's own \"How did you hear about us?\" answer, verbatim — the same values the form's\n * `form_submit` `heardVia` field carries, so one answer reports the same whether it is asked\n * on the form or on the success screen. Ours for reporting: never sent to the shop.\n */\n heardVia?: string | null;\n}\n\nexport interface BookingDetailsOptions {\n /** The `attemptKey` the booking was submitted with; only that submit may add to it. */\n attemptKey: string;\n}\n\nexport type BookingDetailsOutcome =\n /**\n * `updated`: on the shop's request. `queued`: still on its way. `notified_owner`: the shop's\n * request could no longer take them, so the owner was texted. All three mean \"saved\".\n */\n | { status: BookingDetailsStatus; id: string }\n /**\n * Not saved. `invalid`: a bad vehicle or note. `mismatch`: not this booking's attempt key.\n * `conflict`: other details were already saved. `not_found`: no such booking for this key.\n * `failed`: the network or the API failed; worth a retry.\n */\n | { status: BookingDetailsFailure; id: string; message: string };\n\nexport type BookingDetailsFailure = \"invalid\" | \"mismatch\" | \"conflict\" | \"not_found\" | \"failed\";\n\nfunction detailsFailure(error: unknown): BookingDetailsFailure {\n if (!(error instanceof AranovaApiError)) return \"failed\";\n if (error.code === BOOKING_ERROR_CODES.attemptMismatch) return \"mismatch\";\n if (error.code === BOOKING_ERROR_CODES.detailsConflict) return \"conflict\";\n if (error.status === 404) return \"not_found\";\n return error.status === 422 ? \"invalid\" : \"failed\";\n}\n\n/** What the booking form collected. Dates and hours are the shop's wall time. */\nexport interface BookingInput {\n /** `YYYY-MM-DD`, one of `bookableSlots(...)`'s dates. */\n startDate: string;\n /** A start hour offered on that date (8 = 08:00). */\n startHour: number;\n firstName: string;\n /** Required: the shop's system needs it, and a single-word name has none to split off. */\n lastName: string;\n /** Mobile number; the shop texts its confirmation to it. */\n phone: string;\n email?: string | null;\n vehicle?: { year: number; make: string; model: string } | null;\n /** Ask only when the rules list drop-off options. */\n dropOff?: BookingDropOff | null;\n /** Option keys from `useBookingIntegration(...).options`. */\n services: string[];\n notes?: string | null;\n /**\n * The customer's own opt-in to marketing texts. Never default it on (CASL). Service texts\n * about this booking need no opt-in and are always on.\n */\n marketingSmsConsent?: boolean;\n}\n\n/** The form's `form_submit`, sent once per booking attempt and tied to this visit. */\nexport interface BookingFormSubmit {\n /** The site's tracking client (`useTracking()`): the booking takes the visit it reports. */\n tracking: {\n trackEvent(eventType: \"form_submit\", metadata: FormSubmitMetadata): void;\n getSessionId(): string;\n };\n /** The `<form id>`, so `form_start` and `form_submit` line up. */\n formId: string;\n /**\n * The site's own questions (e.g. \"How did you hear about us?\"), appended to this\n * `form_submit` for our reporting; never sent to the shop. A name the booking already\n * reports (`phone`, `email`, ...) is dropped, so lead matching cannot be overridden.\n */\n extraFields?: FormSubmitField[];\n}\n\n/** One field of a `form_submit`. */\nexport type FormSubmitField = NonNullable<FormSubmitMetadata[\"form\"][\"fields\"]>[number];\n\nexport type BookingOutcome =\n /** The shop has the request; it texts and emails the customer itself. */\n | { status: \"created\"; id: string; requestCode: string | null; mode: BookingMode }\n /** Received and still being sent; the shop confirms. Show the same success. */\n | { status: \"pending\"; id: string; mode: BookingMode }\n /**\n * Not created. Still a success for the customer: show the prefilled shop booking link.\n * `id` is null when the booking never reached Aranova (offline, rate-limited, booking off).\n */\n | { status: \"failed\"; id: string | null; fallbackUrl: string | null; mode: BookingMode }\n /** The booking breaks the shop's rules; `message` is customer-safe. Nothing was sent. */\n | { status: \"invalid\"; message: string; reasons: string[] }\n /** The shop's times changed since the form loaded; the config is being refetched. */\n | { status: \"stale\"; message: string };\n\nexport interface BookingSubmitOptions {\n attemptKey: string;\n formSubmit?: BookingFormSubmit;\n /**\n * Development only: force a simulated create's outcome to exercise the success, pending and\n * fallback screens. The production API ignores it, so it can never change a real booking.\n */\n simulate?: BookingSimulation;\n /**\n * Called once when the server answers `pending`, before `submit` polls for the final answer\n * (up to ~30 s): show the pending success right away instead of a spinner.\n */\n onPending?: (outcome: Extract<BookingOutcome, { status: \"pending\" }>) => void;\n}\n\nexport interface SalesBookingsClient {\n /**\n * Send one booking. Never rejects for an API outcome: every answer is a {@link BookingOutcome}.\n * Pass the same `attemptKey` (a UUID, `newBookingAttemptKey()`) on a retry of the same submit,\n * so a double-click or a network retry can never book twice.\n */\n submit(input: BookingInput, options: BookingSubmitOptions): Promise<BookingOutcome>;\n /** A booking's current status by the id `submit` returned. */\n status(id: string): Promise<BookingOutcome>;\n /**\n * Add the vehicle, notes or \"How did you hear about us?\" after booking (e.g.\n * `rules.vehicleDeferred`, or an answer asked on the success screen). Pass the booking's own\n * `attemptKey`. Never rejects: they reach the shop's request, or the owner by text.\n */\n updateBooking(\n bookingId: string,\n details: BookingDetailsInput,\n options: BookingDetailsOptions,\n ): Promise<BookingDetailsOutcome>;\n}\n\nconst POLL_DELAYS_MS = [1000, 2000, 4000, 8000, 15000];\nconst INVALID_DETAILS = \"Please check your details and try again.\";\n\n/** A fresh per-submit key. Keep it for retries of the same submit; mint a new one after. */\nexport const newBookingAttemptKey = uuidV4;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\nfunction outcomeOf(body: BookingResponseBody): BookingOutcome {\n const mode = body.mode ?? \"live\";\n if (body.status === \"created\")\n return { status: \"created\", id: body.id, requestCode: body.request_code, mode };\n if (body.status === \"pending\") return { status: \"pending\", id: body.id, mode };\n return { status: \"failed\", id: body.id, fallbackUrl: body.fallback_url, mode };\n}\n\nfunction formSubmitPayload(\n input: BookingInput,\n formSubmit: BookingFormSubmit,\n labels: Map<string, string>,\n): FormSubmitMetadata {\n const vehicle = input.vehicle\n ? `${input.vehicle.year} ${input.vehicle.make} ${input.vehicle.model}`\n : \"\";\n const field = (name: string, type: string, label: string, value: string) => ({\n name,\n type,\n label,\n value,\n });\n const fields = [\n field(\"first_name\", \"text\", \"First name\", input.firstName),\n field(\"last_name\", \"text\", \"Last name\", input.lastName),\n // E.164 on the wire: it keys the customer and enhanced-conversion matching.\n field(\"phone\", \"tel\", \"Phone\", toE164(input.phone) ?? input.phone),\n field(\"email\", \"email\", \"Email\", input.email ?? \"\"),\n field(\n \"service\",\n \"select\",\n \"Service\",\n input.services.map((key) => labels.get(key) ?? key).join(\", \"),\n ),\n field(\"vehicle\", \"text\", \"Vehicle\", vehicle),\n field(\n \"start\",\n \"text\",\n \"Requested time\",\n `${input.startDate} ${String(input.startHour).padStart(2, \"0\")}:00`,\n ),\n field(\"note\", \"textarea\", \"Notes\", input.notes ?? \"\"),\n ];\n const reported = new Set(fields.map((item) => item.name));\n const extra = (formSubmit.extraFields ?? []).filter((item) => !reported.has(item.name));\n return {\n form: { id: formSubmit.formId, action: \"/tracking/bookings\", fields: [...fields, ...extra] },\n page: { path: typeof window === \"undefined\" ? \"/\" : window.location.pathname },\n };\n}\n\ntype Firing = Pick<ConversionConfigStore, \"getFiring\"> | TrackingConfigRuntime | undefined;\n\n/** The runtime config the sales client was given, when it is the live runtime. */\nfunction runtimeOf(firing: Firing): TrackingConfigRuntime | null {\n return firing && \"integrations\" in firing ? firing : null;\n}\n\nfunction isLocalUrl(url: string): boolean {\n try {\n return [\"localhost\", \"127.0.0.1\", \"[::1]\"].includes(new URL(url).hostname);\n } catch {\n return false;\n }\n}\n\n/**\n * A local API with the production CDN books against prod's config_version and slots: the\n * local API refuses it as stale or unavailable. Config and API must come from one backend.\n */\nexport function bookingSetupMismatch(\n endpoint: string,\n runtime: Pick<TrackingConfigRuntime, \"ref\"> | null,\n): string | null {\n if (!runtime || !isLocalUrl(endpoint)) return null;\n const configUrl = resolveTrackingConfigUrl(runtime.ref);\n if (isLocalUrl(configUrl)) return null;\n return (\n `[aranova] Bookings go to the local API (${endpoint}) but the runtime config comes from ` +\n `${configUrl}. Set NEXT_PUBLIC_ARANOVA_CDN_BASE_URL to the local bucket ` +\n \"(http://localhost:9100/aranova-demos) so both come from one backend.\"\n );\n}\n\nexport function createSalesBookingsClient(\n config: SalesTransportConfig,\n firing: Firing,\n): SalesBookingsClient {\n const submittedForms = new Set<string>();\n const staleAttempts = new Set<string>();\n let isSetupChecked = false;\n\n function warnOnSetupMismatch(runtime: TrackingConfigRuntime | null): void {\n if (isSetupChecked) return;\n isSetupChecked = true;\n const warning = bookingSetupMismatch(config.endpoint, runtime);\n if (warning) console.warn(warning);\n }\n\n function handoff(input: BookingInput): string | null {\n const booking = getBookingIntegration(runtimeOf(firing)?.integrations());\n if (!booking) return null;\n return buildBookingHandoffUrl(booking.fallbackUrl, {\n firstName: input.firstName,\n lastName: input.lastName,\n phone: input.phone,\n email: input.email,\n });\n }\n\n /** Never reached Aranova: the mode is the one the runtime config announced. */\n function failedBeforeSend(input: BookingInput): BookingOutcome {\n const mode = getBookingIntegration(runtimeOf(firing)?.integrations())?.mode ?? \"live\";\n return { status: \"failed\", id: null, fallbackUrl: handoff(input), mode };\n }\n\n function withHandoff(outcome: BookingOutcome, input: BookingInput): BookingOutcome {\n if (outcome.status === \"failed\" && !outcome.fallbackUrl) {\n return { ...outcome, fallbackUrl: handoff(input) };\n }\n return outcome;\n }\n\n async function poll(id: string, mode: BookingMode): Promise<BookingOutcome> {\n let outcome: BookingOutcome = { status: \"pending\", id, mode };\n for (const delay of POLL_DELAYS_MS) {\n await sleep(delay);\n try {\n const path = `/bookings/${encodeURIComponent(id)}`;\n outcome = outcomeOf(await salesRequest<BookingResponseBody>(config, \"GET\", path));\n } catch {\n continue;\n }\n if (outcome.status !== \"pending\") return outcome;\n }\n // Still being sent after ~30 s: the workflow keeps going and alerts the owner if it fails.\n return outcome;\n }\n\n async function rejected(\n error: unknown,\n input: BookingInput,\n attemptKey: string,\n runtime: TrackingConfigRuntime | null,\n ): Promise<BookingOutcome> {\n if (!(error instanceof AranovaApiError)) {\n return failedBeforeSend(input);\n }\n if (error.code === BOOKING_ERROR_CODES.stale) {\n // A second stale answer means the published config has not caught up: hand off instead.\n if (staleAttempts.has(attemptKey)) return failedBeforeSend(input);\n staleAttempts.add(attemptKey);\n await runtime?.revalidate();\n return { status: \"stale\", message: error.message };\n }\n if (error.code === BOOKING_ERROR_CODES.rejected) {\n const reasons = error.context?.reasons;\n return {\n status: \"invalid\",\n message: error.message,\n reasons: Array.isArray(reasons) ? reasons.map(String) : [],\n };\n }\n if (error.code === \"validation_error\") {\n return { status: \"invalid\", message: INVALID_DETAILS, reasons: [] };\n }\n // Unavailable, rate-limited, offline or a server fault: the shop's own page still works.\n return failedBeforeSend(input);\n }\n\n return {\n async submit(input, { attemptKey, formSubmit, simulate, onPending }) {\n registerCapability(\"booking_integration\");\n const runtime = runtimeOf(firing);\n warnOnSetupMismatch(runtime);\n const booking = getBookingIntegration(runtime?.integrations());\n if (!booking?.acceptsBookings || booking.configVersion === null) {\n return failedBeforeSend(input);\n }\n if (formSubmit && !submittedForms.has(attemptKey)) {\n submittedForms.add(attemptKey);\n const labels = new Map(booking.options.map((option) => [option.key, option.label]));\n formSubmit.tracking.trackEvent(\"form_submit\", formSubmitPayload(input, formSubmit, labels));\n }\n const body: BookingRequestBody = {\n idempotency_key: attemptKey,\n // The tracking client's own visit, so the booking and its form_submit share one session.\n session_id:\n formSubmit?.tracking.getSessionId() ?? peekSessionId() ?? `no-session-${attemptKey}`,\n config_version: booking.configVersion,\n start_date: input.startDate,\n start_hour: input.startHour,\n first_name: input.firstName.trim(),\n last_name: input.lastName.trim(),\n phone: input.phone,\n email: input.email?.trim() || null,\n vehicle: input.vehicle ?? null,\n drop_off: input.dropOff ?? null,\n services: input.services,\n notes: input.notes?.trim() || null,\n marketing_sms_consent: input.marketingSmsConsent === true,\n page_path: typeof window === \"undefined\" ? null : window.location.pathname,\n };\n let response: BookingResponseBody;\n try {\n const headers = simulate ? { [BOOKING_SIMULATE_HEADER]: simulate } : undefined;\n response = await salesRequest<BookingResponseBody>(\n config,\n \"POST\",\n \"/bookings\",\n body,\n headers,\n );\n } catch (error) {\n return rejected(error, input, attemptKey, runtime);\n }\n const outcome = outcomeOf(response);\n if (outcome.status !== \"pending\") return withHandoff(outcome, input);\n onPending?.(outcome);\n return withHandoff(await poll(outcome.id, outcome.mode), input);\n },\n\n async updateBooking(bookingId, details, { attemptKey }) {\n const body: BookingDetailsRequestBody = {\n idempotency_key: attemptKey,\n vehicle: details.vehicle ?? null,\n notes: details.notes?.trim() || null,\n heard_via: details.heardVia?.trim() || null,\n };\n try {\n const response = await salesRequest<BookingDetailsResponseBody>(\n config,\n \"POST\",\n `/bookings/${encodeURIComponent(bookingId)}/details`,\n body,\n );\n return { status: response.status, id: response.id };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return { status: detailsFailure(error), id: bookingId, message };\n }\n },\n\n async status(id) {\n return outcomeOf(\n await salesRequest<BookingResponseBody>(\n config,\n \"GET\",\n `/bookings/${encodeURIComponent(id)}`,\n ),\n );\n },\n };\n}\n","import type {\n BusinessConfig,\n CustomerGetOptions,\n CustomerGetResult,\n CustomerKpis,\n CustomerListPage,\n CustomerListQuery,\n CustomerSummaryQuery,\n Sale,\n SaleCursorPage,\n SaleInput,\n SaleListQueryV2,\n SaleSummaryQueryV2,\n SaleSummaryV2,\n SaleUpdateInput,\n SupportedCurrency,\n} from \"./schema\";\nimport { registerCapability } from \"../../capabilities\";\nimport type { ConversionConfigStore } from \"../conversion-config\";\nimport { fireConversionWithConsent } from \"../conversion-firing\";\nimport type { TrackingConfigRuntime } from \"../tracking-config-runtime\";\nimport { getConsentState } from \"../../consent\";\nimport { stashUserData, type ConversionUserData } from \"../../user-data\";\nimport { createSalesBookingsClient, type SalesBookingsClient } from \"./bookings\";\nimport { salesRequest, type SalesTransportConfig } from \"./transport\";\nimport { fromMinor } from \"./money\";\n\n/** Config for {@link createSalesClient}. */\nexport interface SalesClientConfig extends SalesTransportConfig {\n /** Applied when an individual `record()` call omits `currency`. */\n defaultCurrency?: SupportedCurrency;\n /**\n * GAP28: when present, `record()` ALSO fires a real-time on-site conversion\n * (`gtag('event','conversion')`) for any recorded service that has a `firing` send_to in\n * the resolved config. Consent-gated + de-duped; no-ops server-side. Wire it from\n * `resolveConversionConfig(...)`.\n */\n firing?: Pick<ConversionConfigStore, \"getFiring\"> | TrackingConfigRuntime;\n}\n\n// Fire the on-site conversion(s) for a just-recorded sale. Per-service value = the sale's\n// amount when present, else the action's configured default from config (Google applies\n// its own default when neither is sent). transaction_id ties the gtag fire to the sale so\n// retries/reloads de-dup within the WEBPAGE action.\nfunction fireRecordedConversions(\n firing: Pick<ConversionConfigStore, \"getFiring\"> | TrackingConfigRuntime | undefined,\n input: {\n external_id?: string | null;\n amount_total_cents?: number | null;\n customer_email?: string | null;\n customer_phone?: string | null;\n },\n recorded: { service?: string | null; amount_cents: number | null }[],\n sale: Sale,\n currency: SupportedCurrency,\n): void {\n if (!firing) return;\n // Enhanced conversions: the sale's own customer identifiers are the\n // strongest possible user_data signal — pass them into every per-service\n // fire, and refresh the stash so later same-page event-goal fires match too.\n const userData: ConversionUserData | null =\n input.customer_email || input.customer_phone\n ? { email: input.customer_email ?? null, phone: input.customer_phone ?? null }\n : null;\n // Denied consent: the fires below no-op anyway, so don't retain the\n // identifiers in page memory either.\n if (userData && getConsentState() !== \"denied\") stashUserData(userData);\n const txnBase = input.external_id ?? sale.id;\n for (const item of recorded) {\n if (!item.service) continue;\n if (\"fireConversion\" in firing) {\n firing.fireConversion(item.service, {\n value: item.amount_cents != null ? fromMinor(item.amount_cents, currency) : undefined,\n currency,\n transactionId: `${txnBase}:${item.service}`,\n });\n continue;\n }\n const config = firing.getFiring(item.service);\n if (!config) continue;\n const cents = item.amount_cents ?? config.value_cents ?? null;\n fireConversionWithConsent(\n {\n sendTo: config.send_to,\n value: cents != null ? fromMinor(cents, currency) : null,\n currency: config.currency ?? currency,\n transactionId: `${txnBase}:${item.service}`,\n },\n { userData },\n );\n }\n}\n\n/** Phone-keyed customer rollups (sk-only; a public key gets a `403`). */\nexport interface SalesCustomersClient {\n list(query?: CustomerListQuery): Promise<CustomerListPage>;\n /** `id` is the customer's E.164 phone. */\n get(id: string, options?: CustomerGetOptions): Promise<CustomerGetResult>;\n summary(query?: CustomerSummaryQuery): Promise<CustomerKpis>;\n}\n\n/** Business config (low-sensitivity — accepts a public or secret key). */\nexport interface SalesBusinessClient {\n config(): Promise<BusinessConfig>;\n}\n\n/**\n * One isomorphic sales client — what a key may *do* is enforced by the backend,\n * not by hiding methods. A **public** key (`aranv_pk_…`) may `record` (the\n * backend rejects reads/CRUD from it with a `403`); a **secret** key\n * (`aranv_sk_…`), used **server-side only**, gets full read/list/update/delete.\n * Never ship a secret key in a browser bundle.\n *\n * Generic over the service-key union `TService` and the manual-conversion key union\n * `TConversion`: bind the types emitted by `@aranova/tracking-cli gen` (`AranovaService`\n * and `AranovaConversion`) for compile-time-checked `service` / `trackConversion` keys.\n */\nexport interface SalesClient<\n TService extends string = string,\n TConversion extends string = string,\n> {\n record(\n input: Omit<SaleInput, \"currency\" | \"occurred_at\" | \"service\" | \"services\"> & {\n service?: TService | null;\n // XOR with `service`: record multiple services in one sale, each priced\n // individually. `amount_total_cents` is then optional (the backend derives\n // it as the sum). Keys are checked against the codegen `TService` union.\n services?: Array<{ service: TService; amount_cents: number }>;\n currency?: SupportedCurrency;\n occurred_at?: string;\n },\n ): Promise<Sale>;\n /**\n * Record a revenue sale — the intent-revealing alias of {@link record}. Identical\n * behavior (same function reference): POSTs `/sales` and fires the on-site conversion\n * when the sale-goal is WEBPAGE-mapped. Use either for real revenue; use\n * {@link trackConversion} for a non-revenue on-page event.\n */\n recordSale(\n input: Omit<SaleInput, \"currency\" | \"occurred_at\" | \"service\" | \"services\"> & {\n service?: TService | null;\n services?: Array<{ service: TService; amount_cents: number }>;\n currency?: SupportedCurrency;\n occurred_at?: string;\n },\n ): Promise<Sale>;\n /**\n * Fire ONLY the on-site conversion for an event-goal `key` — for MANUAL event-goals\n * (`form_submit`, `phone_click`, `cta_click`) the consumer triggers explicitly. No `/sales`\n * write. No-ops when the goal isn't WEBPAGE-mapped (no resolved `send_to`) or the config\n * isn't wired. Consent-gated + de-duped. (Automatic event-goals fire themselves — no call.)\n */\n trackConversion(\n key: TConversion,\n options?: {\n transactionId?: string | null;\n value?: number | null;\n currency?: SupportedCurrency;\n /**\n * Enhanced conversions override: the visitor's own email/phone for this\n * conversion. Wins per-key over the automatic form-submit stash — the\n * escape hatch when a form's fields defeat the extraction heuristics.\n */\n userData?: ConversionUserData | null;\n },\n ): void;\n /** Keyset list with optional server sort + opt-in `total_count`. */\n list(query?: SaleListQueryV2): Promise<SaleCursorPage>;\n /**\n * Currency-grouped aggregations for the key's business. Additive v2 options:\n * calendar/custom ranges, IANA `timezone`, `granularity`, and `compare_to`.\n * Legacy `24h/7d/30d` keep their exact prior numbers. Secret key only.\n */\n summary(query: SaleSummaryQueryV2): Promise<SaleSummaryV2>;\n get(id: string): Promise<Sale>;\n update(\n id: string,\n patch: Omit<SaleUpdateInput, \"service\" | \"services\"> & {\n service?: TService | null;\n // Present ⇒ replaces the whole service set (XOR with `service`); the\n // backend recomputes `amount_total_cents` from the sum.\n services?: Array<{ service: TService; amount_cents: number }>;\n },\n ): Promise<Sale>;\n delete(id: string): Promise<void>;\n /** Phone-keyed customer rollups (sk-only). */\n customers: SalesCustomersClient;\n /** Business config (pk or sk). */\n business: SalesBusinessClient;\n /**\n * Website bookings into the shop's own system (public key, browser only). Reads the\n * booking rules from `firing` when it is the shared tracking-config runtime.\n */\n bookings: SalesBookingsClient;\n}\n\nexport function createSalesClient<\n TService extends string = string,\n TConversion extends string = string,\n>(config: SalesClientConfig): SalesClient<TService, TConversion> {\n // `firing` is what makes trackConversion do anything at all — without it the\n // method returns immediately, so its presence IS the manual-goal capability.\n // The sales LEDGER verbs are not reported: they are moving to the internal\n // app and are not a client-site capability.\n if (config.firing) registerCapability(\"conversion_goals_manual\");\n\n type RecordInput = Parameters<SalesClient<TService, TConversion>[\"record\"]>[0];\n\n async function record(input: RecordInput): Promise<Sale> {\n const currency = input.currency ?? config.defaultCurrency;\n if (!currency) {\n throw new Error(\n \"record: `currency` is required (pass it on the sale or set config.defaultCurrency)\",\n );\n }\n const body: SaleInput = {\n ...input,\n currency,\n occurred_at: input.occurred_at ?? new Date().toISOString(),\n };\n const sale = await salesRequest<Sale>(config, \"POST\", \"/sales\", body);\n // GAP28: fire the real-time on-site conversion(s) AFTER the sale is recorded, so the\n // gtag transaction_id and the offline path agree. Never throws (best-effort fire).\n const recorded = input.services?.length\n ? input.services.map((s) => ({\n service: s.service,\n amount_cents: s.amount_cents,\n }))\n : [\n {\n service: input.service,\n amount_cents: input.amount_total_cents ?? null,\n },\n ];\n fireRecordedConversions(config.firing, input, recorded, sale, currency);\n return sale;\n }\n\n return {\n record,\n // recordSale is the intent-revealing alias — same behavior, clearer call site.\n recordSale: record,\n\n trackConversion(key, options) {\n // Manual event-goal: fire the WEBPAGE conversion only (no /sales). No-op when the goal\n // isn't WEBPAGE-mapped or the config isn't wired.\n if (config.firing && \"fireConversion\" in config.firing) {\n // Enhanced conversions on the runtime path: the drain fires gtag\n // itself, reading user_data from the stash — persist the explicit\n // override there so it isn't lost to the queue.\n if (options?.userData && getConsentState() !== \"denied\") stashUserData(options.userData);\n config.firing.fireConversion(key, {\n value: options?.value ?? undefined,\n currency: options?.currency ?? config.defaultCurrency ?? undefined,\n transactionId: options?.transactionId ?? null,\n });\n return;\n }\n const firing = config.firing?.getFiring(key);\n if (!firing) return;\n const currency = firing.currency ?? options?.currency ?? config.defaultCurrency ?? null;\n const cents = firing.value_cents ?? null;\n const value =\n options?.value ??\n (cents != null && currency ? fromMinor(cents, currency as SupportedCurrency) : null);\n fireConversionWithConsent(\n {\n sendTo: firing.send_to,\n value,\n currency,\n transactionId: options?.transactionId ?? null,\n },\n { userData: options?.userData },\n );\n },\n\n async list(query) {\n // Pagination + sort travel at the top level; everything else is a filter.\n const { cursor, limit, sort, order, want_total, ...filters } = query ?? {};\n return salesRequest<SaleCursorPage>(config, \"POST\", \"/sales/query\", {\n filters,\n ...(limit !== undefined ? { limit } : {}),\n ...(cursor !== undefined ? { cursor } : {}),\n ...(sort !== undefined ? { sort } : {}),\n ...(order !== undefined ? { order } : {}),\n ...(want_total !== undefined ? { want_total } : {}),\n });\n },\n\n async summary(query) {\n const {\n range,\n include_categories,\n include_deleted_services,\n top_n,\n since,\n until,\n timezone,\n granularity,\n compare_to,\n ...filters\n } = query;\n return salesRequest<SaleSummaryV2>(config, \"POST\", \"/sales/summary\", {\n filters,\n ...(range !== undefined ? { range } : {}),\n ...(include_categories !== undefined ? { include_categories } : {}),\n ...(include_deleted_services !== undefined ? { include_deleted_services } : {}),\n ...(top_n !== undefined ? { top_n } : {}),\n ...(since !== undefined ? { since } : {}),\n ...(until !== undefined ? { until } : {}),\n ...(timezone !== undefined ? { timezone } : {}),\n ...(granularity !== undefined ? { granularity } : {}),\n ...(compare_to !== undefined ? { compare_to } : {}),\n });\n },\n\n async get(id) {\n return salesRequest<Sale>(config, \"GET\", `/sales/${id}`);\n },\n\n async update(id, patch) {\n return salesRequest<Sale>(config, \"PATCH\", `/sales/${id}`, patch);\n },\n\n async delete(id) {\n await salesRequest<void>(config, \"DELETE\", `/sales/${id}`);\n },\n\n customers: {\n async list(query) {\n const { segment, sort, order, cursor, limit, want_total, ...filters } = query ?? {};\n return salesRequest<CustomerListPage>(config, \"POST\", \"/customers/query\", {\n filters,\n ...(segment !== undefined ? { segment } : {}),\n ...(sort !== undefined ? { sort } : {}),\n ...(order !== undefined ? { order } : {}),\n ...(cursor !== undefined ? { cursor } : {}),\n ...(limit !== undefined ? { limit } : {}),\n ...(want_total !== undefined ? { want_total } : {}),\n });\n },\n async get(id, options) {\n const params = new URLSearchParams();\n if (options?.include_sales !== undefined)\n params.set(\"include_sales\", String(options.include_sales));\n if (options?.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options?.cursor != null) params.set(\"cursor\", options.cursor);\n const qs = params.toString();\n // `id` is the E.164 phone (`+1…`) — must be URL-encoded.\n return salesRequest<CustomerGetResult>(\n config,\n \"GET\",\n `/customers/${encodeURIComponent(id)}${qs ? `?${qs}` : \"\"}`,\n );\n },\n async summary(query) {\n const { range, since, until, timezone, compare_to, ...filters } = query ?? {};\n return salesRequest<CustomerKpis>(config, \"POST\", \"/customers/summary\", {\n filters,\n ...(range !== undefined ? { range } : {}),\n ...(since !== undefined ? { since } : {}),\n ...(until !== undefined ? { until } : {}),\n ...(timezone !== undefined ? { timezone } : {}),\n ...(compare_to !== undefined ? { compare_to } : {}),\n });\n },\n },\n\n business: {\n async config() {\n return salesRequest<BusinessConfig>(config, \"GET\", \"/business/config\");\n },\n },\n\n bookings: createSalesBookingsClient(config, config.firing),\n };\n}\n","import * as z from \"zod/mini\";\n\nimport {\n zSaleCreateSchema,\n zSaleItemInputSchema,\n zSaleServiceInputSchema,\n zSaleSource,\n zSupportedCurrency,\n} from \"./generated/zod.gen\";\n\n/**\n * Sales / Conversions wire schemas.\n *\n * The object shapes come from the backend's own spec — see\n * `../generated/zod.gen.ts` and `packages/tracking-core/openapi-ts.config.ts`.\n * This file adds only what JSON Schema cannot express: the `service`/`services`\n * XOR, and defaults Pydantic omits from the spec because `default_factory` is\n * not serialized (`items: []`) — composed onto the generated base, never\n * duplicating its shape.\n *\n * `saleUpdateSchema` stays hand-written: its non-nullable-on-update rule is a\n * backend `model_validator`, invisible to JSON Schema, so the generated shape\n * would accept `null` where the server returns 422.\n */\n\nexport const saleItemSchema = zSaleItemInputSchema;\nexport const saleServiceSchema = zSaleServiceInputSchema;\nexport const currencySchema = zSupportedCurrency;\n\nexport const SUPPORTED_CURRENCIES = currencySchema.options;\nexport type SupportedCurrency = (typeof SUPPORTED_CURRENCIES)[number];\n\nexport const SALE_SOURCES = zSaleSource.options;\nexport type SaleSource = (typeof SALE_SOURCES)[number];\n\nconst TRACKING_ENVIRONMENTS = [\"production\", \"development\"] as const;\n\nconst centsSchema = z.int().check(z.gte(0));\n// Free-form JSON object — the sale's extensibility escape hatch. Mirrors the\n// Pydantic `dict[str, Any]` (`additionalProperties: true`) on the wire.\nconst metadataSchema = z.record(z.string(), z.unknown());\n\n// Raw customer identity. Stored as-is on the backend for human display in\n// dashboards; hashing happens at writeback-upload time in the future Google\n// Ads reconciliation worker (see docs/tracking-package/conversion-writeback.md).\n// The SDK never hashes — pass values straight through.\nconst customerNameSchema = z.string().check(z.maxLength(200));\nconst customerPhoneSchema = z.string().check(z.maxLength(64));\nconst customerEmailSchema = z.email().check(z.maxLength(320));\n\n// The singular `service` (priced by `amount_total_cents`) and the plural\n// `services` (each priced individually) are mutually exclusive. When `services`\n// is present, `amount_total_cents` is optional and the backend derives it as the\n// sum; otherwise it is required. Shared by create + update.\n// Mini reports issues by pushing onto the payload rather than via a\n// `RefinementCtx` helper; `path` still scopes the message to a field.\ntype MiniCheckCtx = { value: unknown; issues: Array<Record<string, unknown>> };\n\nfunction addIssue(ctx: MiniCheckCtx, path: string[], message: string): void {\n ctx.issues.push({ code: \"custom\", input: ctx.value, path, message });\n}\n\nfunction refineServiceXor(\n val: {\n service?: string | null;\n services?: ReadonlyArray<{ service: string; amount_cents: number }> | null;\n amount_total_cents?: number | null;\n },\n ctx: MiniCheckCtx,\n { requireAmount }: { requireAmount: boolean },\n): void {\n if (val.services != null) {\n if (val.service != null) {\n addIssue(ctx, [\"services\"], \"pass either `service` or `services`, not both\");\n }\n if (val.services.length === 0) {\n addIssue(ctx, [\"services\"], \"`services` must not be empty\");\n }\n const keys = val.services.map((s) => s.service);\n if (new Set(keys).size !== keys.length) {\n addIssue(ctx, [\"services\"], \"`services` must not list the same service more than once\");\n }\n if (val.amount_total_cents != null) {\n const sum = val.services.reduce((acc, s) => acc + s.amount_cents, 0);\n if (val.amount_total_cents !== sum) {\n addIssue(\n ctx,\n [\"amount_total_cents\"],\n \"amount_total_cents must equal the sum of the services amounts \" +\n \"(omit it to derive it automatically)\",\n );\n }\n }\n } else if (requireAmount && val.amount_total_cents == null) {\n addIssue(\n ctx,\n [\"amount_total_cents\"],\n \"amount_total_cents is required unless `services` is provided\",\n );\n }\n}\n\n// `items` has a real (mutable) default server-side, but Pydantic doesn't\n// serialize a `default_factory=list` into the JSON Schema `default` keyword —\n// generation has no way to see it, so it's restored here, on the one field\n// where the spec is silent. Every other field's shape and default come\n// straight from `zSaleCreateSchema`.\nexport const saleCreateSchema = z\n .extend(zSaleCreateSchema, { items: z._default(z.array(saleItemSchema), []) })\n .check((ctx) => refineServiceXor(ctx.value, ctx, { requireAmount: true }));\n\nexport const saleUpdateSchema = z\n .strictObject({\n description: z.nullish(z.string()),\n service: z.nullish(z.string()),\n services: z.nullish(z.array(saleServiceSchema)),\n currency: z.optional(currencySchema),\n amount_total_cents: z.optional(centsSchema),\n occurred_at: z.optional(z.iso.datetime()),\n items: z.optional(z.array(saleItemSchema)),\n metadata: z.nullish(metadataSchema),\n customer_name: z.nullish(customerNameSchema),\n // Re-attest when changing a filler-looking name; the server clears the\n // prior attestation whenever `customer_name` changes.\n customer_name_placeholder_confirmed: z.optional(z.boolean()),\n customer_phone: z.nullish(customerPhoneSchema),\n customer_email: z.nullish(customerEmailSchema),\n // NOT NULL server-side: omit to leave unchanged (explicit null is rejected).\n sms_consent: z.optional(z.boolean()),\n })\n .check((ctx) => refineServiceXor(ctx.value, ctx, { requireAmount: false }));\n\nexport type SaleItemInput = z.input<typeof saleItemSchema>;\nexport type SaleServiceInput = z.input<typeof saleServiceSchema>;\n// Use the INPUT type so fields with Zod defaults (environment, items) and\n// occurred_at are optional for callers — the sales client fills them in.\nexport type SaleInput = z.input<typeof saleCreateSchema>;\nexport type SaleUpdateInput = z.input<typeof saleUpdateSchema>;\n\n// ---------------------------------------------------------------------------\n// Read shapes (server responses; not validated on the client)\n// ---------------------------------------------------------------------------\n\nexport interface SaleItem {\n id: string;\n external_item_id: string | null;\n name: string | null;\n category: string | null;\n quantity: string;\n unit_price_cents: number;\n unit_cost_cents: number | null;\n}\n\nexport interface SaleSourceFacts {\n invoice_id: string;\n /** The shop system's repair-order number. */\n invoice_code: string;\n first_order: boolean;\n fleet_customer: boolean;\n}\n\nexport interface SaleService {\n id: string;\n service_id: string;\n service_key: string;\n service_label: string;\n amount_cents: number;\n}\n\nexport interface Sale {\n id: string;\n business_id: string;\n business_name?: string | null;\n external_id: string | null;\n currency: SupportedCurrency;\n amount_total_cents: number;\n description: string | null;\n // Singular service fields are populated only for single-service sales (kept\n // for backwards compatibility); `services` is always the full set.\n service_id: string | null;\n service_key?: string | null;\n service_label?: string | null;\n services: SaleService[];\n occurred_at: string;\n environment: (typeof TRACKING_ENVIRONMENTS)[number];\n /** Which path created the sale: `api`, `admin` (dashboard), or a shop-system sync (`autoleap`). Server-set. */\n source: SaleSource;\n /** What the shop-system sync recorded about the invoice; `null` unless `source` is `autoleap`. Server-set. */\n source_facts: SaleSourceFacts | null;\n metadata: Record<string, unknown> | null;\n customer_name: string | null;\n /** Operator attestation that a filler-looking `customer_name` is genuine. */\n customer_name_placeholder_confirmed: boolean;\n /** Server timestamp for when the name attestation was recorded. */\n customer_name_placeholder_confirmed_at: string | null;\n customer_phone: string | null;\n customer_email: string | null;\n /** CASL consent attestation stored on the sale; SMS send paths gate on it server-side. */\n sms_consent: boolean;\n /** Server timestamp for when the consent attestation was recorded. */\n sms_consent_granted_at: string | null;\n created_at: string;\n updated_at: string;\n items: SaleItem[];\n}\n\n/**\n * The offset page shape returned by the internal admin list, which this client\n * never calls (`list()` is keyset and returns {@link SaleCursorPage}).\n *\n * @deprecated Unused by this client — no verb returns or accepts it. Kept for one release; removed in the next major.\n */\nexport interface SaleListPage {\n items: Sale[];\n total: number;\n}\n\nexport interface SaleCursorPage {\n items: Sale[];\n next_cursor: string | null;\n /** Populated only when `want_total` was requested (a single indexed COUNT). */\n total_count?: number;\n has_more?: boolean;\n}\n\n/**\n * Sortable columns on the internal admin (offset) list. `list()` is typed to\n * {@link SaleKeysetSortField}, which omits `customer_name` and `business_name`.\n *\n * @deprecated Unused by this client — no verb returns or accepts it. Kept for one release; removed in the next major.\n */\nexport type SaleSortField =\n | \"occurred_at\"\n | \"created_at\"\n | \"amount_total_cents\"\n | \"customer_name\"\n | \"business_name\";\n\nexport type SaleSortOrder = \"asc\" | \"desc\";\n\n/**\n * Comprehensive filter shape mirrored from the backend's `SaleQueryFilters`.\n *\n * `search` runs case-insensitively across `customer_name`, `customer_phone`,\n * `customer_email`, `description`, and `external_id` — the human-facing\n * columns. `service_id` is the resolved per-business service UUID (different\n * from the create-time `service` *key*).\n */\nexport interface SaleFilters {\n business_id?: string;\n external_id?: string;\n service_id?: string;\n /** E.164 phone — the canonical customer key; filters sales for one customer. */\n customer_phone?: string;\n currency?: SupportedCurrency;\n environment?: (typeof TRACKING_ENVIRONMENTS)[number];\n since?: string;\n until?: string;\n min_amount_cents?: number;\n max_amount_cents?: number;\n search?: string;\n}\n\n/**\n * Full query input for `SalesClient.list()` — filters + pagination.\n *\n * Pagination is **keyset (cursor)**: `next_cursor` returned by one page is\n * passed back as `cursor` on the next. `null` / undefined cursor = first page.\n *\n * Ordering is selectable via `sort`/`order` on {@link SaleListQueryV2}. A cursor\n * encodes a position under the sort it was minted with, so replaying it under a\n * different sort is rejected (422) — page with the sort you started with.\n */\n/**\n * @deprecated Superseded by {@link SaleListQueryV2}, which is what `list()` accepts.\n * Its ordering note was also stale — `sort`/`order` are supported. Removed in the next major.\n */\nexport interface SaleListQuery extends SaleFilters {\n limit?: number;\n cursor?: string | null;\n}\n\n/** Keyset-safe sort columns (NOT-NULL, indexed). */\nexport type SaleKeysetSortField = \"occurred_at\" | \"created_at\" | \"amount_total_cents\";\n\n/** v2 list query — adds server sort + opt-in total. */\nexport interface SaleListQueryV2 extends SaleListQuery {\n sort?: SaleKeysetSortField;\n order?: SaleSortOrder;\n /** Opt-in: a single indexed COUNT over the filtered set. */\n want_total?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Sales summary (aggregations). Read by `SalesClient.summary()`. Every monetary\n// figure is grouped **by currency** — USD and CAD cents are never summed into a\n// single total. Money stays integer cents; only `quantity` is a decimal string.\n// ---------------------------------------------------------------------------\n\nexport const TRACKING_RANGES = [\"24h\", \"7d\", \"30d\"] as const;\nexport type TrackingOverviewRange = (typeof TRACKING_RANGES)[number];\n\n/** Query input for `SalesClient.summary()` — filters + range + options. */\n/**\n * @deprecated The v1 summary query. `summary()` accepts {@link SaleSummaryQueryV2};\n * the v1 endpoint no longer exists. Removed in the next major.\n */\nexport interface SaleSummaryQuery extends SaleFilters {\n range: TrackingOverviewRange;\n /** Include the per-category line-item breakdown (extra join; default false). */\n include_categories?: boolean;\n /** Surface soft-deleted services individually (flagged \"(deleted)\") instead of\n * rolling them into a single \"Deleted services\" bucket. Default false. */\n include_deleted_services?: boolean;\n /** Cap on the by-service / by-category rows (1–50, default 10). */\n top_n?: number;\n}\n\nexport interface CurrencyRevenue {\n currency: SupportedCurrency;\n sale_count: number;\n revenue_cents: number;\n average_order_value_cents: number;\n}\n\nexport interface SalesServiceBreakdown {\n service_id: string | null;\n service_key: string | null;\n /** \"Unassigned\" when the sale has no service. */\n service_label: string | null;\n currency: SupportedCurrency;\n sale_count: number;\n revenue_cents: number;\n}\n\nexport interface SalesCategoryBreakdown {\n category: string | null;\n currency: SupportedCurrency;\n /** sum(unit_price_cents * quantity) — advisory, not authoritative. */\n revenue_cents: number;\n /** Decimal string. */\n quantity: string;\n}\n\nexport interface SalesTrendPoint {\n bucket_start: string;\n currency: SupportedCurrency;\n sale_count: number;\n revenue_cents: number;\n}\n\n/**\n * @deprecated The v1 summary response. `summary()` returns {@link SaleSummaryV2};\n * the v1 endpoint no longer exists. Removed in the next major.\n */\nexport interface SaleSummary {\n range: TrackingOverviewRange;\n business_id: string | null;\n metrics: {\n sale_count: number;\n distinct_customers: number;\n by_currency: CurrencyRevenue[];\n };\n by_service: SalesServiceBreakdown[];\n by_currency: CurrencyRevenue[];\n by_category: SalesCategoryBreakdown[];\n series: SalesTrendPoint[];\n}\n\n// ---------------------------------------------------------------------------\n// v2 dashboard surface (additive, back-compat). Reads only → plain interfaces,\n// not drift-checked. Legacy `24h/7d/30d` callers get the same numbers; new\n// ranges/options layer on tz-correct calendar windows + comparisons.\n// ---------------------------------------------------------------------------\n\nexport const NAMED_RANGES = [\n \"today\",\n \"yesterday\",\n \"wtd\",\n \"mtd\",\n \"qtd\",\n \"ytd\",\n \"24h\",\n \"7d\",\n \"30d\",\n \"90d\",\n \"custom\",\n] as const;\n/** Superset of `TrackingOverviewRange` (`24h`/`7d`/`30d` stay valid). */\nexport type NamedRange = (typeof NAMED_RANGES)[number];\nexport type Granularity = \"hour\" | \"day\" | \"week\" | \"month\" | \"auto\";\nexport type CompareTo = \"previous_period\" | \"previous_year\" | \"none\";\n\nexport interface DistinctCustomersByCurrency {\n currency: SupportedCurrency;\n distinct_customers: number;\n}\n\nexport interface SummaryWindow {\n since: string;\n until: string;\n}\n\nexport interface SummaryCurrencyDelta {\n currency: SupportedCurrency;\n revenue_cents_delta: number;\n /** Ratio (0.12 = +12%); null when previous revenue was 0. */\n revenue_pct_delta: number | null;\n sale_count_delta: number;\n}\n\nexport interface SummaryDeltas {\n by_currency: SummaryCurrencyDelta[];\n sale_count_delta: number;\n distinct_customers_delta: number;\n}\n\nexport interface SaleSummaryPrevious {\n window: SummaryWindow;\n metrics: SaleSummaryV2[\"metrics\"];\n by_currency: CurrencyRevenue[];\n series: SalesTrendPoint[];\n}\n\n/** Query input for `SalesClient.summary()` v2 — widened range + tz/compare options. */\nexport interface SaleSummaryQueryV2 extends SaleFilters {\n range?: NamedRange;\n /** Required when `range === 'custom'`. */\n since?: string;\n until?: string;\n /** IANA tz, e.g. `America/Toronto`. Default = business tz. */\n timezone?: string;\n granularity?: Granularity;\n compare_to?: CompareTo;\n include_categories?: boolean;\n include_deleted_services?: boolean;\n top_n?: number;\n}\n\n/** Superset of `SaleSummary` (assignable to it). */\nexport interface SaleSummaryV2 extends Omit<SaleSummary, \"range\" | \"metrics\"> {\n range: NamedRange;\n metrics: SaleSummary[\"metrics\"] & {\n distinct_customers_by_currency: DistinctCustomersByCurrency[];\n };\n timezone: string | null;\n window: SummaryWindow | null;\n granularity: string | null;\n previous: SaleSummaryPrevious | null;\n deltas: SummaryDeltas | null;\n}\n\n// ---------------------------------------------------------------------------\n// Customers — phone-keyed (a customer IS their E.164 phone). `customer_id` is\n// the E.164 string. Money is per-currency, never summed.\n// ---------------------------------------------------------------------------\n\nexport type CustomerSegment = \"new\" | \"returning\" | \"repeat\" | \"lapsed\";\nexport type CustomerSortField = \"total_spent\" | \"last_purchase\" | \"purchases\" | \"first_purchase\";\n\nexport interface CustomerCurrencyTotal {\n currency: SupportedCurrency;\n revenue_cents: number;\n sale_count: number;\n average_order_value_cents: number;\n}\n\nexport interface CustomerSummary {\n /** The E.164 phone — the canonical customer id. */\n customer_id: string;\n display_name: string | null;\n email: string | null;\n phone: string;\n first_purchase_at: string;\n last_purchase_at: string;\n purchase_count: number;\n segment: CustomerSegment;\n totals: CustomerCurrencyTotal[];\n}\n\nexport type CustomerProfile = CustomerSummary;\n\nexport interface CustomerListQuery extends Omit<SaleFilters, \"external_id\"> {\n segment?: CustomerSegment;\n sort?: CustomerSortField;\n order?: SaleSortOrder;\n cursor?: string | null;\n limit?: number;\n want_total?: boolean;\n}\n\nexport interface CustomerListPage {\n items: CustomerSummary[];\n next_cursor: string | null;\n total_count: number | null;\n}\n\nexport interface CustomerGetOptions {\n include_sales?: boolean;\n limit?: number;\n cursor?: string | null;\n}\n\nexport interface CustomerGetResult {\n customer: CustomerProfile;\n /** Present only when `include_sales: true` (and a business-scoped key). */\n sales?: SaleCursorPage | null;\n}\n\nexport interface CustomerSummaryQuery extends SaleFilters {\n range?: NamedRange;\n since?: string;\n until?: string;\n timezone?: string;\n compare_to?: CompareTo;\n}\n\nexport interface CustomerSegmentCount {\n segment: CustomerSegment;\n count: number;\n}\n\nexport interface CustomerCurrencyDelta {\n currency: SupportedCurrency;\n revenue_cents_delta: number;\n /** Ratio (0.12 = +12%); null when previous revenue was 0. */\n revenue_pct_delta: number | null;\n}\n\nexport interface CustomerKpisDeltas {\n total_customers_delta: number;\n new_customers_delta: number;\n returning_customers_delta: number;\n repeat_rate_delta: number;\n ltv_by_currency: CustomerCurrencyDelta[];\n}\n\nexport interface CustomerKpisPrevious {\n window_since: string;\n window_until: string;\n total_customers: number;\n new_customers: number;\n returning_customers: number;\n repeat_rate: number;\n ltv_by_currency: CustomerCurrencyTotal[];\n}\n\nexport interface CustomerKpis {\n range: NamedRange;\n timezone: string;\n window_since: string;\n window_until: string;\n total_customers: number;\n new_customers: number;\n returning_customers: number;\n repeat_rate: number;\n by_segment: CustomerSegmentCount[];\n ltv_by_currency: CustomerCurrencyTotal[];\n /** Present only when `compare_to` is set. */\n previous?: CustomerKpisPrevious | null;\n deltas?: CustomerKpisDeltas | null;\n}\n\n// ---------------------------------------------------------------------------\n// Business config — supersets `fetchServices()`.\n// ---------------------------------------------------------------------------\n\nexport interface BusinessConfigService {\n key: string;\n label: string;\n archived: boolean;\n}\n\nexport interface BusinessConfigFeatures {\n customers: boolean;\n comparisons: boolean;\n retention: boolean;\n}\n\n/** A built-in customer field a business can require on every sale. */\nexport type SaleBuiltinRequirableField =\n | \"customer_name\"\n | \"customer_phone\"\n | \"customer_email\"\n | \"description\";\n\n/** v1 value types. `enum` is single-select over the property's `options`. */\nexport type SalePropertyType = \"string\" | \"number\" | \"boolean\" | \"enum\" | \"date\";\n\nexport interface SalePropertyOption {\n key: string;\n label: string;\n}\n\n/** One custom sale property as the sale form sees it: overrides applied, options\n * already filtered to the business's enabled subset. */\nexport interface EffectiveSaleProperty {\n key: string;\n label: string;\n type: SalePropertyType;\n required: boolean;\n options?: SalePropertyOption[] | null;\n}\n\nexport interface BusinessConfig {\n business_id: string;\n display_name: string;\n timezone: string;\n primary_currency: SupportedCurrency;\n currencies: SupportedCurrency[];\n default_phone_country: string | null;\n services: BusinessConfigService[];\n features: BusinessConfigFeatures;\n // The sale-form contract. Read these BEFORE building a sale form: they say\n // what THIS business requires, so a missing field is a render decision rather\n // than a 422 discovered on write.\n builtin_required: SaleBuiltinRequirableField[];\n properties: EffectiveSaleProperty[];\n /** Whether the form should pre-tick the SMS consent box. A form default only —\n * the per-sale `sms_consent` attestation is what every send path gates on. */\n sms_consent_default_opt_in: boolean;\n}\n","// This file is auto-generated by @hey-api/openapi-ts\n\nimport * as z from \"zod/mini\";\n\n/**\n * AutoLeapSaleFacts\n *\n * What the AutoLeap sync stamps on a sale; the writeback picks its conversion action from it.\n */\nexport const zAutoLeapSaleFacts = z.object({\n first_order: z.boolean(),\n fleet_customer: z.boolean(),\n invoice_code: z.string(),\n invoice_id: z.string(),\n});\n\n/**\n * ErrorCode\n *\n * Stable discriminator for a failed request. Values are contract: never rename.\n */\nexport const zErrorCode = z.enum([\n \"missing_credentials\",\n \"invalid_api_key\",\n \"not_authenticated\",\n \"wrong_key_type\",\n \"origin_missing\",\n \"origin_not_allowed\",\n \"capability_denied\",\n \"session_required\",\n \"operator_only\",\n \"cross_tenant\",\n \"business_id_required\",\n \"bad_request\",\n \"not_found\",\n \"conflict\",\n \"gone\",\n \"incompatible_payload\",\n \"repeat_confirmation_required\",\n \"idempotency_conflict\",\n \"slot_unavailable\",\n \"calendar_not_connected\",\n \"calendar_disconnected\",\n \"booking_not_found\",\n \"booking_not_cancellable\",\n \"booking_not_reschedulable\",\n \"booking_not_synced\",\n \"manage_token_invalid\",\n \"google_error\",\n \"booking_unavailable\",\n \"booking_config_stale\",\n \"booking_rejected\",\n \"booking_attempt_mismatch\",\n \"calendar_not_owned\",\n \"calendar_is_sandbox\",\n \"sandbox_exists\",\n \"sandbox_key_too_long\",\n \"sandbox_not_found\",\n \"validation_error\",\n \"unknown_service\",\n \"invalid_sale_properties\",\n \"invalid_cursor\",\n \"rate_limited\",\n \"internal_error\",\n]);\n\n/**\n * SaleItemEntitySchema\n */\nexport const zSaleItemEntitySchema = z.object({\n category: z.nullable(z.string()),\n external_item_id: z.nullable(z.string()),\n id: z.uuid(),\n name: z.nullable(z.string()),\n quantity: z.string().check(z.regex(/^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$/)),\n unit_cost_cents: z.nullable(z.int()),\n unit_price_cents: z.int(),\n});\n\n/**\n * SaleItemInputSchema\n */\nexport const zSaleItemInputSchema = z.strictObject({\n category: z.nullish(z.string()),\n external_item_id: z.nullish(z.string()),\n name: z.nullish(z.string()),\n quantity: z.string().check(z.regex(/^\\d+(\\.\\d{1,3})?$/)),\n unit_cost_cents: z.nullish(z.int().check(z.gte(0))),\n unit_price_cents: z.int().check(z.gte(0)),\n});\n\n/**\n * SaleServiceEntitySchema\n *\n * A service covered by a sale, with its price and resolved taxonomy labels.\n */\nexport const zSaleServiceEntitySchema = z.object({\n amount_cents: z.int(),\n id: z.uuid(),\n mapped: z.nullable(z.boolean()),\n service_id: z.uuid(),\n service_key: z.string(),\n service_label: z.string(),\n});\n\n/**\n * SaleServiceInputSchema\n *\n * One service covered by a sale, at its own price. `service` is the\n * per-business service key, validated against the taxonomy at write time.\n */\nexport const zSaleServiceInputSchema = z.strictObject({\n amount_cents: z.int().check(z.gte(0)),\n service: z.string(),\n});\n\n/**\n * SaleSource\n *\n * Which path created a sale; server-set, never caller-writable.\n */\nexport const zSaleSource = z.enum([\"api\", \"admin\", \"autoleap\"]);\n\n/**\n * SmsAutomationState\n *\n * A sale's automation outcome, worst-first.\n *\n * Ordering is the rollup's precedence: a sale with both a failed send and a\n * withheld condition surfaces the failure, because that is what an operator\n * must act on first.\n */\nexport const zSmsAutomationState = z.enum([\n \"needs_check\",\n \"failed\",\n \"undelivered\",\n \"blocked_opted_out\",\n \"withheld\",\n \"queued\",\n \"sent\",\n \"delivered\",\n \"eligible\",\n \"not_applicable\",\n]);\n\n/**\n * SmsWithheldReason\n *\n * Why an outbound text cannot send right now; the trigger type picks the subset.\n *\n * Derived on read, except a form lead's, stored on `sms_form_lead_outcomes` (ADR-0016).\n * Shared by senders, operator 422s and read models, so operators read what the sender enforced.\n */\nexport const zSmsWithheldReason = z.enum([\n \"automation_disabled\",\n \"sms_disabled\",\n \"no_phone\",\n \"no_consent\",\n \"no_customer_identity\",\n \"missing_customer_name\",\n \"placeholder_customer_name\",\n \"opted_out\",\n \"already_messaged\",\n \"sender_not_ready\",\n \"missing_review_url\",\n \"no_handler\",\n \"dispatch_already_recorded\",\n \"text_back_disabled\",\n \"number_text_back_disabled\",\n \"number_not_active\",\n \"a2p_registration_required\",\n \"alert_recipient_cannot_receive_sms\",\n \"line_type_unresolved\",\n \"sale_source_sends_own_messages\",\n \"form_lead_disabled\",\n \"source_not_enabled\",\n \"confirmed_by_integration\",\n \"test_or_bot_session\",\n \"invalid_phone\",\n \"recently_texted\",\n]);\n\n/**\n * SaleSmsStatusSchema\n *\n * Computed per-sale automation rollup (see `services/sms_rollup_service.py`).\n *\n * Attached to sales list rows when `include_sms` is requested. Derived on read,\n * never stored — `state` reflects live configuration, so the same sale can move\n * from `withheld` to `eligible` when an operator fixes the underlying gap.\n */\nexport const zSaleSmsStatusSchema = z.object({\n can_send_now: z._default(z.boolean(), false),\n last_message_at: z.nullable(z.iso.datetime({ offset: true })),\n reason_detail: z.nullable(z.string()),\n state: zSmsAutomationState,\n suppressed_by_other_sale: z._default(z.boolean(), false),\n withheld_reason: z.nullable(zSmsWithheldReason),\n});\n\n/**\n * SupportedCurrency\n *\n * Accepted ISO-4217 currencies (extend as clients are onboarded).\n *\n * Single backend source of the accepted set; mirrored by the Zod\n * `z.enum([...])` on the client so the drift test keeps them in sync.\n */\nexport const zSupportedCurrency = z.enum([\"USD\", \"CAD\"]);\n\n/**\n * TrackingEnvironment\n *\n * Deployment environment for tracking sessions and events.\n *\n * Stamped on every session and event so the dashboard can filter test\n * traffic from real production data. The SDK reports this via the\n * `context.environment` field on the ingest payload.\n */\nexport const zTrackingEnvironment = z.enum([\"production\", \"development\"]);\n\n/**\n * SaleCreateSchema\n */\nexport const zSaleCreateSchema = z.strictObject({\n amount_total_cents: z.nullish(z.int().check(z.gte(0))),\n currency: zSupportedCurrency,\n customer_email: z.nullish(z.email().check(z.maxLength(320))),\n customer_name: z.nullish(z.string().check(z.maxLength(200))),\n customer_phone: z.nullish(z.string().check(z.maxLength(64))),\n description: z.nullish(z.string()),\n environment: z._default(z.optional(zTrackingEnvironment), \"production\"),\n external_id: z.nullish(z.string()),\n items: z.optional(z.array(zSaleItemInputSchema)),\n metadata: z.nullish(z.record(z.string(), z.unknown())),\n occurred_at: z.iso.datetime({ offset: true }),\n service: z.nullish(z.string()),\n services: z.nullish(z.array(zSaleServiceInputSchema)),\n sms_consent: z.nullish(z.boolean()),\n});\n\n/**\n * ValidationError\n */\nexport const zValidationError = z.object({\n ctx: z.optional(z.record(z.string(), z.unknown())),\n input: z.optional(z.unknown()),\n loc: z.array(z.union([z.string(), z.int()])),\n msg: z.string(),\n type: z.string(),\n});\n\n/**\n * HTTPValidationError\n */\nexport const zHttpValidationError = z.object({\n detail: z.optional(z.array(zValidationError)),\n});\n\n/**\n * ValidationErrorItem\n *\n * One field-level failure from FastAPI's request validation.\n */\nexport const zValidationErrorItem = z.object({\n loc: z.array(z.union([z.string(), z.int()])),\n msg: z.string(),\n type: z.string(),\n});\n\n/**\n * ApiErrorSchema\n *\n * The body of every non-2xx response.\n */\nexport const zApiErrorSchema = z.object({\n code: zErrorCode,\n context: z._default(z.nullish(z.record(z.string(), z.unknown())), null),\n detail: z.string(),\n errors: z._default(z.nullish(z.array(zValidationErrorItem)), null),\n request_id: z._default(z.nullish(z.string()), null),\n});\n\n/**\n * WritebackEmailSource\n *\n * Provenance of the email identifier the upload carried. NONE = no email\n * sent; SALE = the sale's own email; CUSTOMER = same-environment customer\n * fallback; HARVESTED = adopted from a matching form submit.\n */\nexport const zWritebackEmailSource = z.enum([\"NONE\", \"SALE\", \"CUSTOMER\", \"HARVESTED\"]);\n\n/**\n * WritebackEnrichmentStatus\n *\n * Outcome of the form-submit identity-enrichment scan at submit time.\n *\n * NONE = no form submit matched the sale's identity (also the pre-submit\n * default); MATCHED = ≥1 submit matched with a consistent identity;\n * AMBIGUOUS = matches carried >1 distinct emails, so nothing harvested was\n * trusted (phone-only upload, no gclid).\n */\nexport const zWritebackEnrichmentStatus = z.enum([\"NONE\", \"MATCHED\", \"AMBIGUOUS\"]);\n\n/**\n * WritebackSaleUploadCounts\n */\nexport const zWritebackSaleUploadCounts = z.object({\n failed: z._default(z.int(), 0),\n pending: z._default(z.int(), 0),\n skipped: z._default(z.int(), 0),\n submitted: z._default(z.int(), 0),\n total: z._default(z.int(), 0),\n uploaded: z._default(z.int(), 0),\n});\n\n/**\n * SaleWritebackStatusSchema\n *\n * Per-sale writeback rollup attached to the admin sales list rows when\n * `include_writeback` is requested (admin-only — the unified sales table's\n * Writeback column). Never-dispatched sales carry a dispatch readiness the\n * ledger-driven uploads feed can't express.\n */\nexport const zSaleWritebackStatusSchema = z.object({\n blocked_reason: z.nullable(z.string()),\n can_dispatch: z._default(z.boolean(), false),\n email_source: z.nullable(zWritebackEmailSource),\n enrichment_status: z.nullable(zWritebackEnrichmentStatus),\n gclid_attached: z._default(z.boolean(), false),\n identifiers_sent: z.nullable(z.string()),\n mapped_service_ids: z.array(z.uuid()),\n matched_submit_count: z.nullable(z.int()),\n needs_force: z._default(z.boolean(), false),\n rollup: z.enum([\n \"failed\",\n \"skipped\",\n \"pending\",\n \"submitted\",\n \"uploaded\",\n \"not_uploaded\",\n \"out_of_window\",\n \"blocked\",\n ]),\n stale: z._default(z.boolean(), false),\n uncovered_service_ids: z.array(z.uuid()),\n uploads: zWritebackSaleUploadCounts,\n});\n\n/**\n * SaleEntitySchema\n */\nexport const zSaleEntitySchema = z.object({\n amount_total_cents: z.int(),\n business_id: z.uuid(),\n business_name: z.nullable(z.string()),\n created_at: z.iso.datetime({ offset: true }),\n currency: z.string(),\n customer_email: z.nullable(z.string()),\n customer_name: z.nullable(z.string()),\n customer_name_placeholder_confirmed: z._default(z.boolean(), false),\n customer_name_placeholder_confirmed_at: z.nullable(z.iso.datetime({ offset: true })),\n customer_phone: z.nullable(z.string()),\n description: z.nullable(z.string()),\n environment: zTrackingEnvironment,\n external_id: z.nullable(z.string()),\n id: z.uuid(),\n items: z.array(zSaleItemEntitySchema),\n metadata: z.nullable(z.record(z.string(), z.unknown())),\n occurred_at: z.iso.datetime({ offset: true }),\n service_id: z.nullable(z.uuid()),\n service_key: z.nullable(z.string()),\n service_label: z.nullable(z.string()),\n services: z.array(zSaleServiceEntitySchema),\n sms_automation: z.nullable(zSaleSmsStatusSchema),\n sms_consent: z._default(z.boolean(), false),\n sms_consent_granted_at: z.nullable(z.iso.datetime({ offset: true })),\n source: zSaleSource,\n source_facts: z.nullable(zAutoLeapSaleFacts),\n updated_at: z.iso.datetime({ offset: true }),\n writeback: z.nullable(zSaleWritebackStatusSchema),\n});\n","import { salesRequest, type SalesTransportConfig } from \"./sales/transport\";\n\n/** A business's active service, as returned by `GET /tracking/services`. */\nexport interface PublicServiceItem {\n key: string;\n label: string;\n}\n\n/**\n * Fetch the caller's business's active service taxonomy. Accepts a public or\n * secret key (the taxonomy is low-sensitivity category names). Powers the\n * `@aranova/tracking-cli gen` codegen.\n */\nexport async function fetchServices(config: SalesTransportConfig): Promise<PublicServiceItem[]> {\n return salesRequest<PublicServiceItem[]>(config, \"GET\", \"/services\");\n}\n"],"mappings":"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","names":["email","email","iso","record","record","z"]}
1
+ {"version":3,"sources":["../src/sales.ts","../../tracking-core/src/capabilities.ts","../../tracking-core/src/phone.ts","../../tracking-core/src/user-data.ts","../../tracking-core/src/consent.ts","../../tracking-core/src/gtag.ts","../../tracking-core/src/resources/conversion-firing.ts","../../../contracts/autoleap-booking-links.json","../../tracking-core/src/integrations-public.ts","../../tracking-core/src/lead-forms/lead-forms.gen.ts","../../tracking-core/src/uuid.ts","../../tracking-core/src/session.ts","../../tracking-core/src/resources/http/errors.ts","../../tracking-core/src/resources/intl/date.ts","../../tracking-core/src/resources/sales/money.ts","../../tracking-core/src/resources/tracking-config-runtime.ts","../../tracking-core/src/ingest.ts","../../tracking-core/src/resources/http/request.ts","../../tracking-core/src/resources/sales/transport.ts","../../tracking-core/src/resources/sales/bookings.ts","../../tracking-core/src/resources/sales/client.ts","../../tracking-core/src/resources/sales/schema.ts","../../tracking-core/src/resources/sales/generated/zod.gen.ts","../../tracking-core/src/resources/services.ts"],"sourcesContent":["// Server-safe sales SDK entry — `@aranova/tracking-react/sales`. React-free; the\n// recommended import for server/serverless modules (API routes, Node scripts).\n// The surface is authored once in tracking-core (see sales-public.ts); this file\n// exists only as the per-package tsup build entry.\nexport * from \"../../tracking-core/src/sales-public\";\n","// Capability self-report registry.\n//\n// Every SDK construct registers itself here at construction time, and the\n// registered set rides out on `TrackingClientContext.capabilities`, which the\n// backend persists on the session row. The dashboard derives what a client site\n// ACTUALLY has installed from those sessions, rather than trusting an operator\n// checkbox — see contracts/tracking-capabilities.json, which pins this id list\n// against the backend enum and the dashboard type union.\n//\n// Browser-only by design: a server-side client (calendar bookings) emits no\n// session, and its artifact is better evidence than a self-report anyway.\n//\n// This module deliberately imports nothing: it is the one thing every other\n// resource can import without adding an edge that could close a cycle.\n\n/**\n * Every capability an install can report. Pinned by\n * `contracts/tracking-capabilities.json` — adding one here without a catalog\n * entry fails the drift test.\n */\nexport const TRACKING_CAPABILITIES = [\n \"ad_tags_google\",\n \"ad_tags_meta\",\n \"base_tracking\",\n \"blog_rendering\",\n \"booking_handoff\",\n \"booking_integration\",\n \"calendar_read\",\n \"calendar_write\",\n \"consent_controls\",\n \"conversion_goals_auto\",\n \"conversion_goals_manual\",\n \"cross_site_linker\",\n \"cta_click_capture\",\n \"form_capture\",\n \"phone_click_capture\",\n \"phone_fields\",\n] as const;\n\nexport type TrackingCapability = (typeof TRACKING_CAPABILITIES)[number];\n\n/**\n * DOM attribute a server-rendered surface stamps to report itself.\n *\n * Blog rendering never calls the Aranova API — it reads the CDN directly — so\n * neither the ingest payload nor the request-header path can see it. The\n * renderer stamps this attribute instead and the browser context collector\n * picks it up on the next batch.\n */\nexport const CAPABILITY_DOM_ATTRIBUTE = \"data-aranova-capability\";\n\nconst registered = new Set<TrackingCapability>();\n\n/**\n * Record that a capability is present in this runtime.\n *\n * Idempotent and side-effect free — safe to call on every construction,\n * including React strict-mode double mounts.\n */\nexport function registerCapability(capability: TrackingCapability): void {\n registered.add(capability);\n}\n\n/**\n * Capabilities registered so far in this runtime, sorted for a stable wire\n * value (so the backend can hash the array to skip redundant writes).\n *\n * Also collects `data-aranova-capability` markers from the DOM, which is how\n * server-rendered surfaces that never call our API report themselves.\n */\nexport function getRegisteredCapabilities(): TrackingCapability[] {\n const all = new Set<TrackingCapability>(registered);\n for (const marker of readDomMarkers()) all.add(marker);\n return Array.from(all).sort();\n}\n\nfunction readDomMarkers(): TrackingCapability[] {\n if (typeof document === \"undefined\") return [];\n const known = new Set<string>(TRACKING_CAPABILITIES);\n const found: TrackingCapability[] = [];\n // Unknown values are dropped rather than forwarded: the backend rejects ids\n // outside its enum, and a stray attribute on a client's page must never be\n // able to fail an otherwise-valid ingest batch.\n for (const node of document.querySelectorAll(`[${CAPABILITY_DOM_ATTRIBUTE}]`)) {\n const value = node.getAttribute(CAPABILITY_DOM_ATTRIBUTE);\n if (value && known.has(value)) found.push(value as TrackingCapability);\n }\n return found;\n}\n\n/** Test-only: drop everything registered so far. */\nexport function resetRegisteredCapabilitiesForTests(): void {\n registered.clear();\n}\n","// Framework-agnostic phone-number utilities — isomorphic (browser + Node/RSC),\n// zero React. Bundled `libphonenumber-js` (standard metadata) so clients add no\n// dependency. The transmitted value is ALWAYS E.164; display is the only knob.\n\nimport { AsYouType, parsePhoneNumberFromString, type CountryCode } from \"libphonenumber-js\";\n\nexport type { CountryCode };\n\n/**\n * How a phone number is shown in the UI. The transmitted value is always E.164\n * and is deliberately NOT part of this — only the display format is configurable.\n * A function form covers the long tail (`(parsed) => string`).\n */\nexport type PhoneDisplayFormat =\n | \"national\"\n | \"international\"\n | \"e164\"\n | ((parsed: ParsedPhone) => string);\n\nexport interface ParsedPhone {\n /** E.164 (`\"+14165550199\"`) or `null` when the input isn't a valid number. This is what gets transmitted. */\n e164: string | null;\n /** National display form (`\"(416) 555-0199\"`); empty string when unparseable. */\n national: string;\n /** International display form (`\"+1 416 555 0199\"`); empty string when unparseable. */\n international: string;\n /** ISO-3166 country resolved by libphonenumber, or `null`. */\n country: CountryCode | null;\n isValid: boolean;\n}\n\n/** Region assumed for numbers typed without a country code. */\nexport const DEFAULT_PHONE_COUNTRY: CountryCode = \"CA\";\n\n/** Parse a raw/display string into every representation at once (one parse → display + wire never drift). */\nexport function parsePhone(raw: string, country?: CountryCode): ParsedPhone {\n const region = country ?? DEFAULT_PHONE_COUNTRY;\n const parsed = parsePhoneNumberFromString(raw ?? \"\", region);\n if (!parsed) {\n return { e164: null, national: \"\", international: \"\", country: region, isValid: false };\n }\n const isValid = parsed.isValid();\n return {\n // E.164 is only surfaced for a *valid* number — a possible-but-invalid input\n // (e.g. too few digits) still parses but must not be transmitted.\n e164: isValid ? parsed.number : null,\n national: parsed.formatNational(),\n international: parsed.formatInternational(),\n country: parsed.country ?? region,\n isValid,\n };\n}\n\n/** Normalize any raw/display value to E.164, or `null` if it isn't a valid number. */\nexport function toE164(raw: string, country?: CountryCode): string | null {\n return parsePhone(raw, country).e164;\n}\n\n/** Format a value for display. Defaults to `'national'`. Never affects the wire value. */\nexport function formatPhone(\n value: string,\n format: PhoneDisplayFormat = \"national\",\n country?: CountryCode,\n): string {\n const parsed = parsePhone(value, country);\n if (typeof format === \"function\") return format(parsed);\n switch (format) {\n case \"international\":\n return parsed.international || value;\n case \"e164\":\n return parsed.e164 ?? value;\n case \"national\":\n default:\n return parsed.national || value;\n }\n}\n\n/** Live, incremental formatting for an `<input>` as the user types (`AsYouType`). */\nexport function formatPhoneAsTyped(raw: string, country?: CountryCode): string {\n return new AsYouType(country ?? DEFAULT_PHONE_COUNTRY).input(raw ?? \"\");\n}\n","// Enhanced-conversions user_data bridge (Google Ads \"user-provided data\").\n//\n// Why this exists: Google's automatic user-provided-data capture demonstrably\n// misses phone fields on client sites (verified live: a filled tel input never\n// produced a `pn` hit parameter), and our offline sale uploads are phone-hash\n// keyed — without a phone↔click pairing built at conversion time, none of them\n// can match. This module makes the SDK the deterministic capture path: it\n// extracts the visitor's own email/phone from `form_submit` fields (or an\n// explicit override / a recorded sale's customer fields), normalizes them\n// (E.164 phone, lowercased email), and sets them as PLAINTEXT\n// `gtag('set','user_data', …)` immediately before a conversion fires. gtag.js\n// normalizes further and SHA-256 hashes IN THE BROWSER — plaintext never\n// leaves the page, and we never own Google's hashing contract.\n//\n// Privacy posture: the stash is module-level memory only — never persisted to\n// any storage, never added to our own ingest payloads (the form fields were\n// already part of the site's `form_submit` metadata; we only read them).\n// Consent gating happens at the egress in `fireConversionWithConsent` (the\n// same gate every conversion already passes through); an explicit consent\n// denial additionally clears the stash and nulls gtag's page state (see\n// consent.ts). This module must NOT import consent.ts — consent.ts imports us\n// for that clearing hook, and the reverse edge would be a cycle.\n\nimport { toE164, type CountryCode } from \"./phone\";\n\n/** Raw identifiers a caller may hand us (normalized before use). */\nexport interface ConversionUserData {\n email?: string | null;\n phone?: string | null;\n}\n\n/** Normalized identifiers as gtag's `user_data` expects them. */\nexport interface NormalizedUserData {\n email: string | null;\n /** E.164 (`\"+16477836797\"`) — the format Google's parser reliably accepts. */\n phoneNumber: string | null;\n}\n\nconst EMAIL_SHAPE = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/;\nconst EMAIL_NAME_HINT = /e[-_]?mail/i;\n// `\\b` won't do: underscores are word chars, so `customer_phone` would miss.\n// The leading guard keeps bare \"tel\" from matching e.g. \"hotel\".\nconst PHONE_NAME_HINT = /(^|[^a-z])(phone|tel|mobile|cell)/i;\n\n// Page-lifetime, memory-only. Freshest non-null value wins per identifier.\nlet stash: NormalizedUserData = { email: null, phoneNumber: null };\n\n/**\n * Trim + lowercase + shape-check. Deliberately NO gmail dot/plus\n * canonicalization — gtag.js applies Google's own normalization before\n * hashing, and owning that contract here would be drift waiting to happen.\n */\nexport function normalizeEmail(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const cleaned = raw.trim().toLowerCase();\n return EMAIL_SHAPE.test(cleaned) ? cleaned : null;\n}\n\ninterface FieldLike {\n name?: unknown;\n type?: unknown;\n label?: unknown;\n value?: unknown;\n}\n\nfunction fieldText(field: FieldLike, key: \"name\" | \"type\" | \"label\"): string {\n const value = field[key];\n return typeof value === \"string\" ? value : \"\";\n}\n\n/**\n * Deterministic extraction from `form_submit` `fields[]`: for each identifier,\n * a typed field (`type=\"email\"` / `type=\"tel\"`) wins over a name/label match\n * (`user_email`, `customer_phone`, …), in document order. A field whose value\n * fails normalization does NOT consume the slot — scanning continues, so one\n * malformed phone field can't mask a later valid one. Never throws.\n */\nexport function extractUserDataFromFormFields(\n fields: unknown,\n country?: CountryCode,\n): NormalizedUserData {\n const result: NormalizedUserData = { email: null, phoneNumber: null };\n if (!Array.isArray(fields)) return result;\n const passes: Array<(field: FieldLike, hint: RegExp, type: string) => boolean> = [\n (field, _hint, type) => fieldText(field, \"type\").toLowerCase() === type,\n (field, hint) => hint.test(fieldText(field, \"name\")) || hint.test(fieldText(field, \"label\")),\n ];\n for (const matches of passes) {\n for (const raw of fields) {\n if (raw === null || typeof raw !== \"object\") continue;\n const field = raw as FieldLike;\n if (typeof field.value !== \"string\" || field.value.length === 0) continue;\n if (result.email === null && matches(field, EMAIL_NAME_HINT, \"email\")) {\n result.email = normalizeEmail(field.value);\n }\n if (result.phoneNumber === null && matches(field, PHONE_NAME_HINT, \"tel\")) {\n try {\n result.phoneNumber = toE164(field.value, country);\n } catch {\n // libphonenumber choked on garbage — leave the slot open\n }\n }\n }\n if (result.email !== null && result.phoneNumber !== null) break;\n }\n return result;\n}\n\n/** Normalize + merge into the stash (non-null values win over existing). */\nexport function stashUserData(data: ConversionUserData, country?: CountryCode): void {\n const email = normalizeEmail(data.email);\n let phoneNumber: string | null = null;\n if (typeof data.phone === \"string\" && data.phone.length > 0) {\n try {\n phoneNumber = toE164(data.phone, country);\n } catch {\n phoneNumber = null;\n }\n }\n stash = {\n email: email ?? stash.email,\n phoneNumber: phoneNumber ?? stash.phoneNumber,\n };\n}\n\n/** Extract from `form_submit` fields and merge into the stash. Never throws. */\nexport function stashUserDataFromFormFields(fields: unknown, country?: CountryCode): void {\n try {\n const extracted = extractUserDataFromFormFields(fields, country);\n stash = {\n email: extracted.email ?? stash.email,\n phoneNumber: extracted.phoneNumber ?? stash.phoneNumber,\n };\n } catch {\n // extraction must never break event tracking\n }\n}\n\n/** Snapshot of the current stash (copy — mutations don't leak back). */\nexport function getStashedUserData(): NormalizedUserData {\n return { ...stash };\n}\n\n/** Drop everything (consent denial, tests). */\nexport function clearStashedUserData(): void {\n stash = { email: null, phoneNumber: null };\n}\n\n/**\n * Set gtag's per-page `user_data` right before a conversion fires: explicit\n * values win per-key over the stash; an empty merge sets nothing at all.\n * Browser-gated and throw-proof — the conversion must fire regardless.\n * NOT consent-checked here: the single caller (`fireConversionWithConsent`)\n * has already returned on denial before reaching this.\n *\n * Returns whether a `gtag('set','user_data', …)` call was made.\n */\nexport function applyUserDataForConversion(\n explicit?: ConversionUserData | null,\n country?: CountryCode,\n): boolean {\n if (typeof window === \"undefined\" || typeof window.gtag !== \"function\") return false;\n let email = stash.email;\n let phoneNumber = stash.phoneNumber;\n if (explicit) {\n const normalizedEmail = normalizeEmail(explicit.email);\n if (normalizedEmail) email = normalizedEmail;\n if (typeof explicit.phone === \"string\" && explicit.phone.length > 0) {\n try {\n phoneNumber = toE164(explicit.phone, country) ?? phoneNumber;\n } catch {\n // keep the stashed phone\n }\n }\n }\n if (email === null && phoneNumber === null) return false;\n try {\n window.gtag(\"set\", \"user_data\", {\n ...(email !== null ? { email } : {}),\n ...(phoneNumber !== null ? { phone_number: phoneNumber } : {}),\n });\n return true;\n } catch {\n return false;\n }\n}\n","import type { ConsentState } from \"./types\";\nimport { clearStashedUserData } from \"./user-data\";\n\n/**\n * localStorage key for the visitor's explicit consent choice.\n */\nexport const CONSENT_STATE_KEY = \"consent_state\";\n\n/**\n * localStorage key for the ISO timestamp when consent was last changed.\n */\nexport const CONSENT_TIMESTAMP_KEY = \"consent_timestamp\";\n\n/**\n * localStorage key for the ISO timestamp when an explicit decline expires.\n *\n * Written alongside a `denied` choice; absent for grants (they never expire).\n * Computed at WRITE time so every reader — including the inline\n * `beforeInteractive` scripts that can't import this module — only has to\n * compare a stored ISO string against the clock.\n */\nexport const CONSENT_EXPIRES_AT_KEY = \"consent_expires_at\";\n\n/**\n * How long an explicit decline is honored before the visitor reverts to the\n * default-granted state. Explicit grants never expire.\n */\nexport const DEFAULT_DECLINE_TTL_DAYS = 90;\n\nconst DAY_MS = 86_400_000;\n\n/**\n * Google Consent Mode value sent to `gtag('consent', 'update', ...)`.\n */\nexport type GtagConsentValue = \"granted\" | \"denied\";\n\n/**\n * Consent Mode v2 payload sent to Google Ads when consent changes.\n */\nexport interface ConsentUpdatePayload {\n ad_storage: GtagConsentValue;\n ad_user_data: GtagConsentValue;\n ad_personalization: GtagConsentValue;\n analytics_storage: GtagConsentValue;\n}\n\n/** Effective consent state — the opt-out model has no \"pending\". */\nexport type ConsentChoiceState = \"granted\" | \"denied\";\n\n/**\n * Where the effective state came from: `explicit` when the visitor made a\n * stored, still-valid choice; `default` otherwise (no choice, expired decline,\n * storage blocked, SSR).\n */\nexport type ConsentSource = \"default\" | \"explicit\";\n\n/**\n * The effective consent decision plus its provenance.\n */\nexport interface ConsentChoice {\n state: ConsentChoiceState;\n source: ConsentSource;\n /** ISO timestamp of the explicit choice; null for the default state. */\n updatedAt: string | null;\n /** ISO expiry of an unexpired decline; null for grants and the default state. */\n expiresAt: string | null;\n}\n\n/** Options for explicit consent writes. */\nexport interface SetConsentOptions {\n /** Days an explicit decline is honored. Defaults to {@link DEFAULT_DECLINE_TTL_DAYS}. */\n declineTtlDays?: number;\n}\n\nconst DEFAULT_CHOICE: ConsentChoice = {\n state: \"granted\",\n source: \"default\",\n updatedAt: null,\n expiresAt: null,\n};\n\ntype ConsentChangeListener = (choice: ConsentChoice) => void;\nconst changeListeners = new Set<ConsentChangeListener>();\n\n/**\n * Subscribe to consent changes (opt-in, opt-out, reset) made in THIS tab.\n * Cross-tab changes surface via the browser's `storage` event instead.\n * Returns an unsubscribe function.\n */\nexport function onConsentChange(listener: ConsentChangeListener): () => void {\n changeListeners.add(listener);\n return () => {\n changeListeners.delete(listener);\n };\n}\n\n// Takes the intended choice rather than re-reading storage: when localStorage\n// is blocked, a re-read would report default-granted right after an opt-out\n// click even though the live gtag/fbq revoke DID apply for this page load.\nfunction notifyConsentChanged(choice: ConsentChoice): void {\n for (const listener of changeListeners) {\n try {\n listener(choice);\n } catch {\n // a listener must never break the consent flow\n }\n }\n}\n\n/**\n * Build the Google Consent Mode v2 update payload for a single consent state.\n */\nexport function buildConsentPayload(state: GtagConsentValue): ConsentUpdatePayload {\n return {\n ad_storage: state,\n ad_user_data: state,\n ad_personalization: state,\n analytics_storage: state,\n };\n}\n\n/**\n * Resolve the visitor's effective consent (opt-out model).\n *\n * Default is GRANTED. Only a stored, unexpired explicit decline yields\n * `denied`. SSR, blocked storage, and expired declines all resolve to the\n * default. A legacy decline stored by the opt-in-era SDK (no expiry key) stays\n * denied and gets a fresh 90-day expiry backfilled, keeping this reader\n * consistent with the inline scripts (which treat a missing expiry as denied).\n */\nexport function getConsentChoice(): ConsentChoice {\n if (typeof window === \"undefined\") return DEFAULT_CHOICE;\n\n try {\n const stored = window.localStorage.getItem(CONSENT_STATE_KEY);\n const updatedAt = window.localStorage.getItem(CONSENT_TIMESTAMP_KEY);\n\n if (stored === \"granted\")\n return { state: \"granted\", source: \"explicit\", updatedAt, expiresAt: null };\n\n if (stored === \"denied\") {\n let expiresAt = window.localStorage.getItem(CONSENT_EXPIRES_AT_KEY);\n if (!expiresAt) {\n expiresAt = new Date(Date.now() + DEFAULT_DECLINE_TTL_DAYS * DAY_MS).toISOString();\n try {\n window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);\n } catch {\n // backfill is best-effort; the decline is still honored this read\n }\n }\n // A garbled expiry parses to NaN, the comparison is false, and the decline\n // stays honored (fail closed on the decliner's side). Only a parseable\n // expiry in the past reverts to the default.\n if (!(Date.parse(expiresAt) <= Date.now()))\n return { state: \"denied\", source: \"explicit\", updatedAt, expiresAt };\n }\n } catch {\n // localStorage blocked (sandboxed iframe / storage disabled) — default\n }\n\n return DEFAULT_CHOICE;\n}\n\n/**\n * Read the visitor's effective consent state.\n *\n * Opt-out model: returns `granted` unless a stored, unexpired explicit decline\n * exists. Never returns `pending` — that value survives in {@link ConsentState}\n * only so pre-v2 call sites keep compiling.\n */\nexport function getConsentState(): ConsentState {\n return getConsentChoice().state;\n}\n\nfunction pushConsentToPlatforms(state: GtagConsentValue): void {\n if (typeof window === \"undefined\") return;\n\n if (typeof window.gtag === \"function\")\n window.gtag(\"consent\", \"update\", buildConsentPayload(state));\n\n // Mirror to the Meta Pixel so one control gates both ad platforms.\n if (typeof window.fbq === \"function\")\n window.fbq(\"consent\", state === \"granted\" ? \"grant\" : \"revoke\");\n}\n\n/**\n * Persist an explicit visitor consent choice and push it live to Google\n * Consent Mode and the Meta Pixel.\n *\n * Declines expire after {@link DEFAULT_DECLINE_TTL_DAYS} days (override via\n * `options.declineTtlDays`); grants never expire.\n */\nexport function setConsentState(state: GtagConsentValue, options?: SetConsentOptions): void {\n if (typeof window === \"undefined\") return;\n\n // Compute the full intended choice BEFORE touching storage so (a) a garbage\n // TTL can't throw mid-write (state written, expiry not), and (b) listeners\n // get the visitor's actual choice even when storage is blocked.\n const requestedTtl = options?.declineTtlDays;\n const ttlDays =\n typeof requestedTtl === \"number\" && Number.isFinite(requestedTtl) && requestedTtl > 0\n ? requestedTtl\n : DEFAULT_DECLINE_TTL_DAYS;\n const updatedAt = new Date().toISOString();\n const expiresAt =\n state === \"denied\" ? new Date(Date.now() + ttlDays * DAY_MS).toISOString() : null;\n\n try {\n window.localStorage.setItem(CONSENT_STATE_KEY, state);\n window.localStorage.setItem(CONSENT_TIMESTAMP_KEY, updatedAt);\n if (expiresAt !== null) {\n window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);\n } else {\n window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);\n }\n } catch {\n // localStorage blocked — still push the live consent update below so the\n // visitor's click takes effect for this page load even if it can't persist\n }\n\n pushConsentToPlatforms(state);\n if (state === \"denied\") {\n // A mid-session revoke also scrubs enhanced-conversions page state: drop\n // the stashed identifiers and best-effort null gtag's user_data.\n clearStashedUserData();\n try {\n if (typeof window.gtag === \"function\") window.gtag(\"set\", \"user_data\", null);\n } catch {\n // scrubbing is best-effort; the consent update above already gates fires\n }\n }\n notifyConsentChanged({ state, source: \"explicit\", updatedAt, expiresAt });\n}\n\n/**\n * Explicitly opt the visitor in to ad tracking (never expires).\n */\nexport function optIn(): void {\n setConsentState(\"granted\");\n}\n\n/**\n * Explicitly opt the visitor out of ad tracking for\n * {@link DEFAULT_DECLINE_TTL_DAYS} days (override via `options.declineTtlDays`).\n *\n * This is the primitive a client site's footer \"cookie preferences\" control\n * should call — the packages ship no consent UI of their own.\n */\nexport function optOut(options?: SetConsentOptions): void {\n setConsentState(\"denied\", options);\n}\n\n/**\n * Clear the stored explicit choice, returning the visitor to the\n * default-granted state, and push that state live to gtag + Meta.\n *\n * Power a \"Cookie preferences\" reset in a footer:\n *\n * ```tsx\n * const { reset } = useCookiePreferences();\n * <button onClick={reset}>Reset cookie preferences</button>\n * ```\n */\nexport function resetConsent(): void {\n if (typeof window === \"undefined\") return;\n\n try {\n window.localStorage.removeItem(CONSENT_STATE_KEY);\n window.localStorage.removeItem(CONSENT_TIMESTAMP_KEY);\n window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);\n } catch {\n // localStorage blocked — nothing to clear\n }\n\n pushConsentToPlatforms(\"granted\");\n notifyConsentChanged(DEFAULT_CHOICE);\n}\n\n/**\n * Re-apply a stored explicit consent choice to Google Consent Mode + Meta.\n *\n * @deprecated The v2 consent-default path ({@link applyDefaultConsentState} /\n * `createConsentDefaultScript`) already applies the effective state at\n * bootstrap, making this redundant. Pushes platform updates directly — it must\n * NOT route through {@link setConsentState}, which would renew the decline's\n * 90-day expiry on every page load.\n */\nexport function restoreStoredConsent(): ConsentState {\n const choice = getConsentChoice();\n\n if (choice.source === \"explicit\") pushConsentToPlatforms(choice.state);\n\n return choice.state;\n}\n\n/**\n * Inline-JS fragment that resolves the effective consent value into\n * `var aranovaConsent = 'granted' | 'denied'`.\n *\n * Single source of the expiry logic for every inline `<script>` builder (gtag\n * and Meta), so the runtime reader and the inline readers can't drift. Must\n * stay dependency-free ES5 and never throw (localStorage access throws in\n * sandboxed iframes).\n */\nexport function createEffectiveConsentSnippet(): string {\n // IIFE keeps the temporaries off the page's global scope — only\n // `aranovaConsent` (the value consumed by the rest of the script) leaks.\n return `\nvar aranovaConsent = (function () {\n try {\n if (window.localStorage.getItem('${CONSENT_STATE_KEY}') === 'denied') {\n var exp = window.localStorage.getItem('${CONSENT_EXPIRES_AT_KEY}');\n var expMs = exp ? Date.parse(exp) : NaN;\n if (!(expMs <= Date.now())) return 'denied';\n }\n } catch (e) {}\n return 'granted';\n})();\n`.trim();\n}\n","// This module loads the Google tag (gtag.js), applies Consent Mode, and fires\n// `gtag('config', …)` (remarketing) plus real-time `gtag('event','conversion')`\n// via `fireGtagConversion` (GAP28). Consent v2 is OPT-OUT: the effective state\n// is granted unless the visitor stored an explicit, unexpired decline, so tags\n// run from first paint for the default visitor.\n// See docs/google-ads-deployment/gtags-and-conversion-tracking.md.\n\nimport { buildConsentPayload, createEffectiveConsentSnippet, getConsentState } from \"./consent\";\nimport type { GtagEnvironmentMap } from \"./types\";\n\n/**\n * Google Tag Manager script host used for gtag loading.\n */\nexport const GTAG_SCRIPT_HOST = \"https://www.googletagmanager.com/gtag/js\";\n\n/**\n * Attribute used to mark scripts inserted by the tracking SDK.\n */\nexport const TRACKING_SCRIPT_ATTRIBUTE = \"data-aranova-tracking\";\n\nfunction getScriptMarker(id: string): string {\n return `aranova-${id}`;\n}\n\n/**\n * Pattern matching valid Google Ads / GA4 tag IDs.\n *\n * Accepted formats: `AW-123456789`, `G-XXXXXXXXXX`, `GT-XXXXXXX`, `DC-XXXXXXX`.\n * IDs that don't match are silently dropped to prevent script injection when\n * interpolated into inline `<script>` content.\n */\nconst GTAG_ID_PATTERN = /^[A-Z]{1,3}-[A-Za-z0-9_-]+$/;\n\nexport function isValidGtagId(id: string): boolean {\n return GTAG_ID_PATTERN.test(id);\n}\n\n/**\n * Create an inline script that initializes gtag with the visitor's EFFECTIVE\n * consent as the Consent Mode default (opt-out model).\n *\n * Resolves granted/denied synchronously from localStorage in one shot — a\n * decliner never gets a granted window and the default visitor never gets a\n * denied one, so no `wait_for_update` and no follow-up restore script is\n * needed. Run it `beforeInteractive` so the default lands before gtag.js.\n */\nexport function createConsentDefaultScript(): string {\n return `\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\nwindow.gtag = gtag;\n${createEffectiveConsentSnippet()}\ngtag('consent', 'default', {\n ad_storage: aranovaConsent,\n ad_user_data: aranovaConsent,\n ad_personalization: aranovaConsent,\n analytics_storage: aranovaConsent\n});\n`.trim();\n}\n\n/**\n * Create an inline script that initializes gtag for a Google Ads tag id.\n */\nexport function createGtagInitScript(gtagId: string): string {\n if (!isValidGtagId(gtagId)) return \"\";\n return `\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\nwindow.gtag = gtag;\ngtag('js', new Date());\ngtag('config', '${gtagId}');\n`.trim();\n}\n\n/**\n * Create an inline script that pushes the effective consent as an update.\n *\n * @deprecated Redundant since consent v2 — `createConsentDefaultScript()`\n * already resolves the effective state (including decline expiry) into the\n * Consent Mode default, so no restore pass is needed. Kept expiry-aware for\n * integrations that still render it.\n */\nexport function createConsentRestoreScript(): string {\n return `\n${createEffectiveConsentSnippet()}\nif (window.gtag) {\n window.gtag('consent', 'update', {\n ad_storage: aranovaConsent,\n ad_user_data: aranovaConsent,\n ad_personalization: aranovaConsent,\n analytics_storage: aranovaConsent\n });\n}\n`.trim();\n}\n\n/**\n * Ensure `window.gtag` and `window.dataLayer` exist and return the gtag shim.\n */\nexport function ensureGtagFunction(): NonNullable<Window[\"gtag\"]> {\n window.dataLayer = window.dataLayer || [];\n\n if (typeof window.gtag === \"function\") return window.gtag;\n\n // CRITICAL: push the `arguments` object, NOT a rest-param array. gtag.js only\n // treats the canonical `dataLayer.push(arguments)` form as a command — a plain\n // array pushed to the dataLayer is ignored. With the array form, every\n // gtag('config' | 'consent' | 'event') call silently no-ops: the account never\n // registers, consent mode never applies, and conversions never fire (verified\n // live — the array form sends zero network hits; the arguments form sends the\n // pagead/conversion ping). Mirror Google's canonical inline snippet exactly.\n function gtag(): void {\n // eslint-disable-next-line prefer-rest-params\n window.dataLayer?.push(arguments);\n }\n window.gtag = gtag;\n\n return window.gtag;\n}\n\nconst SEND_TO_RE = /^AW-[A-Za-z0-9]+\\/[A-Za-z0-9_-]+$/;\n\n/** A gtag `send_to` target, e.g. `AW-123456789/AbC-D_efg`. */\nexport function isValidSendTo(sendTo: string): boolean {\n return SEND_TO_RE.test(sendTo);\n}\n\nexport interface GtagConversionInput {\n /** `AW-<id>/<label>` — the conversion action's firing target. */\n sendTo: string;\n /** Conversion value in MAJOR units; omit to let Google apply the action's default. */\n value?: number | null;\n currency?: string | null;\n transactionId?: string | null;\n}\n\n/**\n * Fire a real-time on-site conversion — `gtag('event','conversion',{ send_to, … })`\n * (GAP28). Browser-gated and never throws: `record()` is isomorphic and runs server-side\n * under a secret key where `window`/`gtag` are absent, so this no-ops there. Consent\n * gating + de-dup are the caller's responsibility (see `fireConversionWithConsent`).\n * Returns whether the event was actually pushed.\n */\nexport function fireGtagConversion(input: GtagConversionInput): boolean {\n if (typeof window === \"undefined\" || typeof window.gtag !== \"function\") return false;\n if (!isValidSendTo(input.sendTo)) return false;\n const params: Record<string, unknown> = { send_to: input.sendTo };\n if (input.value != null) params.value = input.value;\n if (input.currency) params.currency = input.currency;\n if (input.transactionId) params.transaction_id = input.transactionId;\n try {\n window.gtag(\"event\", \"conversion\", params);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Apply the visitor's EFFECTIVE consent as the Consent Mode default\n * (opt-out model): granted unless a stored, unexpired decline exists.\n *\n * Reading via `getConsentState()` also backfills the expiry key for\n * legacy opt-in-era declines, keeping later inline reads consistent.\n */\nexport function applyDefaultConsentState(): void {\n const gtag = ensureGtagFunction();\n gtag(\n \"consent\",\n \"default\",\n buildConsentPayload(getConsentState() === \"denied\" ? \"denied\" : \"granted\"),\n );\n}\n\n/**\n * Load the external gtag script once for the provided Google Ads tag id.\n */\nexport function loadGtagScript(gtagId: string): void {\n if (typeof document === \"undefined\") return;\n\n const marker = getScriptMarker(\"gtag-loader\");\n const existingScript = document.querySelector<HTMLScriptElement>(\n `script[${TRACKING_SCRIPT_ATTRIBUTE}=\"${marker}\"]`,\n );\n if (existingScript) return;\n\n const script = document.createElement(\"script\");\n script.async = true;\n script.src = `${GTAG_SCRIPT_HOST}?id=${encodeURIComponent(gtagId)}`;\n script.setAttribute(TRACKING_SCRIPT_ATTRIBUTE, marker);\n document.head.append(script);\n}\n\n/**\n * Send the initial `gtag('js')` and `gtag('config')` calls.\n */\nexport function initializeGtag(gtagId: string): void {\n const gtag = ensureGtagFunction();\n gtag(\"js\", new Date());\n gtag(\"config\", gtagId);\n}\n\n/**\n * Push the current effective consent to gtag as an update.\n *\n * @deprecated Redundant since consent v2 — `applyDefaultConsentState()`\n * already applies the effective state at bootstrap. Pushes directly (no\n * storage writes), so it never renews a decline's expiry.\n */\nexport function restoreConsentState(): void {\n if (typeof window === \"undefined\") return;\n\n const consentState = getConsentState();\n if (consentState === \"granted\" || consentState === \"denied\")\n window.gtag?.(\"consent\", \"update\", buildConsentPayload(consentState));\n}\n\n/**\n * Load and initialize Google Ads tracking with Consent Mode support.\n *\n * Used by framework components and browser-script initialization.\n */\nexport function bootstrapGoogleAdsTracking(gtagId: string): void {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n if (!isValidGtagId(gtagId)) return;\n\n applyDefaultConsentState();\n loadGtagScript(gtagId);\n initializeGtag(gtagId);\n}\n\n/**\n * Load and initialize Google Ads tracking for ALL labelled gtag IDs.\n *\n * Every ID in the map gets a `gtag('config', ...)` call — gtag natively\n * supports multiple configured tags on the same page. The script loader\n * only runs once (for the first ID); subsequent IDs reuse the shared\n * `dataLayer`.\n */\nexport function bootstrapMultipleGtags(gtagIds: GtagEnvironmentMap): void {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n const ids = Object.values(gtagIds).filter(\n (id): id is string => typeof id === \"string\" && isValidGtagId(id),\n );\n if (ids.length === 0) return;\n\n applyDefaultConsentState();\n loadGtagScript(ids[0]!);\n\n // Call gtag('js') once, then gtag('config') per ID. initializeGtag\n // calls both, but gtag('js') should only fire once per page load.\n const gtag = ensureGtagFunction();\n gtag(\"js\", new Date());\n for (const id of ids) {\n gtag(\"config\", id);\n }\n}\n\n/**\n * Create an inline script that initializes gtag for multiple Google Ads tag ids.\n *\n * Used by the Next.js `GoogleAdsTracking` component when rendering `<Script>`\n * tags for a multi-gtag configuration.\n */\nexport function createGtagInitScriptMulti(gtagIds: string[]): string {\n const safeIds = gtagIds.filter(isValidGtagId);\n const configs = safeIds.map((id) => `gtag('config', '${id}');`).join(\"\\n\");\n return `\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\nwindow.gtag = gtag;\ngtag('js', new Date());\n${configs}\n`.trim();\n}\n","// Consent-gated, de-duped orchestration around the raw `fireGtagConversion` (GAP28).\n//\n// Consent v2 (opt-out model): fire unless the visitor's effective consent is an\n// explicit, unexpired decline. There is no \"pending\" state and therefore no\n// queue — the default visitor converts in real time.\n//\n// `fireGtagConversion` is itself browser-gated and never throws, so this is safe to call\n// from the isomorphic sales `record()` — server-side it simply no-ops.\n\nimport { getConsentState } from \"../consent\";\nimport { fireGtagConversion, isValidSendTo, type GtagConversionInput } from \"../gtag\";\nimport { applyUserDataForConversion, type ConversionUserData } from \"../user-data\";\n\nconst DEDUP_PREFIX = \"_aranova_conv_\";\n\nexport type ConversionFireOutcome = \"fired\" | \"denied\" | \"duplicate\" | \"invalid\" | \"retryable\";\n\nfunction dedupKey(input: GtagConversionInput): string {\n return `${DEDUP_PREFIX}${input.transactionId ?? \"\"}:${input.sendTo}`;\n}\n\n// Has this (transaction_id, send_to) already fired? Read-only — the key is written ONLY\n// after a successful fire (see `fireOnce`), so a fire that never happened (e.g. gtag not\n// ready yet) is never suppressed on retry. Best-effort: a blocked sessionStorage reads as\n// \"not fired\" so the conversion still gets a chance.\nfunction alreadyFired(input: GtagConversionInput): boolean {\n if (!input.transactionId || typeof window === \"undefined\") return false;\n try {\n return window.sessionStorage.getItem(dedupKey(input)) !== null;\n } catch {\n return false;\n }\n}\n\nfunction markFired(input: GtagConversionInput): void {\n if (!input.transactionId || typeof window === \"undefined\") return;\n try {\n window.sessionStorage.setItem(dedupKey(input), \"1\");\n } catch {\n // sessionStorage unavailable — Google's counting_type is the authoritative backstop\n }\n}\n\n/**\n * Fire a conversion through the consent gate + de-dup guard.\n *\n * Enhanced conversions: right before the fire (past the consent gate AND the\n * de-dup guard — a duplicate that won't emit an event must not mutate gtag's\n * page-level user_data either), gtag's `user_data` is set from the explicit\n * `options.userData` merged over the form-submit stash — this is the single\n * choke point all firing paths (recordSale, trackConversion, autofire) flow\n * through, so every conversion hit carries the visitor's hashed identifiers\n * when any are known. Re-setting before each fire is idempotent (gtag scopes\n * user_data to the page), so a later autofire on the same page matches too.\n * The de-dup key is written only AFTER a successful fire, so a fire that\n * never happened (gtag not ready) is never suppressed on retry.\n */\nexport function fireConversionWithConsent(\n input: GtagConversionInput,\n options?: { userData?: ConversionUserData | null },\n): ConversionFireOutcome {\n if (getConsentState() === \"denied\") return \"denied\";\n if (alreadyFired(input)) return \"duplicate\";\n if (!isValidSendTo(input.sendTo)) return \"invalid\";\n try {\n applyUserDataForConversion(options?.userData);\n if (!fireGtagConversion(input)) return \"retryable\";\n markFired(input);\n return \"fired\";\n } catch {\n return \"retryable\";\n }\n}\n\n/**\n * Flush conversions queued while consent was pending.\n *\n * @deprecated No-op since consent v2 — there is no \"pending\" state, so nothing\n * is ever queued. Kept exported so pre-v2 integrations keep compiling.\n */\nexport function flushPendingConversions(): void {\n // intentionally empty\n}\n","{\n \"hosts\": [\"myalp.io\"]\n}\n","// Provider-agnostic booking read over the published config (`integrations.*.booking`).\n// React-free: the hooks and the link live in `booking-react.tsx`.\n\nimport bookingLinks from \"../../../contracts/autoleap-booking-links.json\";\nimport { parsePhone } from \"./phone\";\nimport type {\n BookingDayWindows,\n BookingMode,\n BookingRulesConfig,\n ConversionIntegrations,\n} from \"./resources/conversion-config\";\n\n/** Hosts a handoff link may point at: the backend's short-link allowlist, from one contract. */\nconst HANDOFF_HOSTS = new Set<string>(bookingLinks.hosts);\n\nexport type BookingProvider = \"autoleap\";\n\n/** One service choice on the booking form, by the site's own key. */\nexport interface BookingOption {\n key: string;\n label: string;\n /** The shop's service category, for a heading over its choices; null when it has none. */\n group: string | null;\n /** Optional helper text to show under the label. */\n description: string | null;\n}\n\n/** One heading on the form and the choices under it; `group` null holds the ungrouped ones. */\nexport interface BookingOptionGroup {\n group: string | null;\n options: BookingOption[];\n}\n\n/** The booking rules in camelCase, all in the shop's wall time. */\nexport interface BookingRules {\n minDaysAhead: number;\n maxDaysAhead: number;\n /** Start hours per weekday, `monday` … `sunday`; both lists empty = closed. */\n hours: Record<string, BookingDayWindows>;\n /** `YYYY-MM-DD` shop dates with no bookings although the weekday is open. */\n closedDates: string[];\n /** `YYYY-MM-DD` shop dates whose start hours replace their weekday's. */\n specialHours: Record<string, BookingDayWindows>;\n vehicleRequired: boolean;\n /** The vehicle may come after booking: send it with `bookings.updateBooking`. */\n vehicleDeferred: boolean;\n /** `waitForIt` / `dropOff`; empty when the shop hides the question. */\n dropOffOptions: string[];\n}\n\nexport interface BookingIntegration {\n provider: BookingProvider;\n /** False: render only the handoff link. Never block the customer on it. */\n enabled: boolean;\n /** The shop's own booking page. */\n fallbackUrl: string;\n /** True when the site may post a booking: `enabled`, rules captured and options mapped. */\n acceptsBookings: boolean;\n /** The shop's IANA timezone; every date and hour here is its wall time. */\n timezone: string;\n /** Sent back with each booking; a stale one is refused and the config refetched. */\n configVersion: number | null;\n rules: BookingRules | null;\n options: BookingOption[];\n /**\n * Where a booking from this page goes: `live` in production; on a development config `test`\n * (the shop's test lead source) or `simulated` (never sent). Show a banner when not `live`.\n */\n mode: BookingMode;\n}\n\n/** What the visitor already typed, carried onto the shop's booking page. */\nexport interface BookingHandoffPrefill {\n /** A single full-name field; split on the first space when `firstName` is not given. */\n name?: string | null;\n firstName?: string | null;\n lastName?: string | null;\n /** Any format; sent as national digits (no country code), as the shop's portal stores it. */\n phone?: string | null;\n email?: string | null;\n}\n\n/** The enabled-or-fallback booking provider in a config's integrations, if any. */\nexport function getBookingIntegration(\n integrations: ConversionIntegrations | null | undefined,\n): BookingIntegration | null {\n const booking = integrations?.autoleap?.booking;\n if (!booking) return null;\n const rules = booking.rules ? camelRules(booking.rules) : null;\n return {\n provider: \"autoleap\",\n enabled: booking.enabled,\n fallbackUrl: booking.fallback_url,\n acceptsBookings: booking.accepts_bookings && rules !== null && booking.config_version !== null,\n timezone: booking.timezone,\n configVersion: booking.config_version,\n rules,\n options: booking.options.map((option) => ({\n key: option.key,\n label: option.label,\n group: option.group,\n description: option.description,\n })),\n mode: booking.mode ?? \"live\",\n };\n}\n\nfunction camelRules(rules: BookingRulesConfig): BookingRules {\n return {\n minDaysAhead: rules.min_days_ahead,\n maxDaysAhead: rules.max_days_ahead,\n hours: rules.hours,\n closedDates: rules.closed_dates,\n specialHours: rules.special_hours,\n vehicleRequired: rules.vehicle_required,\n vehicleDeferred: rules.vehicle_deferred,\n dropOffOptions: rules.drop_off_options,\n };\n}\n\nconst WEEKDAYS = [\"sunday\", \"monday\", \"tuesday\", \"wednesday\", \"thursday\", \"friday\", \"saturday\"];\n\n/** One bookable day: its start hours split as the shop's portal shows them. */\nexport interface BookableDay {\n /** `YYYY-MM-DD`, shop-local. */\n date: string;\n /** `monday` … `sunday`. */\n weekday: string;\n /** Start hours before noon (8 = 08:00). */\n morning: number[];\n /** Start hours from noon on. */\n afternoon: number[];\n}\n\n/** Today's date in `timeZone` as `YYYY-MM-DD`; the server judges notice by the same date. */\nexport function shopToday(timeZone: string, now: Date = new Date()): string {\n const parts = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n }).formatToParts(now);\n const part = (type: string) => parts.find((p) => p.type === type)?.value ?? \"\";\n return `${part(\"year\")}-${part(\"month\")}-${part(\"day\")}`;\n}\n\nfunction addDays(isoDate: string, days: number): string {\n const [year, month, day] = isoDate.split(\"-\").map(Number);\n const date = new Date(Date.UTC(year, month - 1, day + days));\n return date.toISOString().slice(0, 10);\n}\n\n/**\n * Every day a customer may book, from the minimum notice to the end of the window, with its\n * start hours. Exactly what the server accepts: closed dates removed, special hours applied.\n */\nexport function bookableSlots(\n rules: BookingRules,\n { timeZone, now = new Date() }: { timeZone: string; now?: Date },\n): BookableDay[] {\n const today = shopToday(timeZone, now);\n const days: BookableDay[] = [];\n const closed = new Set(rules.closedDates);\n for (let offset = rules.minDaysAhead; offset <= rules.maxDaysAhead; offset += 1) {\n const date = addDays(today, offset);\n if (closed.has(date)) continue;\n const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()];\n const windows = rules.specialHours[date] ?? rules.hours[weekday];\n if (!windows || (windows.morning.length === 0 && windows.afternoon.length === 0)) continue;\n days.push({\n date,\n weekday,\n morning: [...windows.morning],\n afternoon: [...windows.afternoon],\n });\n }\n return days;\n}\n\n/**\n * The handoff URL with the visitor's details prefilled, or null when `fallbackUrl` is not an\n * https link on the shop's booking host (a config value never becomes an arbitrary redirect).\n */\nexport function buildBookingHandoffUrl(\n fallbackUrl: string,\n prefill: BookingHandoffPrefill = {},\n): string | null {\n let url: URL;\n try {\n url = new URL(fallbackUrl);\n } catch {\n return null;\n }\n if (\n url.protocol !== \"https:\" ||\n !HANDOFF_HOSTS.has(url.hostname) ||\n url.username ||\n url.password ||\n url.port\n ) {\n return null;\n }\n const [firstName, lastName] = prefill.firstName?.trim()\n ? [prefill.firstName, prefill.lastName]\n : splitFullName(prefill.name);\n const values: Array<[string, string | null | undefined]> = [\n [\"firstName\", firstName?.trim()],\n [\"lastName\", lastName?.trim()],\n [\"phone\", prefill.phone ? nationalDigits(prefill.phone) : \"\"],\n [\"email\", prefill.email?.trim()],\n ];\n for (const [key, value] of values) {\n if (value) url.searchParams.set(key, value);\n }\n return url.toString();\n}\n\nfunction splitFullName(name: string | null | undefined): [string, string] {\n const [first = \"\", ...rest] = (name ?? \"\").trim().split(/\\s+/);\n return [first, rest.join(\" \")];\n}\n\n/** \"+1 (416) 555-0100\" → \"4165550100\"; an unparseable value keeps its raw digits. */\nfunction nationalDigits(phone: string): string {\n const parsed = parsePhone(phone);\n return (parsed.isValid ? parsed.national : phone).replace(/\\D/g, \"\");\n}\n\n/**\n * The options as headed sections, in published order: one per category, choices in order\n * under it. The API saves them grouped, so a category is never split; this keeps the first run.\n */\nexport function groupBookingOptions(options: readonly BookingOption[]): BookingOptionGroup[] {\n const groups = new Map<string | null, BookingOption[]>();\n for (const option of options) {\n const members = groups.get(option.group);\n if (members) members.push(option);\n else groups.set(option.group, [option]);\n }\n return [...groups].map(([group, members]) => ({ group, options: members }));\n}\n","// GENERATED FILE — DO NOT EDIT BY HAND.\n// Regenerate from apps/api with:\n// poetry run python scripts/generate_lead_form_contract.py\n// Source of truth: apps/api/src/services/lead_forms/ (ADR-0024). Pinned by\n// lead-forms.contract.test.ts and test_lead_form_contract.py.\n\nimport * as z from \"zod/mini\";\n\n/** Bump-on-change registry version; travels on every contract-typed form_submit. */\nexport const LEAD_FORM_REGISTRY_VERSION = 2;\n\nexport const LEAD_FORM_KINDS = [\"booking\", \"quote\", \"contact\", \"consult\", \"callback\"] as const;\n/** What a form is for; `form_id` stays the instance handle. */\nexport type LeadFormKind = (typeof LEAD_FORM_KINDS)[number];\n\nexport const LEAD_FORM_ROLES = [\n \"given_name\",\n \"family_name\",\n \"phone\",\n \"email\",\n \"service\",\n \"preferred_date\",\n \"preferred_time\",\n \"note\",\n \"visit\",\n \"intent\",\n \"vehicle\",\n \"drop_off\",\n \"address\",\n \"patient_type\",\n \"matter\",\n \"language\",\n \"products\",\n \"goal\",\n \"business_type\",\n \"lead_volume\",\n \"attribution\",\n] as const;\n/** A role from the registry, or `add_on:<key>` for a yes/no extra with its own label. */\nexport type LeadFormRole = (typeof LEAD_FORM_ROLES)[number] | `add_on:${string}`;\n\nexport const LEAD_FORM_PRIMITIVES = [\n \"string\",\n \"text\",\n \"enum\",\n \"multi_enum\",\n \"bool\",\n \"date\",\n \"time_window\",\n \"phone\",\n \"email\",\n \"address\",\n \"number\",\n] as const;\nexport type LeadFormPrimitive = (typeof LEAD_FORM_PRIMITIVES)[number];\n\n/** The wire schema of each primitive's value. */\n// Pure, so a bundle that only reads the version or the types drops the schemas.\nexport const leadFormPrimitiveSchemas = /* @__PURE__ */ (() =>\n ({\n string: z.string().check(z.maxLength(120)),\n text: z.string().check(z.maxLength(2000)),\n enum: z.string().check(z.regex(/^[a-z0-9-]{1,48}$/)),\n multi_enum: z.array(z.string().check(z.regex(/^[a-z0-9-]{1,48}$/))).check(z.maxLength(10)),\n bool: z.boolean(),\n date: z.string().check(z.regex(/^\\d{4}-\\d{2}-\\d{2}$/)),\n time_window: z.strictObject({\n start: z.string().check(z.regex(/^([01]\\d|2[0-3]):[0-5]\\d$/)),\n end: z.nullable(z.string().check(z.regex(/^([01]\\d|2[0-3]):[0-5]\\d$/))),\n label: z.optional(z.string().check(z.maxLength(60))),\n }),\n phone: z.string().check(z.regex(/^\\+[1-9]\\d{6,14}$/)),\n email: z.string().check(z.maxLength(320), z.regex(/^[^\\s@]+@[^\\s@]+$/)),\n address: z.strictObject({\n line1: z.string().check(z.maxLength(120)),\n line2: z.optional(z.string().check(z.maxLength(120))),\n city: z.optional(z.string().check(z.maxLength(120))),\n region: z.optional(z.string().check(z.maxLength(120))),\n postal_code: z.optional(z.string().check(z.maxLength(120))),\n country: z.optional(z.string().check(z.maxLength(120))),\n }),\n number: z.number(),\n }) as const)();\n\nexport interface LeadFormRoleOption {\n key: string;\n label: string;\n isNonAnswer?: true;\n}\n\nexport interface LeadFormRoleSpec {\n primitive: LeadFormPrimitive;\n smsLabel: string;\n contactIdentity?: true;\n uninteresting?: true;\n options?: readonly LeadFormRoleOption[];\n}\n\n/** Every role's primitive and text label; `add_on` is the template for `add_on:<key>`. */\nexport const LEAD_FORM_ROLE_SPECS = {\n given_name: { primitive: \"string\", smsLabel: \"\", contactIdentity: true },\n family_name: { primitive: \"string\", smsLabel: \"\", contactIdentity: true },\n phone: { primitive: \"phone\", smsLabel: \"\", contactIdentity: true },\n email: { primitive: \"email\", smsLabel: \"\", contactIdentity: true },\n service: { primitive: \"multi_enum\", smsLabel: \"Service\" },\n preferred_date: { primitive: \"date\", smsLabel: \"Date\" },\n preferred_time: { primitive: \"time_window\", smsLabel: \"Time\" },\n note: { primitive: \"text\", smsLabel: \"Note\" },\n visit: {\n primitive: \"enum\",\n smsLabel: \"Visit\",\n options: [\n { key: \"in-shop\", label: \"In shop\" },\n { key: \"in-home\", label: \"In home\" },\n { key: \"remote\", label: \"Remote\" },\n { key: \"on-site\", label: \"On site\" },\n ],\n },\n intent: {\n primitive: \"enum\",\n smsLabel: \"Wants\",\n options: [\n { key: \"book\", label: \"Book\" },\n { key: \"quote\", label: \"Quote\" },\n ],\n },\n vehicle: { primitive: \"string\", smsLabel: \"Vehicle\" },\n drop_off: {\n primitive: \"enum\",\n smsLabel: \"Drop-off\",\n options: [\n { key: \"wait-for-it\", label: \"Waiting\" },\n { key: \"drop-off\", label: \"Dropping off\" },\n ],\n },\n address: { primitive: \"address\", smsLabel: \"Address\" },\n patient_type: { primitive: \"enum\", smsLabel: \"Patient\" },\n matter: { primitive: \"enum\", smsLabel: \"Matter\" },\n language: { primitive: \"enum\", smsLabel: \"Language\" },\n products: { primitive: \"multi_enum\", smsLabel: \"Products\" },\n goal: { primitive: \"enum\", smsLabel: \"Goal\" },\n business_type: { primitive: \"enum\", smsLabel: \"Business\" },\n lead_volume: { primitive: \"enum\", smsLabel: \"Lead volume\" },\n add_on: { primitive: \"bool\", smsLabel: \"\" },\n attribution: { primitive: \"enum\", smsLabel: \"\", uninteresting: true },\n} as const satisfies Record<string, LeadFormRoleSpec>;\n","/** A UUID v4 from Web Crypto, with a `getRandomValues` (then `Math.random`) fallback. */\nexport function uuidV4(): string {\n const cryptoApi = globalThis.crypto;\n if (typeof cryptoApi?.randomUUID === \"function\") return cryptoApi.randomUUID();\n const bytes = new Uint8Array(16);\n if (typeof cryptoApi?.getRandomValues === \"function\") cryptoApi.getRandomValues(bytes);\n else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n","// Visitor + session identity for the tracking SDK.\n//\n// Visitor: persistent localStorage UUID, never expires until the user clears\n// browser storage. Used for cross-session correlation.\n//\n// Session: rolling 30-minute idle window. Regenerated when more than\n// SESSION_IDLE_MS has passed since the last event. Matches the behavior of\n// GA4, PostHog, Mixpanel, etc., so analytics is comparable.\n\nimport { clearLandingRecord } from \"./landing\";\nimport { uuidV4 } from \"./uuid\";\n\n/**\n * localStorage key for the persistent visitor id.\n */\nexport const VISITOR_STORAGE_KEY = \"aranova_tracking_visitor\";\n\n/**\n * localStorage key for the rolling session id state.\n */\nexport const SESSION_STORAGE_KEY = \"aranova_tracking_session\";\n\n/**\n * Idle window before a new session id is created.\n */\nexport const SESSION_IDLE_MS = 30 * 60 * 1000;\n\n/**\n * Serialized session state stored in localStorage.\n */\nexport interface StoredSession {\n /** Client-generated session UUID. */\n id: string;\n /** Unix timestamp in milliseconds for the most recent event/session touch. */\n last_event_at: number;\n}\n\nfunction readLocalStorage(key: string): string | null {\n try {\n return window.localStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeLocalStorage(key: string, value: string): void {\n try {\n window.localStorage.setItem(key, value);\n } catch {\n // Storage may be denied (private mode, blocked cookies, quota). Caller is\n // responsible for degrading gracefully.\n }\n}\n\n/**\n * Return the persistent visitor id for this browser profile.\n *\n * Creates and stores a new id when one does not already exist. During SSR,\n * returns an ephemeral id because browser storage is unavailable.\n */\nexport function getVisitorId(): string {\n if (typeof window === \"undefined\") return uuidV4();\n\n const existing = readLocalStorage(VISITOR_STORAGE_KEY);\n if (existing && existing.length > 0) return existing;\n\n const fresh = uuidV4();\n writeLocalStorage(VISITOR_STORAGE_KEY, fresh);\n return fresh;\n}\n\n/** Adopt `visitorId` only when this browser has none — an existing id is never orphaned. */\nexport function adoptVisitorId(visitorId: string): boolean {\n if (typeof window === \"undefined\" || visitorId.length === 0) return false;\n const existing = readLocalStorage(VISITOR_STORAGE_KEY);\n if (existing && existing.length > 0) return false;\n writeLocalStorage(VISITOR_STORAGE_KEY, visitorId);\n return readLocalStorage(VISITOR_STORAGE_KEY) === visitorId;\n}\n\n/**\n * Result from `getOrRotateSessionId()`.\n */\nexport interface SessionIdResult {\n /** Current session id. */\n id: string;\n /** Whether this call created a new session. */\n isNew: boolean;\n}\n\n/**\n * Return the current session id, rotating it after the idle window expires.\n *\n * Also refreshes `last_event_at` for active sessions.\n */\nexport function getOrRotateSessionId(now: number = Date.now()): SessionIdResult {\n if (typeof window === \"undefined\") return { id: uuidV4(), isNew: true };\n\n const raw = readLocalStorage(SESSION_STORAGE_KEY);\n if (raw) {\n try {\n const parsed = JSON.parse(raw) as Partial<StoredSession>;\n if (typeof parsed.id === \"string\" && typeof parsed.last_event_at === \"number\") {\n if (now - parsed.last_event_at <= SESSION_IDLE_MS) {\n const refreshed: StoredSession = { id: parsed.id, last_event_at: now };\n writeLocalStorage(SESSION_STORAGE_KEY, JSON.stringify(refreshed));\n return { id: parsed.id, isNew: false };\n }\n }\n } catch {\n // Fall through to a fresh session.\n }\n }\n\n const fresh: StoredSession = { id: uuidV4(), last_event_at: now };\n writeLocalStorage(SESSION_STORAGE_KEY, JSON.stringify(fresh));\n return { id: fresh.id, isNew: true };\n}\n\n/** This browser's visitor id, read without creating one; `null` before tracking has run. */\nexport function peekVisitorId(): string | null {\n if (typeof window === \"undefined\") return null;\n const existing = readLocalStorage(VISITOR_STORAGE_KEY);\n return existing && existing.length > 0 ? existing : null;\n}\n\n/**\n * The live session id, read without refreshing or rotating it; `null` once the idle window\n * has lapsed, since the next event would start a new session anyway.\n */\nexport function peekSessionId(now: number = Date.now()): string | null {\n if (typeof window === \"undefined\") return null;\n const raw = readLocalStorage(SESSION_STORAGE_KEY);\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw) as Partial<StoredSession>;\n if (typeof parsed.id !== \"string\" || typeof parsed.last_event_at !== \"number\") return null;\n return now - parsed.last_event_at <= SESSION_IDLE_MS ? parsed.id : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Clear visitor and session identity from localStorage, including the\n * session-scoped landing record (a landing must never outlive its session).\n *\n * Intended for tests, debugging, and explicit user reset flows.\n */\nexport function resetTrackingIdentity(): void {\n clearLandingRecord();\n if (typeof window === \"undefined\") return;\n try {\n window.localStorage.removeItem(VISITOR_STORAGE_KEY);\n window.localStorage.removeItem(SESSION_STORAGE_KEY);\n } catch {\n // ignored\n }\n}\n","/**\n * Thrown on a non-2xx from any awaited Aranova API call.\n *\n * Lives here rather than inside one resource because more than one resource\n * needs it, and a duplicate class would be a genuine hazard: a name star-exported\n * from two modules is silently dropped unless both resolve to the same binding.\n */\nexport class AranovaApiError extends Error {\n readonly status: number;\n readonly code: string | undefined;\n readonly requestId: string | undefined;\n /** The error envelope's machine-readable `context`, when the API sent one. */\n readonly context: Record<string, unknown> | undefined;\n\n constructor(\n message: string,\n options: {\n status: number;\n code?: string;\n requestId?: string;\n context?: Record<string, unknown>;\n },\n ) {\n super(message);\n this.name = \"AranovaApiError\";\n this.status = options.status;\n this.code = options.code;\n this.requestId = options.requestId;\n this.context = options.context;\n }\n}\n","/**\n * Format an ISO timestamp in a specific IANA time zone (e.g. `America/Toronto`),\n * so dashboards stop hand-rolling `Intl`. Defaults to a short date-time; pass\n * `opts` to override fields. The `timeZone` is always forced to the argument.\n */\nexport function formatDateInTz(\n iso: string,\n timeZone: string,\n opts?: Intl.DateTimeFormatOptions,\n locale?: string,\n): string {\n const date = new Date(iso);\n // Guard malformed input: Intl.format(Invalid Date) throws a RangeError.\n if (Number.isNaN(date.getTime())) return iso;\n return new Intl.DateTimeFormat(locale, {\n year: \"numeric\",\n month: \"short\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n ...opts,\n timeZone,\n }).format(date);\n}\n","import type { SupportedCurrency } from \"./schema\";\n\n/**\n * Money conversion helpers.\n *\n * The API speaks **integer minor units (cents)** exclusively. These helpers move\n * between a human-facing major amount (dollars) and cents, and format cents for\n * display — so a consumer always has both representations without float math.\n */\n\n// Minor-unit exponent per supported currency (USD/CAD = 2 decimal places).\nconst MINOR_UNIT_EXPONENT: Record<SupportedCurrency, number> = {\n USD: 2,\n CAD: 2,\n};\n\nfunction exponentFor(currency: SupportedCurrency): number {\n return MINOR_UNIT_EXPONENT[currency] ?? 2;\n}\n\n/**\n * Convert a major amount (dollars `250.5`) to integer minor units (`25050`).\n *\n * Convenience only — the wire is always integer cents. Uses float multiply +\n * `Math.round`, so values that aren't exactly representable in binary float\n * (e.g. `1.005`) can round to the neighbouring cent. If you already hold an\n * exact cents integer, pass it straight through and skip this helper.\n */\nexport function toMinor(amount: number, currency: SupportedCurrency): number {\n return Math.round(amount * 10 ** exponentFor(currency));\n}\n\n/** Convert integer minor units (`25050`) to a major amount (`250.5`). */\nexport function fromMinor(cents: number, currency: SupportedCurrency): number {\n return cents / 10 ** exponentFor(currency);\n}\n\n/**\n * Format integer minor units as a localized currency string (e.g. `\"$250.50\"`).\n * Uses the built-in `Intl.NumberFormat` — no extra dependency.\n */\nexport function formatMoney(cents: number, currency: SupportedCurrency, locale?: string): string {\n return new Intl.NumberFormat(locale, { style: \"currency\", currency }).format(\n fromMinor(cents, currency),\n );\n}\n\n// Hoisted to `resources/intl/date` so the calendar resource shares the same\n// binding; still public API via `sales-public.ts`.\nexport { formatDateInTz } from \"../intl/date\";\n","import { stripLinkParam } from \"../cross-site-link\";\nimport { applyDefaultConsentState, isValidGtagId, loadGtagScript } from \"../gtag\";\nimport {\n parseConversionConfig,\n type ConversionConfig,\n type ConversionGoal,\n type ConversionIntegrations,\n type LeadFormsConfig,\n} from \"./conversion-config\";\nimport { fireConversionWithConsent } from \"./conversion-firing\";\nimport { getAutomaticTransactionId } from \"./automatic-transaction\";\nimport { automaticThresholdMet } from \"./automatic-trigger\";\nimport { fromMinor } from \"./sales/money\";\nimport type { SupportedCurrency } from \"./sales/schema\";\n\n/**\n * Where a business's published tracking config lives.\n *\n * `businessId` + `environment` are the stable identity; the ORIGIN it is fetched\n * from is deployment-specific (production CDN vs. a local object store), which is\n * why `cdnBaseUrl` exists. Codegen bakes only the identity, so one committed\n * generated file works in every environment and only an env var changes.\n *\n * `cdnUrl` remains supported and WINS when present — every client repo generated\n * before `cdnBaseUrl` passes one, and those must keep working untouched.\n */\nexport interface TrackingConfigReference {\n /**\n * Fully-resolved object URL. Optional: omit it and pass `cdnBaseUrl` (or rely\n * on the production default) to have it composed from the identity below.\n */\n cdnUrl?: string;\n /**\n * Origin (optionally with a path prefix) the config object is served from, e.g.\n * `https://demos.aranova.io` in production or\n * `http://localhost:9100/aranova-demos` against a local MinIO. Ignored when\n * `cdnUrl` is set; blank or omitted falls back to {@link DEFAULT_CDN_BASE_URL}.\n * `tracking-cli gen` wires this to an env var so retargeting is config, not code.\n */\n cdnBaseUrl?: string;\n businessId: string;\n /**\n * The site whose config object to load (ENG-284). Selects the per-site\n * `tracking-config/v1/sites/` key; omitted (older generated files) resolves\n * the legacy per-business key, which is the primary site's config.\n */\n siteId?: string;\n environment: \"production\" | \"test\";\n}\n\n/** Production object-storage origin — the default when nothing overrides it. */\nexport const DEFAULT_CDN_BASE_URL = \"https://demos.aranova.io\";\n\n/**\n * Object key for a config, mirroring the backend's\n * `core/storage.py::tracking_config_key` (per-site) and its legacy per-business\n * key. The backend stays the authority: it\n * publishes at this key and returns the absolute URL, which is what the CLI bakes\n * as the legacy `cdnUrl`. This is the composition path for references that carry\n * identity instead of a URL — the `v1` prefix is the schema MAJOR and must move in\n * lockstep with the backend if it is ever cut to `v2`.\n */\nfunction trackingConfigKey(ref: TrackingConfigReference): string {\n if (ref.siteId) return `tracking-config/v1/sites/${ref.siteId}-${ref.environment}.json`;\n return `tracking-config/v1/${ref.businessId}-${ref.environment}.json`;\n}\n\n/**\n * The URL a reference resolves to. An explicit `cdnUrl` wins (back-compat);\n * otherwise compose from `cdnBaseUrl` (or the production default).\n *\n * A blank/whitespace `cdnBaseUrl` is treated as UNSET rather than composed: the\n * generated module reads it from an env var, and a var that is present-but-empty\n * (a stray `NEXT_PUBLIC_ARANOVA_CDN_BASE_URL=` line) would otherwise produce\n * `/tracking-config/...` — a same-origin request to the client's own site.\n */\nexport function resolveTrackingConfigUrl(ref: TrackingConfigReference): string {\n if (ref.cdnUrl) return ref.cdnUrl;\n const configured = ref.cdnBaseUrl?.trim();\n const base = (configured || DEFAULT_CDN_BASE_URL).replace(/\\/+$/, \"\");\n return `${base}/${trackingConfigKey(ref)}`;\n}\n\ntype RuntimeState = \"unconfirmed\" | \"active\" | \"tombstone\";\n\ninterface CachedEntry {\n etag: string | null;\n config: ConversionConfig;\n}\n\ninterface QueuedConversion {\n key: string;\n value?: number | null;\n currency?: string | null;\n transactionId?: string | null;\n}\n\nexport type TrackingConfigConversionOptions = Omit<QueuedConversion, \"key\">;\n\ninterface QueuedPageView {\n href: string;\n title: string | null;\n referrer: string | null;\n}\n\ninterface QueuedAutomaticEvent {\n eventType: string;\n metadata: Record<string, unknown>;\n transactionPath: string;\n transactionScope: string;\n}\n\nconst CACHE_PREFIX = \"_aranova_cfg_runtime_\";\nconst AUTHORITY_TTL_MS = 60_000;\n// Queues are bounded rather than cleared while authority is unconfirmed: the\n// config can appear at any time (a first publish, or R2 recovering), and the\n// most recent items are still worth firing when it does. Unbounded, a site whose\n// config 404s accumulates an entry per page view for the life of the tab.\nconst MAX_QUEUE_LENGTH = 50;\n// While unconfirmed, revalidation is spaced by a growing delay instead of riding\n// every queued item. Without it a missing config produces one no-cache request\n// per page view, forever. `focus` / `visibilitychange` still revalidate, so\n// recovery stays prompt once the object exists.\nconst RETRY_BASE_MS = 5_000;\nconst RETRY_MAX_MS = 5 * 60_000;\n\nconst runtimes = new Map<string, TrackingConfigRuntime>();\nconst configuredIds = new Set<string>();\nlet scriptLoad: Promise<void> | null = null;\nlet jsInitialized = false;\n\nfunction cacheKey(url: string): string {\n return `${CACHE_PREFIX}${url}`;\n}\n\n/** Append, dropping the oldest entry once the cap is reached. */\nfunction pushBounded<T>(queue: T[], item: T): void {\n queue.push(item);\n if (queue.length > MAX_QUEUE_LENGTH) queue.splice(0, queue.length - MAX_QUEUE_LENGTH);\n}\n\nfunction readCache(url: string): CachedEntry | null {\n if (typeof window === \"undefined\") return null;\n try {\n const raw = window.sessionStorage.getItem(cacheKey(url));\n if (!raw) return null;\n const parsed = JSON.parse(raw) as { etag?: unknown; config?: unknown };\n const config = parseConversionConfig(parsed.config);\n if (!config) return null;\n return {\n etag: typeof parsed.etag === \"string\" ? parsed.etag : null,\n config,\n };\n } catch {\n return null;\n }\n}\n\nfunction writeCache(url: string, entry: CachedEntry): void {\n if (typeof window === \"undefined\") return;\n try {\n window.sessionStorage.setItem(cacheKey(url), JSON.stringify(entry));\n } catch {\n // sessionStorage unavailable — runtime still works, just without a conditional candidate\n }\n}\n\nfunction isTombstone(config: ConversionConfig): boolean {\n return config.google_tracking_state === \"disabled\" || Object.keys(config.gtag_ids).length === 0;\n}\n\nfunction validateConfig(config: ConversionConfig, ref: TrackingConfigReference): boolean {\n return (\n config.business_id === ref.businessId &&\n config.environment === ref.environment &&\n (config.google_tracking_state === \"active\" || config.google_tracking_state === \"disabled\")\n );\n}\n\nfunction pageViewSnapshot(): QueuedPageView | null {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return null;\n return {\n href: stripLinkParam(window.location.href),\n title: document.title || null,\n referrer: document.referrer || null,\n };\n}\n\nfunction ensureScript(gtagId: string): Promise<void> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return Promise.resolve();\n applyDefaultConsentState();\n loadGtagScript(gtagId);\n if (scriptLoad) return scriptLoad;\n scriptLoad = new Promise((resolve) => {\n const script = document.querySelector<HTMLScriptElement>(\n 'script[data-aranova-tracking=\"aranova-gtag-loader\"]',\n );\n if (!script) {\n resolve();\n return;\n }\n if ((script as HTMLScriptElement & { dataset: DOMStringMap }).dataset.loaded === \"true\") {\n resolve();\n return;\n }\n window.setTimeout(resolve, 0);\n script.addEventListener(\n \"load\",\n () => {\n script.dataset.loaded = \"true\";\n resolve();\n },\n { once: true },\n );\n script.addEventListener(\"error\", () => resolve(), { once: true });\n });\n return scriptLoad;\n}\n\nexport class TrackingConfigRuntime {\n private readonly fetchImpl: typeof fetch;\n /** Resolved config URL — see the constructor for why it is computed once. */\n private readonly url: string;\n private current: ConversionConfig | null = null;\n private etag: string | null = null;\n private stateValue: RuntimeState = \"unconfirmed\";\n private confirmedAt = 0;\n private authorityGeneration = 0;\n private inFlight: Promise<void> | null = null;\n private flushInFlight: Promise<void> | null = null;\n private flushRequested = false;\n private retryTimer: number | null = null;\n private started = false;\n /** Consecutive failed authority attempts — drives the revalidate backoff. */\n private authorityFailures = 0;\n /** Epoch ms before which `ensureAuthority` must not issue another request. */\n private nextAuthorityAttemptAt = 0;\n private readonly conversionQueue: QueuedConversion[] = [];\n private readonly automaticQueue: QueuedAutomaticEvent[] = [];\n private readonly pageQueue: QueuedPageView[] = [];\n private readonly listeners = new Set<() => void>();\n\n constructor(\n readonly ref: TrackingConfigReference,\n fetchImpl: typeof fetch = globalThis.fetch,\n ) {\n // Native browser fetch requires Window/WorkerGlobalScope as its receiver.\n // Calling an unbound native fetch stored on `this` throws \"Illegal invocation\".\n this.fetchImpl = fetchImpl.bind(globalThis);\n // Resolved once: the cache key, the fetch target, and the write-back key must\n // all be the same string, or a reference that composes its URL would read a\n // cache entry it never writes.\n this.url = resolveTrackingConfigUrl(ref);\n const cached = readCache(this.url);\n this.current = cached?.config ?? null;\n this.etag = cached?.etag ?? null;\n this.start();\n }\n\n /** The URL this runtime actually fetches (composed or explicit). */\n configUrl(): string {\n return this.url;\n }\n\n /**\n * Explicitly start authority resolution and Google-tag bootstrap.\n *\n * Idempotent so framework effects can call it after hydration without\n * depending on constructor timing.\n */\n start(): void {\n if (this.started) {\n void this.flush();\n return;\n }\n this.started = true;\n void this.revalidate();\n if (typeof window !== \"undefined\") {\n window.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState === \"visible\") void this.revalidate();\n });\n window.addEventListener(\"focus\", () => void this.revalidate());\n }\n }\n\n state(): RuntimeState {\n return this.stateValue;\n }\n\n config(): ConversionConfig | null {\n return this.stateValue === \"active\" || this.stateValue === \"tombstone\" ? this.current : null;\n }\n\n __unsafeExpireAuthorityForTests(): void {\n this.confirmedAt = Number.NEGATIVE_INFINITY;\n this.nextAuthorityAttemptAt = 0;\n }\n\n /** Queue depths — asserted by tests to pin the bound. */\n __queueDepthsForTests(): { conversions: number; automatic: number; pages: number } {\n return {\n conversions: this.conversionQueue.length,\n automatic: this.automaticQueue.length,\n pages: this.pageQueue.length,\n };\n }\n\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n async ensureAuthority(): Promise<boolean> {\n if (this.stateValue !== \"unconfirmed\" && Date.now() - this.confirmedAt < AUTHORITY_TTL_MS) {\n return true;\n }\n // Backoff applies only to this implicit path (driven by queued work). An\n // explicit `revalidate()` — the visibility/focus listeners, or a caller —\n // always goes to the network so recovery is never delayed by the timer.\n if (Date.now() < this.nextAuthorityAttemptAt) return false;\n await this.revalidateAuthority();\n return this.stateValue !== \"unconfirmed\" && Date.now() - this.confirmedAt < AUTHORITY_TTL_MS;\n }\n\n async revalidate(): Promise<void> {\n this.nextAuthorityAttemptAt = 0;\n await this.revalidateAuthority();\n await this.flush();\n }\n\n private async revalidateAuthority(): Promise<void> {\n if (typeof window === \"undefined\" || !this.fetchImpl) return;\n if (this.inFlight) return this.inFlight;\n this.inFlight = this.revalidateNow().finally(() => {\n this.inFlight = null;\n });\n return this.inFlight;\n }\n\n queuePageView(snapshot: QueuedPageView | null = pageViewSnapshot()): void {\n if (!snapshot) return;\n pushBounded(this.pageQueue, snapshot);\n void this.flush();\n }\n\n fireConversion(key: string, options?: TrackingConfigConversionOptions): void {\n pushBounded(this.conversionQueue, { key, ...options });\n void this.flush();\n }\n\n queueAutomaticEvent(\n eventType: string,\n metadata: Record<string, unknown>,\n transactionPath: string,\n transactionScope: string,\n ): void {\n pushBounded(this.automaticQueue, {\n eventType,\n metadata,\n transactionPath,\n transactionScope,\n });\n void this.flush();\n }\n\n listGoals(): ConversionGoal[] {\n return this.current?.goals ?? [];\n }\n\n /** Sibling hostnames for the linker — read from the cached object even while unconfirmed. */\n linkedHostnames(): readonly string[] {\n return this.current?.linked_hostnames ?? [];\n }\n\n /**\n * Integration blocks, from the cached object even while unconfirmed: the booking fallback\n * link is safe to show stale. Same reference until the next adopt, for `useSyncExternalStore`.\n */\n integrations(): ConversionIntegrations | null {\n return this.current?.integrations ?? null;\n }\n\n /** The lead-form contracts, from the cached object like `integrations()`. */\n leadForms(): LeadFormsConfig | null {\n return this.current?.lead_forms ?? null;\n }\n\n private async revalidateNow(): Promise<void> {\n try {\n const headers: Record<string, string> = {};\n if (this.etag) headers[\"If-None-Match\"] = this.etag;\n const response = await this.fetchImpl(this.url, {\n method: \"GET\",\n headers,\n cache: \"no-cache\",\n });\n if (response.status === 304 && this.current && validateConfig(this.current, this.ref)) {\n this.confirm(this.current, this.etag);\n return;\n }\n if (!response.ok) {\n this.expireAuthority();\n return;\n }\n const next = parseConversionConfig(await response.json());\n if (!next || !validateConfig(next, this.ref)) {\n this.expireAuthority();\n return;\n }\n if (this.current && next.config_version < this.current.config_version) {\n this.expireAuthority();\n return;\n }\n this.confirm(next, response.headers.get(\"ETag\"));\n } catch {\n this.expireAuthority();\n }\n }\n\n private expireAuthority(): void {\n this.authorityGeneration += 1;\n if (this.stateValue !== \"unconfirmed\") this.stateValue = \"unconfirmed\";\n // Exponential, capped. Queued work keeps accumulating (bounded), so the\n // config appearing later still fires the most recent items.\n this.authorityFailures += 1;\n const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (this.authorityFailures - 1));\n this.nextAuthorityAttemptAt = Date.now() + delay;\n }\n\n private confirm(config: ConversionConfig, etag: string | null): void {\n this.authorityGeneration += 1;\n this.current = config;\n this.etag = etag;\n this.confirmedAt = Date.now();\n this.authorityFailures = 0;\n this.nextAuthorityAttemptAt = 0;\n this.stateValue = isTombstone(config) ? \"tombstone\" : \"active\";\n writeCache(this.url, { etag, config });\n if (this.stateValue === \"tombstone\") {\n if (this.retryTimer !== null) {\n window.clearTimeout(this.retryTimer);\n this.retryTimer = null;\n }\n this.conversionQueue.length = 0;\n this.automaticQueue.length = 0;\n this.pageQueue.length = 0;\n }\n for (const listener of this.listeners) listener();\n }\n\n private async flush(): Promise<void> {\n if (this.flushInFlight) {\n this.flushRequested = true;\n return this.flushInFlight;\n }\n this.flushRequested = false;\n this.flushInFlight = this.flushNow().finally(() => {\n this.flushInFlight = null;\n if (this.flushRequested && this.stateValue === \"active\") void this.flush();\n });\n return this.flushInFlight;\n }\n\n private scheduleRetry(): void {\n if (this.retryTimer !== null || typeof window === \"undefined\") return;\n this.retryTimer = window.setTimeout(() => {\n this.retryTimer = null;\n void this.flush();\n }, 1000);\n }\n\n private async flushNow(): Promise<void> {\n if (!(await this.ensureAuthority())) return;\n if (this.stateValue !== \"active\" || !this.current) return;\n const config = this.current;\n const generation = this.authorityGeneration;\n const ids = Object.values(config.gtag_ids).filter(\n (id): id is string => typeof id === \"string\" && isValidGtagId(id),\n );\n if (ids.length === 0) return;\n await ensureScript(ids[0]!);\n if (\n this.authorityGeneration !== generation ||\n this.stateValue !== \"active\" ||\n this.current !== config\n ) {\n return;\n }\n const gtag = window.gtag;\n if (typeof gtag !== \"function\") return;\n try {\n if (!jsInitialized) {\n gtag(\"js\", new Date());\n jsInitialized = true;\n }\n for (const id of ids) {\n if (configuredIds.has(id)) continue;\n gtag(\"config\", id, { send_page_view: false });\n configuredIds.add(id);\n }\n } catch {\n this.scheduleRetry();\n return;\n }\n while (this.pageQueue.length > 0) {\n const page = this.pageQueue[0]!;\n try {\n gtag(\"event\", \"page_view\", {\n page_location: page.href,\n page_title: page.title ?? undefined,\n page_referrer: page.referrer ?? undefined,\n });\n this.pageQueue.shift();\n } catch {\n this.scheduleRetry();\n return;\n }\n }\n while (this.automaticQueue.length > 0) {\n const event = this.automaticQueue[0]!;\n for (const goal of config.goals) {\n if (goal.kind !== \"event\" || !goal.firing) continue;\n if (!automaticThresholdMet(goal, event.eventType, event.metadata)) continue;\n pushBounded(this.conversionQueue, {\n key: goal.key,\n transactionId: getAutomaticTransactionId(\n event.transactionScope,\n goal.key,\n event.transactionPath,\n ),\n });\n }\n this.automaticQueue.shift();\n }\n while (this.conversionQueue.length > 0) {\n const item = this.conversionQueue[0]!;\n const goal = config.goals.find((g) => g.key === item.key);\n const firing = goal?.firing;\n if (!firing) {\n this.conversionQueue.shift();\n continue;\n }\n const currency = item.currency ?? firing.currency ?? null;\n const value =\n item.value ??\n (firing.value_cents != null && currency\n ? fromMinor(firing.value_cents, currency as SupportedCurrency)\n : null);\n const outcome = fireConversionWithConsent({\n sendTo: firing.send_to,\n value,\n currency,\n transactionId: item.transactionId ?? null,\n });\n if (outcome === \"retryable\") {\n this.scheduleRetry();\n return;\n }\n this.conversionQueue.shift();\n }\n }\n}\n\nexport function getTrackingConfigRuntime(\n ref: TrackingConfigReference,\n fetchImpl?: typeof fetch,\n): TrackingConfigRuntime {\n // Keyed on the RESOLVED url: `{cdnUrl}` and an equivalent\n // `{cdnBaseUrl,businessId,environment}` name the same object and must share one\n // runtime, or each would keep its own authority state and gtag bootstrap.\n const key = `${resolveTrackingConfigUrl(ref)}|${ref.businessId}|${ref.environment}`;\n const existing = runtimes.get(key);\n if (existing) return existing;\n const runtime = new TrackingConfigRuntime(ref, fetchImpl ?? globalThis.fetch);\n runtimes.set(key, runtime);\n return runtime;\n}\n\nexport function resetTrackingConfigRuntimesForTests(): void {\n runtimes.clear();\n configuredIds.clear();\n scriptLoad = null;\n jsInitialized = false;\n}\n","// Tracking ingest client. Queues events, debounce-flushes them to the\n// /tracking/events endpoint, and degrades silently on errors so a broken\n// network never breaks the host site.\n\nimport { getRegisteredCapabilities } from \"./capabilities\";\nimport { getConsentChoice, getConsentState } from \"./consent\";\nimport { consumeInboundLink, stripLinkParam, type OriginLink } from \"./cross-site-link\";\nimport { getEventSemantics } from \"./events/semantics\";\nimport { resetPageViewState } from \"./page-view\";\nimport type { PhoneConfig } from \"./phone-field\";\nimport { captureFbc, getFbcCookie, getFbpCookie } from \"./fbq\";\nimport {\n captureTrackingParamsFromLocation,\n createEmptyTrackingParams,\n getCookieValueFromDocument,\n getTrackingParamsFromCookieReader,\n mergeTrackingParams,\n} from \"./tracking\";\nimport type { TriggerRegistryConfig } from \"./events/registry\";\nimport { buildHeartbeatMetadata } from \"./heartbeat\";\nimport { buildLandingPayloadFields, getOrCaptureLandingParams } from \"./landing\";\nimport { getOrRotateSessionId, getVisitorId } from \"./session\";\nimport { stashUserDataFromFormFields } from \"./user-data\";\nimport type {\n TrackingClientContext,\n TrackingEnvironment,\n TrackingInstallSurface,\n TrackingParams,\n TrackingSessionUpsertPayload,\n} from \"./types\";\n\n/**\n * Default debounce window before queued events are flushed.\n */\nexport const DEFAULT_FLUSH_INTERVAL_MS = 2000;\n\n/**\n * Default queue size that triggers an immediate flush.\n */\nexport const DEFAULT_MAX_QUEUE_SIZE = 10;\n\n/**\n * Hard server-side maximum event count per request body.\n */\nexport const HARD_MAX_BATCH = 50;\n\n/**\n * Ceiling on the exponential backoff between failed delivery attempts.\n */\nexport const MAX_RETRY_BACKOFF_MS = 60_000;\n\n/**\n * Cap on events held in the durable retry buffer. Oldest batches are dropped\n * first once this is exceeded — an unbounded buffer would eventually blow the\n * localStorage quota and take the whole SDK down with it.\n */\nexport const MAX_BUFFERED_EVENTS = 200;\n\n/**\n * localStorage key prefix for the durable retry buffer. Versioned so a future\n * shape change can't be misread as the current one.\n */\nexport const RETRY_BUFFER_KEY_PREFIX = \"aranova_tracking_pending_v1\";\n\n/**\n * What to do with a batch after an attempted delivery.\n *\n * `retry` covers the transport failing and the server saying \"later\" (408, 429,\n * 5xx). `drop` covers a permanent rejection — a 422 from a malformed payload\n * will never succeed, and retrying it forever would wedge every batch behind it.\n */\ntype DeliveryOutcome = \"ok\" | \"retry\" | \"drop\";\n\n/**\n * Header used to authenticate public tracking ingest requests.\n */\nexport const API_KEY_HEADER = \"X-Aranova-Api-Key\";\n\n/**\n * Identity headers stamped on every ingest request. Duplicate fields already\n * present in `session.context` but survive body-parse failures so the backend\n * can attribute 422s to the offending SDK install.\n */\nexport const SDK_VERSION_HEADER = \"X-Aranova-Sdk-Version\";\nexport const SDK_PACKAGE_HEADER = \"X-Aranova-Sdk-Package\";\nexport const SDK_SURFACE_HEADER = \"X-Aranova-Sdk-Surface\";\nexport const SDK_ENVIRONMENT_HEADER = \"X-Aranova-Sdk-Environment\";\n\n/**\n * Configuration for the low-level ingest client.\n *\n * Framework packages usually create this for you through `createTracking()`.\n */\nexport interface TrackingClientConfig {\n /** Public tracking API key issued for the business. */\n apiKey: string;\n /** Tracking endpoint base URL, usually ending in `/tracking`. */\n endpoint: string;\n /** SDK surface creating this client. */\n surface: TrackingInstallSurface;\n /** Package version reported in session context and heartbeat metadata. */\n sdkVersion?: string;\n /** Package name reported in session context and heartbeat metadata. */\n packageName?: string;\n /** Trigger registry so the heartbeat can report registered events. */\n triggers?: TriggerRegistryConfig;\n /** Override the default 2s debounce window. */\n flushIntervalMs?: number;\n /** Override the default 10-event batch trigger. */\n maxQueueSize?: number;\n /** Deployment environment label reported in session context. */\n environment?: TrackingEnvironment;\n /** All active gtag IDs, keyed by label. Included in session context. */\n activeGtagIds?: Record<string, string>;\n /** `siteId` of the tracking-config reference in use; reported on the heartbeat. */\n configSiteId?: string | null;\n /** When true, swallow nothing — useful for tests. */\n debug?: boolean;\n /** Phone-field config, carried for parity; the React hook reads it via context. */\n phone?: PhoneConfig;\n}\n\n/**\n * Input accepted by the low-level stringly-typed client.\n *\n * Prefer the typed `trackEvent(eventName, metadata)` facade exposed by\n * `useTracking()` in React/Next integrations.\n */\nexport interface TrackEventInput {\n /** Event name to enqueue. */\n eventType: string;\n /** URL associated with the event. Defaults to the current page URL. */\n pageUrl?: string | null;\n /** Event-specific metadata. */\n metadata?: Record<string, unknown> | null;\n /** Timestamp override. Defaults to queue time. */\n occurredAt?: Date | string | null;\n}\n\ninterface QueuedEvent {\n event_type: string;\n page_url: string | null;\n metadata: Record<string, unknown> | null;\n occurred_at: string | null;\n}\n\n/**\n * Low-level tracking client responsible for queueing and flushing events.\n */\nexport interface TrackingClient {\n /** Enqueue an event for batched delivery. */\n trackEvent: (input: TrackEventInput) => void;\n /** Flush queued events immediately (fetch, non-keepalive). */\n flush: () => Promise<void>;\n /**\n * Flush queued events through the keepalive transport so the request\n * survives document unload. Use from `pagehide`/`visibilitychange:hidden`\n * handlers — a plain `flush()` there is aborted by the browser on unload.\n */\n flushBeacon: () => void;\n /** Return the current rolling session id. */\n getSessionId: () => string;\n /** Return the persistent visitor id. */\n getVisitorId: () => string;\n /** Remove timers/listeners and prevent future flushes. */\n destroy: () => void;\n}\n\ninterface IngestRequestBody {\n session: TrackingSessionUpsertPayload;\n events: Array<{\n event_type: string;\n page_url: string | null;\n metadata: Record<string, unknown> | null;\n occurred_at: string | null;\n }>;\n}\n\nfunction buildContext(\n surface: TrackingInstallSurface,\n sdkVersion: string | null,\n packageName: string | null,\n environment: TrackingEnvironment,\n activeGtagIds: Record<string, string> | null,\n): TrackingClientContext {\n return {\n surface,\n sdk_version: sdkVersion,\n package_name: packageName,\n site_origin: typeof window === \"undefined\" ? null : window.location.origin,\n page_title: typeof document === \"undefined\" ? null : document.title || null,\n referrer: typeof document === \"undefined\" ? null : document.referrer || null,\n environment,\n active_gtag_ids: activeGtagIds,\n // Rebuilt per flush (this runs inside the payload builder), so a client\n // constructed later on a deeper route still gets reported.\n capabilities: getRegisteredCapabilities(),\n };\n}\n\nfunction readTrackingParams(): TrackingParams {\n if (typeof window === \"undefined\") return createEmptyTrackingParams();\n // Capture from URL on every read so the first event in a session reflects the\n // landing-page params even if the cookie helper hasn't run yet.\n try {\n captureTrackingParamsFromLocation();\n } catch {\n // ignore\n }\n return getTrackingParamsFromCookieReader(getCookieValueFromDocument);\n}\n\nfunction consentSnapshot(): Record<string, unknown> | null {\n try {\n // Effective consent + provenance so the dashboard can tell default-granted\n // (opt-out model, no interaction) apart from an explicit choice.\n const choice = getConsentChoice();\n return {\n state: choice.state,\n source: choice.source,\n updated_at: choice.updatedAt,\n expires_at: choice.expiresAt,\n };\n } catch {\n return null;\n }\n}\n\ninterface IdentityHeaders {\n sdkVersion: string;\n packageName: string;\n surface: string;\n environment: string;\n}\n\nasync function postWithFetch(\n url: string,\n body: string,\n apiKey: string,\n identity: IdentityHeaders,\n keepalive: boolean,\n): Promise<DeliveryOutcome> {\n // No fetch at all (a non-browser runtime): there is nothing to retry against,\n // and scheduling one would spin a timer forever. Give up on the batch.\n if (typeof fetch !== \"function\") return \"drop\";\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n [API_KEY_HEADER]: apiKey,\n [SDK_VERSION_HEADER]: identity.sdkVersion,\n [SDK_PACKAGE_HEADER]: identity.packageName,\n [SDK_SURFACE_HEADER]: identity.surface,\n [SDK_ENVIRONMENT_HEADER]: identity.environment,\n },\n body,\n keepalive,\n // CORS is open on the tracking endpoint; never send cookies.\n credentials: \"omit\",\n mode: \"cors\",\n });\n if (response.ok) return \"ok\";\n if (response.status === 408 || response.status === 429 || response.status >= 500) {\n return \"retry\";\n }\n // 401/403/422 and friends: the payload or the key is wrong, and will still\n // be wrong in 30 seconds.\n return \"drop\";\n } catch {\n // Network-level failure (offline, DNS, CORS preflight, aborted). Never\n // throws to the host site; the batch stays buffered for the next attempt.\n return \"retry\";\n }\n}\n\n/**\n * A batch awaiting delivery. Each one carries its OWN session snapshot: a batch\n * can outlive the session that produced it (buffered across a reload), and\n * re-sending it under whatever session is current would silently misattribute\n * those events.\n */\ninterface PendingBatch {\n session: TrackingSessionUpsertPayload;\n events: QueuedEvent[];\n}\n\nfunction retryBufferKey(apiKey: string, endpoint: string): string {\n return `${RETRY_BUFFER_KEY_PREFIX}:${apiKey}:${endpoint}`;\n}\n\nfunction readPendingBatches(key: string): PendingBatch[] {\n if (typeof window === \"undefined\") return [];\n try {\n const raw = window.localStorage.getItem(key);\n if (!raw) return [];\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed.filter(\n (entry): entry is PendingBatch =>\n typeof entry === \"object\" &&\n entry !== null &&\n \"session\" in entry &&\n Array.isArray((entry as PendingBatch).events),\n );\n } catch {\n // Storage blocked, or a corrupt/foreign value — start clean rather than\n // letting a bad read break the host site.\n return [];\n }\n}\n\n/**\n * Mirror the buffer to storage unconditionally.\n *\n * Deliberately no read-back verification: under partitioned storage a write can\n * succeed for this document and still not be visible to a verifying read, so a\n * verify-gate false-negatives exactly where durability matters most.\n */\nfunction writePendingBatches(key: string, batches: PendingBatch[]): void {\n if (typeof window === \"undefined\") return;\n try {\n if (batches.length === 0) window.localStorage.removeItem(key);\n else window.localStorage.setItem(key, JSON.stringify(batches));\n } catch {\n // Quota exceeded or storage blocked — in-memory retry still works.\n }\n}\n\n/** Drop the oldest batches until the buffer is back under the event cap. */\nfunction trimToBufferCap(batches: PendingBatch[]): PendingBatch[] {\n let total = batches.reduce((sum, batch) => sum + batch.events.length, 0);\n const trimmed = batches.slice();\n while (total > MAX_BUFFERED_EVENTS && trimmed.length > 1) {\n const dropped = trimmed.shift();\n total -= dropped ? dropped.events.length : 0;\n }\n return trimmed;\n}\n\nlet globalClient: TrackingClient | null = null;\nlet globalClientKey: string | null = null;\n\nfunction clientConfigKey(config: TrackingClientConfig): string {\n return `${config.apiKey}@${config.endpoint}#${config.surface}`;\n}\n\n/**\n * Return a page-level singleton tracking client. Creating the client anew on\n * every component mount is wrong — React StrictMode double-mounts dev-only,\n * and destroying+recreating the client between the cleanup and re-run strips\n * away the pushState patch that SPA auto page view relies on. A singleton\n * survives all of that: the client lives for the entire page, and providers\n * just attach/detach auto page view against it.\n *\n * If `apiKey` / `endpoint` / `surface` change between calls, the previous\n * singleton is destroyed and a new one replaces it. This covers hot-config\n * changes without leaking state.\n */\nexport function getOrCreateTrackingClient(config: TrackingClientConfig): TrackingClient {\n const key = clientConfigKey(config);\n if (globalClient !== null && globalClientKey === key) {\n return globalClient;\n }\n if (globalClient !== null) {\n globalClient.destroy();\n // A config swap is a genuinely new client, not a remount of this one — the old client's\n // route already latched the page_view gate, which would otherwise suppress the new client's.\n resetPageViewState();\n }\n globalClient = createTrackingClient(config);\n globalClientKey = key;\n return globalClient;\n}\n\n// Global/delegated captures (document/window listeners: page_exit, scroll_depth,\n// time_on_site, form_start, cta_click, phone_click, bfcache restore) must attach ONCE\n// per page-singleton client — NOT once per React provider mount. A page can mount several\n// <TrackingProvider>s (a supported island pattern: a global provider for page-level\n// triggers plus per-component providers so `useSearchParams()` doesn't opt the whole tree\n// out of static rendering). Since the client is a singleton but each provider runs its own\n// attach effect, N mounts would stack N document listeners and every interaction would emit\n// its event N times. We ref-count attaches against the singleton client's identity: the\n// first mount attaches, later mounts are no-ops, and the last unmount detaches.\ninterface ClientCaptureEntry {\n detach: () => void;\n refCount: number;\n}\nconst clientCaptureRegistry = new WeakMap<TrackingClient, ClientCaptureEntry>();\n\n/**\n * Attach a set of global/delegated captures against a singleton `client` exactly once,\n * ref-counted across provider mounts. `build` performs the actual `document`/`window`\n * listener attachment and returns a single detacher for all of them; it runs only on the\n * first mount for a given client. The returned release decrements the ref-count and runs\n * `build`'s detacher when the last holder releases. Release is idempotent — React\n * StrictMode invokes an effect's cleanup twice in dev, and a double release must not\n * double-decrement.\n */\nexport function attachClientCapturesOnce(\n client: TrackingClient,\n build: () => () => void,\n): () => void {\n let entry = clientCaptureRegistry.get(client);\n if (entry === undefined) {\n entry = { detach: build(), refCount: 0 };\n clientCaptureRegistry.set(client, entry);\n }\n entry.refCount += 1;\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = clientCaptureRegistry.get(client);\n if (current === undefined) return;\n current.refCount -= 1;\n if (current.refCount <= 0) {\n current.detach();\n clientCaptureRegistry.delete(client);\n }\n };\n}\n\n/**\n * Tear down the singleton if any. Primarily an escape hatch for tests where\n * each test should see a fresh client; production code rarely needs this.\n * Also clears the in-session SPA referrer so the next test starts with a\n * fresh referrer chain.\n */\nexport function resetGlobalTrackingClient(): void {\n if (globalClient !== null) {\n // Force-detach any captures still attached to this client so their document/window\n // listeners can't leak into the next test even if a provider didn't unmount.\n const entry = clientCaptureRegistry.get(globalClient);\n if (entry !== undefined) {\n entry.detach();\n clientCaptureRegistry.delete(globalClient);\n }\n globalClient.destroy();\n }\n globalClient = null;\n globalClientKey = null;\n resetPageViewState();\n}\n\n/**\n * Create a low-level ingest client.\n *\n * The client queues events, debounces network flushes, sends an SDK heartbeat\n * once per new session, and swallows network errors so analytics never break\n * the host site.\n */\nexport function createTrackingClient(config: TrackingClientConfig): TrackingClient {\n const flushIntervalMs = config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;\n const maxQueueSize = Math.min(config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE, HARD_MAX_BATCH);\n const sdkVersion = config.sdkVersion ?? null;\n const packageName = config.packageName ?? null;\n // Default to 'production' so the wire payload always carries a valid enum\n // value. Sending null would fail the backend's strict enum validation.\n const environment: TrackingEnvironment = config.environment ?? \"production\";\n const activeGtagIds = config.activeGtagIds ?? null;\n const endpointBase = config.endpoint.replace(/\\/$/, \"\");\n const eventsUrl = `${endpointBase}/events`;\n const identityHeaders: IdentityHeaders = {\n sdkVersion: sdkVersion ?? \"\",\n packageName: packageName ?? \"\",\n surface: config.surface,\n environment,\n };\n\n let queue: QueuedEvent[] = [];\n let flushTimer: ReturnType<typeof setTimeout> | null = null;\n // Batches that have been handed to the network at least once and not yet\n // acknowledged. Mirrored to localStorage so a reload — or a deploy window that\n // 5xxs every request — costs latency instead of data.\n const bufferKey = retryBufferKey(config.apiKey, endpointBase);\n let pending: PendingBatch[] = readPendingBatches(bufferKey);\n // Flushes are serialized through one chain. Batches are no longer removed from\n // the buffer before the response arrives, so two overlapping flushes would\n // send the same batch twice. Chaining (rather than a \"busy\" flag that returns\n // early) keeps `await flush()` meaning \"flushed\" for the caller.\n let flushChain: Promise<void> = Promise.resolve();\n let retryAttempt = 0;\n let firstPage: string | null = null;\n // Attribution captured synchronously at client creation (before any SPA router\n // can strip the query string). Merged into every session payload as a fallback\n // so a blocked-cookie webview or a stripped URL still attributes.\n let initialParams = createEmptyTrackingParams();\n let destroyed = false;\n\n // Consumed BEFORE identity is minted: it may supply the visitor id itself.\n let inboundLink: ReturnType<typeof consumeInboundLink> = null;\n if (typeof window !== \"undefined\") {\n try {\n inboundLink = consumeInboundLink();\n } catch {\n // never break the host site\n }\n }\n // The sibling-site session this page load arrived from; cleared on rotation.\n let originLink: OriginLink | null = inboundLink?.link ?? null;\n\n // Initialize identity early so the first POST has stable values.\n const visitorId = getVisitorId();\n const initialSession = getOrRotateSessionId();\n let sessionId = initialSession.id;\n\n if (typeof window !== \"undefined\") {\n firstPage = stripLinkParam(window.location.href);\n try {\n initialParams = captureTrackingParamsFromLocation();\n } catch {\n // ignore — never break the host site\n }\n // Pin this session's LANDING params now, before any SPA router strips the\n // query string (reuses the stored record when the session already has one).\n getOrCaptureLandingParams(sessionId);\n try {\n captureFbc();\n } catch {\n // ignore\n }\n }\n\n /** The live page URL as stored — never carrying the transport token. */\n function currentPageUrl(): string | null {\n return typeof window === \"undefined\" ? null : stripLinkParam(window.location.href);\n }\n\n // SDK-internal events are queued at construction, before `trackEvent` exists.\n function enqueueInternal(\n eventType: string,\n metadata: Record<string, unknown>,\n pageUrl: string | null,\n ): void {\n queue.push({\n event_type: eventType,\n page_url: pageUrl,\n metadata,\n occurred_at: new Date().toISOString(),\n });\n }\n\n // Re-read per call: a session can rotate mid-page, and the heartbeat reports\n // the page the visitor is on then, not the one the client was built on.\n function enqueueHeartbeat(): void {\n enqueueInternal(\n \"sdk_heartbeat\",\n buildHeartbeatMetadata(\n config.surface,\n sdkVersion,\n packageName,\n config.triggers ?? null,\n activeGtagIds,\n config.configSiteId ?? null,\n ),\n currentPageUrl(),\n );\n }\n\n if (initialSession.isNew) {\n enqueueHeartbeat();\n }\n\n // A rejected token is reported too: the backend never sees one, only this verdict.\n if (inboundLink !== null && typeof window !== \"undefined\") {\n const link = inboundLink.link;\n enqueueInternal(\n \"site_link_arrival\",\n {\n outcome: inboundLink.outcome,\n origin_session_id: link?.sessionId ?? null,\n origin_visitor_id: link?.visitorId ?? null,\n transit_ms: link ? Math.max(0, Date.now() - link.linkedAt) : null,\n adopted_visitor: inboundLink.adoptedVisitor,\n page: { path: window.location.pathname },\n },\n firstPage,\n );\n scheduleFlush();\n }\n\n // Anything left buffered by a previous page load goes out on the normal\n // debounce, ahead of whatever this page produces.\n if (pending.length > 0) {\n scheduleFlush();\n }\n\n function buildSessionPayload(): TrackingSessionUpsertPayload {\n const rotated = getOrRotateSessionId();\n if (rotated.isNew && rotated.id !== sessionId) {\n // Session rotated mid-page (idle > 30min then user returned).\n enqueueHeartbeat();\n originLink = null;\n }\n sessionId = rotated.id;\n // Prefer the live cookie/localStorage read; fall back to the init-time\n // snapshot (covers a webview that blocked the cookie AND a router that\n // already stripped the landing URL by flush time).\n const params = mergeTrackingParams(readTrackingParams(), initialParams);\n const context = buildContext(\n config.surface,\n sdkVersion,\n packageName,\n environment,\n activeGtagIds,\n );\n return {\n session_id: sessionId,\n visitor_id: visitorId,\n gclid: params.gclid,\n wbraid: params.wbraid,\n gbraid: params.gbraid,\n ylpcid: params.ylpcid,\n fbclid: params.fbclid,\n fbc: getFbcCookie(),\n fbp: getFbpCookie(),\n utm_source: params.utm_source,\n utm_medium: params.utm_medium,\n utm_campaign: params.utm_campaign,\n utm_term: params.utm_term,\n utm_content: params.utm_content,\n // Landing params for the CURRENT session id — captured on the spot when\n // the session just rotated (the current URL is the rotated session's\n // landing), reused from the stored record otherwise. Keys are omitted\n // entirely when the landing isn't observable (SSR).\n ...buildLandingPayloadFields(sessionId),\n ...(originLink\n ? {\n origin_session_id: originLink.sessionId,\n origin_visitor_id: originLink.visitorId,\n origin_linked_at: new Date(originLink.linkedAt).toISOString(),\n }\n : {}),\n first_page: firstPage,\n consent_state: consentSnapshot(),\n context,\n };\n }\n\n function scheduleFlush(delayMs: number = flushIntervalMs): void {\n if (flushTimer !== null || destroyed) return;\n flushTimer = setTimeout(() => {\n flushTimer = null;\n void flush();\n }, delayMs);\n }\n\n function clearScheduledFlush(): void {\n if (flushTimer !== null) {\n clearTimeout(flushTimer);\n flushTimer = null;\n }\n }\n\n /** Move everything currently queued into the durable buffer. */\n function bufferQueued(): void {\n if (queue.length === 0) return;\n const events = queue.slice(0, HARD_MAX_BATCH);\n queue = queue.slice(events.length);\n pending = trimToBufferCap([...pending, { session: buildSessionPayload(), events }]);\n writePendingBatches(bufferKey, pending);\n }\n\n /**\n * Drain the durable buffer, oldest batch first.\n *\n * A batch leaves the buffer only on `ok` (acknowledged) or `drop` (permanently\n * rejected). Anything else keeps it, so a 5xx window costs latency, not data.\n * At-least-once: the backend drops a re-sent event by `tracking.events.dedupe_key`.\n */\n function flush(): Promise<void> {\n // `.catch` keeps one unexpected throw from poisoning every later flush.\n flushChain = flushChain.then(runFlush).catch(() => {});\n return flushChain;\n }\n\n async function runFlush(): Promise<void> {\n if (destroyed) return;\n clearScheduledFlush();\n bufferQueued();\n if (pending.length === 0) return;\n\n try {\n while (pending.length > 0) {\n const batch = pending[0]!;\n const body: IngestRequestBody = { session: batch.session, events: batch.events };\n // Plain fetch on a live page so the outcome is observable; only the unload\n // path needs keepalive, which outlives the document but can't be observed.\n const outcome = await postWithFetch(\n eventsUrl,\n JSON.stringify(body),\n config.apiKey,\n identityHeaders,\n false,\n );\n // Destroyed mid-send: the successor client owns the shared buffer now.\n if (destroyed) return;\n if (outcome === \"retry\") {\n retryAttempt += 1;\n return;\n }\n // By identity, not position: an unload during the await may already have\n // taken this batch, and `pending[0]` is then one that was never sent.\n pending = pending.filter((entry) => entry !== batch);\n writePendingBatches(bufferKey, pending);\n retryAttempt = 0;\n }\n } finally {\n if (pending.length > 0) {\n const backoff = Math.min(flushIntervalMs * 2 ** retryAttempt, MAX_RETRY_BACKOFF_MS);\n scheduleFlush(backoff);\n } else if (queue.length > 0) {\n // `bufferQueued` takes at most HARD_MAX_BATCH per pass, so a burst\n // larger than one batch still has events waiting.\n scheduleFlush();\n }\n }\n }\n\n function trackEvent(input: TrackEventInput): void {\n if (destroyed) return;\n if (!input || typeof input.eventType !== \"string\" || input.eventType.length === 0) return;\n\n // Enhanced conversions: a form submit is the one moment the visitor's own\n // email/phone pass through the SDK — stash them (memory only) so the\n // conversion that fires next carries user_data. NOT done for phone_click:\n // its metadata holds the BUSINESS's number, not the visitor's. An explicit\n // consent decline also skips the stash entirely — egress is already gated,\n // but a decliner's identifiers shouldn't sit in page memory either.\n if (input.eventType === \"form_submit\" && getConsentState() !== \"denied\") {\n try {\n const fields = (input.metadata as { form?: { fields?: unknown } } | null)?.form?.fields;\n if (fields) stashUserDataFromFormFields(fields, config.phone?.defaultCountry);\n } catch {\n // user-data capture must never break ingest\n }\n }\n\n const occurredAt =\n input.occurredAt instanceof Date\n ? input.occurredAt.toISOString()\n : typeof input.occurredAt === \"string\"\n ? input.occurredAt\n : new Date().toISOString();\n\n queue.push({\n event_type: input.eventType,\n // A caller's href still carries `_arv` until the surface strips it after hydration.\n page_url: input.pageUrl != null ? stripLinkParam(input.pageUrl) : currentPageUrl(),\n metadata: input.metadata ?? null,\n occurred_at: occurredAt,\n });\n\n // A lead is sent while the page is still alive: a tel: tap or a form submit\n // usually hides or leaves the page next, and the unload path can't retry.\n if (\n queue.length >= maxQueueSize ||\n getEventSemantics(input.eventType)?.outcomeRole === \"lead\"\n ) {\n void flush();\n } else {\n scheduleFlush();\n }\n }\n\n /**\n * Last-gasp keepalive send on pagehide/visibilitychange.\n *\n * Its outcome is unobservable, so the batch leaves the buffer optimistically;\n * re-sending one `runFlush` has in flight is intended (unload may abort that).\n */\n function flushOnUnload(): void {\n if (destroyed) return;\n bufferQueued();\n clearScheduledFlush();\n if (pending.length === 0) return;\n\n const batch = pending[0]!;\n pending = pending.slice(1);\n writePendingBatches(bufferKey, pending);\n\n const body: IngestRequestBody = { session: batch.session, events: batch.events };\n void postWithFetch(eventsUrl, JSON.stringify(body), config.apiKey, identityHeaders, true);\n }\n\n function onVisibilityChange(): void {\n if (document.visibilityState === \"hidden\") flushOnUnload();\n }\n\n if (typeof window !== \"undefined\") {\n window.addEventListener(\"pagehide\", flushOnUnload);\n window.addEventListener(\"visibilitychange\", onVisibilityChange);\n }\n\n return {\n trackEvent,\n flush,\n flushBeacon: flushOnUnload,\n getSessionId: () => sessionId,\n getVisitorId: () => visitorId,\n destroy: () => {\n if (destroyed) return;\n // Keepalive-send what's queued, or StrictMode's dev unmount drops the first page_view.\n if (queue.length > 0) {\n flushOnUnload();\n }\n // Only after that final send: from here the successor owns the shared buffer.\n destroyed = true;\n clearScheduledFlush();\n queue = [];\n if (typeof window !== \"undefined\") {\n window.removeEventListener(\"pagehide\", flushOnUnload);\n window.removeEventListener(\"visibilitychange\", onVisibilityChange);\n }\n },\n };\n}\n","import {\n API_KEY_HEADER,\n SDK_ENVIRONMENT_HEADER,\n SDK_PACKAGE_HEADER,\n SDK_SURFACE_HEADER,\n SDK_VERSION_HEADER,\n} from \"../../ingest\";\nimport { AranovaApiError } from \"./errors\";\n\n/** Shared config for every awaited (non fire-and-forget) API helper. */\nexport interface ApiTransportConfig {\n /** Public (`aranv_pk_…`) or secret (`aranv_sk_…`) API key. */\n apiKey: string;\n /** Base tracking endpoint, e.g. `https://aranovainternal-production.up.railway.app/tracking`. */\n endpoint: string;\n /** Optional SDK identity headers (mirrors the event ingest client). */\n sdkVersion?: string;\n packageName?: string;\n surface?: string;\n environment?: string;\n}\n\nfunction identityHeaders(config: ApiTransportConfig): Record<string, string> {\n const headers: Record<string, string> = { [API_KEY_HEADER]: config.apiKey };\n if (config.sdkVersion) headers[SDK_VERSION_HEADER] = config.sdkVersion;\n if (config.packageName) headers[SDK_PACKAGE_HEADER] = config.packageName;\n if (config.surface) headers[SDK_SURFACE_HEADER] = config.surface;\n if (config.environment) headers[SDK_ENVIRONMENT_HEADER] = config.environment;\n return headers;\n}\n\nfunction joinUrl(endpoint: string, path: string): string {\n return `${endpoint.replace(/\\/$/, \"\")}${path}`;\n}\n\n/**\n * Single awaited request. Unlike the event queue, this surfaces failures: any\n * non-2xx rejects with an {@link AranovaApiError}. Returns `undefined` for 204.\n */\nexport async function apiRequest<T>(\n config: ApiTransportConfig,\n method: string,\n path: string,\n body?: unknown,\n extraHeaders?: Record<string, string>,\n): Promise<T> {\n const headers = { ...identityHeaders(config), ...extraHeaders };\n if (body !== undefined) headers[\"Content-Type\"] = \"application/json\";\n\n const response = await fetch(joinUrl(config.endpoint, path), {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n\n if (!response.ok) {\n let detail: string | undefined;\n let code: string | undefined;\n let context: Record<string, unknown> | undefined;\n try {\n const parsed: unknown = await response.json();\n if (parsed && typeof parsed === \"object\") {\n const record = parsed as Record<string, unknown>;\n if (typeof record.detail === \"string\") detail = record.detail;\n if (typeof record.code === \"string\") code = record.code;\n if (record.context && typeof record.context === \"object\") {\n context = record.context as Record<string, unknown>;\n }\n }\n } catch {\n // non-JSON error body — fall back to status text\n }\n throw new AranovaApiError(detail ?? response.statusText ?? \"Request failed\", {\n status: response.status,\n code,\n requestId: response.headers.get(\"x-request-id\") ?? undefined,\n context,\n });\n }\n\n if (response.status === 204) return undefined as T;\n return (await response.json()) as T;\n}\n","// Hoisted to `resources/http/request`. Both names below are public API via\n// `sales-public.ts`, so they keep their original spellings.\nimport type { ApiTransportConfig } from \"../http/request\";\nimport { apiRequest } from \"../http/request\";\n\nexport type SalesTransportConfig = ApiTransportConfig;\nexport const salesRequest = apiRequest;\n","import { registerCapability } from \"../../capabilities\";\nimport type { FormSubmitMetadata, JsonValue } from \"../../events/form-submit\";\nimport { buildBookingHandoffUrl, getBookingIntegration } from \"../../integrations-public\";\nimport {\n LEAD_FORM_REGISTRY_VERSION,\n type LEAD_FORM_ROLE_SPECS,\n type LEAD_FORM_ROLES,\n type LeadFormKind,\n} from \"../../lead-forms/lead-forms.gen\";\nimport { toE164 } from \"../../phone\";\nimport { peekSessionId } from \"../../session\";\nimport { uuidV4 } from \"../../uuid\";\nimport type { BookingMode, ConversionConfigStore } from \"../conversion-config\";\n\nexport { BOOKING_MODES, type BookingMode } from \"../conversion-config\";\nimport { AranovaApiError } from \"../http/errors\";\nimport { resolveTrackingConfigUrl, type TrackingConfigRuntime } from \"../tracking-config-runtime\";\nimport { salesRequest, type SalesTransportConfig } from \"./transport\";\n\n// The wire vocabulary; `bookings.test.tsx` pins each list to `contracts/tracking-bookings.json`.\nexport const BOOKING_RESPONSE_STATUSES = [\"created\", \"pending\", \"failed\"] as const;\n/** Developer-only outcomes for a simulated create; the production API ignores them. */\nexport const BOOKING_SIMULATIONS = [\"created\", \"rejected\", \"unknown\", \"failed\"] as const;\nexport const BOOKING_SIMULATE_HEADER = \"X-Aranova-Simulate\";\nexport const BOOKING_DROP_OFF_OPTIONS = [\"waitForIt\", \"dropOff\"] as const;\nexport const BOOKING_DETAILS_STATUSES = [\"updated\", \"queued\", \"notified_owner\"] as const;\nexport const BOOKING_ERROR_CODES = {\n unavailable: \"booking_unavailable\",\n stale: \"booking_config_stale\",\n rejected: \"booking_rejected\",\n attemptMismatch: \"booking_attempt_mismatch\",\n detailsConflict: \"idempotency_conflict\",\n} as const;\n\ntype BookingResponseStatus = (typeof BOOKING_RESPONSE_STATUSES)[number];\nexport type BookingDropOff = (typeof BOOKING_DROP_OFF_OPTIONS)[number];\nexport type BookingSimulation = (typeof BOOKING_SIMULATIONS)[number];\nexport type BookingDetailsStatus = (typeof BOOKING_DETAILS_STATUSES)[number];\n\n/** `POST /tracking/bookings` body; its keys are pinned against the contract by the test. */\nexport interface BookingRequestBody {\n idempotency_key: string;\n session_id: string;\n config_version: number;\n start_date: string;\n start_hour: number;\n first_name: string;\n last_name: string;\n phone: string;\n email: string | null;\n vehicle: { year: number; make: string; model: string } | null;\n drop_off: BookingDropOff | null;\n services: string[];\n notes: string | null;\n marketing_sms_consent: boolean;\n page_path: string | null;\n}\n\n/** `POST /tracking/bookings` and `GET /tracking/bookings/{id}` answer. */\nexport interface BookingResponseBody {\n id: string;\n status: BookingResponseStatus;\n request_code: string | null;\n fallback_url: string | null;\n /** Absent from an API older than this SDK: read as `live`. */\n mode?: BookingMode;\n}\n\n/** `POST /tracking/bookings/{id}/details` body; its keys are pinned against the contract. */\nexport interface BookingDetailsRequestBody {\n idempotency_key: string;\n vehicle: { year: number; make: string; model: string } | null;\n notes: string | null;\n heard_via: string | null;\n}\n\n/** `POST /tracking/bookings/{id}/details` answer. */\nexport interface BookingDetailsResponseBody {\n id: string;\n status: BookingDetailsStatus;\n}\n\n/** Added after the booking: the vehicle (when the rules defer it), notes, or both. */\nexport interface BookingDetailsInput {\n vehicle?: { year: number; make: string; model: string } | null;\n notes?: string | null;\n /**\n * The site's own \"How did you hear about us?\" answer, verbatim — the same values the form's\n * `form_submit` `heardVia` field carries, so one answer reports the same whether it is asked\n * on the form or on the success screen. Ours for reporting: never sent to the shop.\n */\n heardVia?: string | null;\n}\n\nexport interface BookingDetailsOptions {\n /** The `attemptKey` the booking was submitted with; only that submit may add to it. */\n attemptKey: string;\n}\n\nexport type BookingDetailsOutcome =\n /**\n * `updated`: on the shop's request. `queued`: still on its way. `notified_owner`: the shop's\n * request could no longer take them, so the owner was texted. All three mean \"saved\".\n */\n | { status: BookingDetailsStatus; id: string }\n /**\n * Not saved. `invalid`: a bad vehicle or note. `mismatch`: not this booking's attempt key.\n * `conflict`: other details were already saved. `not_found`: no such booking for this key.\n * `failed`: the network or the API failed; worth a retry.\n */\n | { status: BookingDetailsFailure; id: string; message: string };\n\nexport type BookingDetailsFailure = \"invalid\" | \"mismatch\" | \"conflict\" | \"not_found\" | \"failed\";\n\nfunction detailsFailure(error: unknown): BookingDetailsFailure {\n if (!(error instanceof AranovaApiError)) return \"failed\";\n if (error.code === BOOKING_ERROR_CODES.attemptMismatch) return \"mismatch\";\n if (error.code === BOOKING_ERROR_CODES.detailsConflict) return \"conflict\";\n if (error.status === 404) return \"not_found\";\n return error.status === 422 ? \"invalid\" : \"failed\";\n}\n\n/** What the booking form collected. Dates and hours are the shop's wall time. */\nexport interface BookingInput {\n /** `YYYY-MM-DD`, one of `bookableSlots(...)`'s dates. */\n startDate: string;\n /** A start hour offered on that date (8 = 08:00). */\n startHour: number;\n firstName: string;\n /** Required: the shop's system needs it, and a single-word name has none to split off. */\n lastName: string;\n /** Mobile number; the shop texts its confirmation to it. */\n phone: string;\n email?: string | null;\n vehicle?: { year: number; make: string; model: string } | null;\n /** Ask only when the rules list drop-off options. */\n dropOff?: BookingDropOff | null;\n /** Option keys from `useBookingIntegration(...).options`. */\n services: string[];\n notes?: string | null;\n /**\n * The customer's own opt-in to marketing texts. Never default it on (CASL). Service texts\n * about this booking need no opt-in and are always on.\n */\n marketingSmsConsent?: boolean;\n}\n\n/** The form's `form_submit`, sent once per booking attempt and tied to this visit. */\nexport interface BookingFormSubmit {\n /** The site's tracking client (`useTracking()`): the booking takes the visit it reports. */\n tracking: {\n trackEvent(eventType: \"form_submit\", metadata: FormSubmitMetadata): void;\n getSessionId(): string;\n };\n /** The `<form id>`, so `form_start` and `form_submit` line up. */\n formId: string;\n /**\n * The site's own questions (e.g. \"How did you hear about us?\"), appended to this\n * `form_submit` for our reporting; never sent to the shop. A name the booking already\n * reports (`phone`, `email`, ...) is dropped, so lead matching cannot be overridden.\n */\n extraFields?: FormSubmitField[];\n}\n\n/** One field of a `form_submit`. */\nexport type FormSubmitField = NonNullable<FormSubmitMetadata[\"form\"][\"fields\"]>[number];\n\nexport type BookingOutcome =\n /** The shop has the request; it texts and emails the customer itself. */\n | { status: \"created\"; id: string; requestCode: string | null; mode: BookingMode }\n /** Received and still being sent; the shop confirms. Show the same success. */\n | { status: \"pending\"; id: string; mode: BookingMode }\n /**\n * Not created. Still a success for the customer: show the prefilled shop booking link.\n * `id` is null when the booking never reached Aranova (offline, rate-limited, booking off).\n */\n | { status: \"failed\"; id: string | null; fallbackUrl: string | null; mode: BookingMode }\n /** The booking breaks the shop's rules; `message` is customer-safe. Nothing was sent. */\n | { status: \"invalid\"; message: string; reasons: string[] }\n /** The shop's times changed since the form loaded; the config is being refetched. */\n | { status: \"stale\"; message: string };\n\nexport interface BookingSubmitOptions {\n attemptKey: string;\n formSubmit?: BookingFormSubmit;\n /**\n * Development only: force a simulated create's outcome to exercise the success, pending and\n * fallback screens. The production API ignores it, so it can never change a real booking.\n */\n simulate?: BookingSimulation;\n /**\n * Called once when the server answers `pending`, before `submit` polls for the final answer\n * (up to ~30 s): show the pending success right away instead of a spinner.\n */\n onPending?: (outcome: Extract<BookingOutcome, { status: \"pending\" }>) => void;\n}\n\nexport interface SalesBookingsClient {\n /**\n * Send one booking. Never rejects for an API outcome: every answer is a {@link BookingOutcome}.\n * Pass the same `attemptKey` (a UUID, `newBookingAttemptKey()`) on a retry of the same submit,\n * so a double-click or a network retry can never book twice.\n */\n submit(input: BookingInput, options: BookingSubmitOptions): Promise<BookingOutcome>;\n /** A booking's current status by the id `submit` returned. */\n status(id: string): Promise<BookingOutcome>;\n /**\n * Add the vehicle, notes or \"How did you hear about us?\" after booking (e.g.\n * `rules.vehicleDeferred`, or an answer asked on the success screen). Pass the booking's own\n * `attemptKey`. Never rejects: they reach the shop's request, or the owner by text.\n */\n updateBooking(\n bookingId: string,\n details: BookingDetailsInput,\n options: BookingDetailsOptions,\n ): Promise<BookingDetailsOutcome>;\n}\n\nconst POLL_DELAYS_MS = [1000, 2000, 4000, 8000, 15000];\nconst INVALID_DETAILS = \"Please check your details and try again.\";\n\n/** A fresh per-submit key. Keep it for retries of the same submit; mint a new one after. */\nexport const newBookingAttemptKey = uuidV4;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\nfunction outcomeOf(body: BookingResponseBody): BookingOutcome {\n const mode = body.mode ?? \"live\";\n if (body.status === \"created\")\n return { status: \"created\", id: body.id, requestCode: body.request_code, mode };\n if (body.status === \"pending\") return { status: \"pending\", id: body.id, mode };\n return { status: \"failed\", id: body.id, fallbackUrl: body.fallback_url, mode };\n}\n\ntype DropOffKey = (typeof LEAD_FORM_ROLE_SPECS)[\"drop_off\"][\"options\"][number][\"key\"];\n\nconst DROP_OFF_KEYS: Record<BookingDropOff, DropOffKey> = {\n waitForIt: \"wait-for-it\",\n dropOff: \"drop-off\",\n};\n\ninterface BookingField {\n name: string;\n type: string;\n label: string;\n value: JsonValue;\n role: (typeof LEAD_FORM_ROLES)[number];\n}\n\n/** A contract-typed `booking` submit (ADR-0024): option keys, never labels; blanks omitted. */\nfunction formSubmitPayload(\n input: BookingInput,\n formSubmit: BookingFormSubmit,\n contractHash: string | null,\n): FormSubmitMetadata {\n const field = (\n role: BookingField[\"role\"],\n name: string,\n type: string,\n label: string,\n value: JsonValue | undefined,\n ) => ({ name, type, label, value, role });\n const text = (value: string | null | undefined) => value?.trim() || undefined;\n const { vehicle, dropOff } = input;\n const entries = [\n field(\"given_name\", \"first_name\", \"text\", \"First name\", text(input.firstName)),\n field(\"family_name\", \"last_name\", \"text\", \"Last name\", text(input.lastName)),\n // E.164 on the wire: it keys the customer and enhanced-conversion matching.\n field(\"phone\", \"phone\", \"tel\", \"Phone\", toE164(input.phone) ?? input.phone),\n field(\"email\", \"email\", \"email\", \"Email\", text(input.email)),\n field(\"service\", \"service\", \"select\", \"Service\", input.services),\n field(\n \"vehicle\",\n \"vehicle\",\n \"text\",\n \"Vehicle\",\n vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : undefined,\n ),\n field(\n \"drop_off\",\n \"drop_off\",\n \"select\",\n \"Drop-off\",\n dropOff ? DROP_OFF_KEYS[dropOff] : undefined,\n ),\n field(\"preferred_date\", \"preferred_date\", \"date\", \"Date\", input.startDate),\n field(\"preferred_time\", \"preferred_time\", \"time\", \"Time\", {\n start: `${String(input.startHour).padStart(2, \"0\")}:00`,\n end: null,\n }),\n field(\"note\", \"note\", \"textarea\", \"Notes\", text(input.notes)),\n ];\n // Reserved even when blank, so an extra field can never stand in for one.\n const reported = new Set(entries.map((item) => item.name));\n const fields = entries.filter((item): item is BookingField => item.value !== undefined);\n const extra = (formSubmit.extraFields ?? []).filter((item) => !reported.has(item.name));\n return {\n form: {\n id: formSubmit.formId,\n action: \"/tracking/bookings\",\n fields: [...fields, ...extra],\n kind: \"booking\" satisfies LeadFormKind,\n contract_version: LEAD_FORM_REGISTRY_VERSION,\n ...(contractHash ? { contract_hash: contractHash } : {}),\n },\n page: { path: typeof window === \"undefined\" ? \"/\" : window.location.pathname },\n };\n}\n\ntype Firing = Pick<ConversionConfigStore, \"getFiring\"> | TrackingConfigRuntime | undefined;\n\n/** The runtime config the sales client was given, when it is the live runtime. */\nfunction runtimeOf(firing: Firing): TrackingConfigRuntime | null {\n return firing && \"integrations\" in firing ? firing : null;\n}\n\nfunction isLocalUrl(url: string): boolean {\n try {\n return [\"localhost\", \"127.0.0.1\", \"[::1]\"].includes(new URL(url).hostname);\n } catch {\n return false;\n }\n}\n\n/**\n * A local API with the production CDN books against prod's config_version and slots: the\n * local API refuses it as stale or unavailable. Config and API must come from one backend.\n */\nexport function bookingSetupMismatch(\n endpoint: string,\n runtime: Pick<TrackingConfigRuntime, \"ref\"> | null,\n): string | null {\n if (!runtime || !isLocalUrl(endpoint)) return null;\n const configUrl = resolveTrackingConfigUrl(runtime.ref);\n if (isLocalUrl(configUrl)) return null;\n return (\n `[aranova] Bookings go to the local API (${endpoint}) but the runtime config comes from ` +\n `${configUrl}. Set NEXT_PUBLIC_ARANOVA_CDN_BASE_URL to the local bucket ` +\n \"(http://localhost:9100/aranova-demos) so both come from one backend.\"\n );\n}\n\nexport function createSalesBookingsClient(\n config: SalesTransportConfig,\n firing: Firing,\n): SalesBookingsClient {\n const submittedForms = new Set<string>();\n const staleAttempts = new Set<string>();\n let isSetupChecked = false;\n\n function warnOnSetupMismatch(runtime: TrackingConfigRuntime | null): void {\n if (isSetupChecked) return;\n isSetupChecked = true;\n const warning = bookingSetupMismatch(config.endpoint, runtime);\n if (warning) console.warn(warning);\n }\n\n function handoff(input: BookingInput): string | null {\n const booking = getBookingIntegration(runtimeOf(firing)?.integrations());\n if (!booking) return null;\n return buildBookingHandoffUrl(booking.fallbackUrl, {\n firstName: input.firstName,\n lastName: input.lastName,\n phone: input.phone,\n email: input.email,\n });\n }\n\n /** Never reached Aranova: the mode is the one the runtime config announced. */\n function failedBeforeSend(input: BookingInput): BookingOutcome {\n const mode = getBookingIntegration(runtimeOf(firing)?.integrations())?.mode ?? \"live\";\n return { status: \"failed\", id: null, fallbackUrl: handoff(input), mode };\n }\n\n function withHandoff(outcome: BookingOutcome, input: BookingInput): BookingOutcome {\n if (outcome.status === \"failed\" && !outcome.fallbackUrl) {\n return { ...outcome, fallbackUrl: handoff(input) };\n }\n return outcome;\n }\n\n async function poll(id: string, mode: BookingMode): Promise<BookingOutcome> {\n let outcome: BookingOutcome = { status: \"pending\", id, mode };\n for (const delay of POLL_DELAYS_MS) {\n await sleep(delay);\n try {\n const path = `/bookings/${encodeURIComponent(id)}`;\n outcome = outcomeOf(await salesRequest<BookingResponseBody>(config, \"GET\", path));\n } catch {\n continue;\n }\n if (outcome.status !== \"pending\") return outcome;\n }\n // Still being sent after ~30 s: the workflow keeps going and alerts the owner if it fails.\n return outcome;\n }\n\n async function rejected(\n error: unknown,\n input: BookingInput,\n attemptKey: string,\n runtime: TrackingConfigRuntime | null,\n ): Promise<BookingOutcome> {\n if (!(error instanceof AranovaApiError)) {\n return failedBeforeSend(input);\n }\n if (error.code === BOOKING_ERROR_CODES.stale) {\n // A second stale answer means the published config has not caught up: hand off instead.\n if (staleAttempts.has(attemptKey)) return failedBeforeSend(input);\n staleAttempts.add(attemptKey);\n await runtime?.revalidate();\n return { status: \"stale\", message: error.message };\n }\n if (error.code === BOOKING_ERROR_CODES.rejected) {\n const reasons = error.context?.reasons;\n return {\n status: \"invalid\",\n message: error.message,\n reasons: Array.isArray(reasons) ? reasons.map(String) : [],\n };\n }\n if (error.code === \"validation_error\") {\n return { status: \"invalid\", message: INVALID_DETAILS, reasons: [] };\n }\n // Unavailable, rate-limited, offline or a server fault: the shop's own page still works.\n return failedBeforeSend(input);\n }\n\n return {\n async submit(input, { attemptKey, formSubmit, simulate, onPending }) {\n registerCapability(\"booking_integration\");\n const runtime = runtimeOf(firing);\n warnOnSetupMismatch(runtime);\n const booking = getBookingIntegration(runtime?.integrations());\n if (!booking?.acceptsBookings || booking.configVersion === null) {\n return failedBeforeSend(input);\n }\n if (formSubmit && !submittedForms.has(attemptKey)) {\n submittedForms.add(attemptKey);\n const contract = runtime?.leadForms()?.contracts.find((c) => c.form_kind === \"booking\");\n formSubmit.tracking.trackEvent(\n \"form_submit\",\n formSubmitPayload(input, formSubmit, contract?.contract_hash ?? null),\n );\n }\n const body: BookingRequestBody = {\n idempotency_key: attemptKey,\n // The tracking client's own visit, so the booking and its form_submit share one session.\n session_id:\n formSubmit?.tracking.getSessionId() ?? peekSessionId() ?? `no-session-${attemptKey}`,\n config_version: booking.configVersion,\n start_date: input.startDate,\n start_hour: input.startHour,\n first_name: input.firstName.trim(),\n last_name: input.lastName.trim(),\n phone: input.phone,\n email: input.email?.trim() || null,\n vehicle: input.vehicle ?? null,\n drop_off: input.dropOff ?? null,\n services: input.services,\n notes: input.notes?.trim() || null,\n marketing_sms_consent: input.marketingSmsConsent === true,\n page_path: typeof window === \"undefined\" ? null : window.location.pathname,\n };\n let response: BookingResponseBody;\n try {\n const headers = simulate ? { [BOOKING_SIMULATE_HEADER]: simulate } : undefined;\n response = await salesRequest<BookingResponseBody>(\n config,\n \"POST\",\n \"/bookings\",\n body,\n headers,\n );\n } catch (error) {\n return rejected(error, input, attemptKey, runtime);\n }\n const outcome = outcomeOf(response);\n if (outcome.status !== \"pending\") return withHandoff(outcome, input);\n onPending?.(outcome);\n return withHandoff(await poll(outcome.id, outcome.mode), input);\n },\n\n async updateBooking(bookingId, details, { attemptKey }) {\n const body: BookingDetailsRequestBody = {\n idempotency_key: attemptKey,\n vehicle: details.vehicle ?? null,\n notes: details.notes?.trim() || null,\n heard_via: details.heardVia?.trim() || null,\n };\n try {\n const response = await salesRequest<BookingDetailsResponseBody>(\n config,\n \"POST\",\n `/bookings/${encodeURIComponent(bookingId)}/details`,\n body,\n );\n return { status: response.status, id: response.id };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return { status: detailsFailure(error), id: bookingId, message };\n }\n },\n\n async status(id) {\n return outcomeOf(\n await salesRequest<BookingResponseBody>(\n config,\n \"GET\",\n `/bookings/${encodeURIComponent(id)}`,\n ),\n );\n },\n };\n}\n","import type {\n BusinessConfig,\n CustomerGetOptions,\n CustomerGetResult,\n CustomerKpis,\n CustomerListPage,\n CustomerListQuery,\n CustomerSummaryQuery,\n Sale,\n SaleCursorPage,\n SaleInput,\n SaleListQueryV2,\n SaleSummaryQueryV2,\n SaleSummaryV2,\n SaleUpdateInput,\n SupportedCurrency,\n} from \"./schema\";\nimport { registerCapability } from \"../../capabilities\";\nimport type { ConversionConfigStore } from \"../conversion-config\";\nimport { fireConversionWithConsent } from \"../conversion-firing\";\nimport type { TrackingConfigRuntime } from \"../tracking-config-runtime\";\nimport { getConsentState } from \"../../consent\";\nimport { stashUserData, type ConversionUserData } from \"../../user-data\";\nimport { createSalesBookingsClient, type SalesBookingsClient } from \"./bookings\";\nimport { salesRequest, type SalesTransportConfig } from \"./transport\";\nimport { fromMinor } from \"./money\";\n\n/** Config for {@link createSalesClient}. */\nexport interface SalesClientConfig extends SalesTransportConfig {\n /** Applied when an individual `record()` call omits `currency`. */\n defaultCurrency?: SupportedCurrency;\n /**\n * GAP28: when present, `record()` ALSO fires a real-time on-site conversion\n * (`gtag('event','conversion')`) for any recorded service that has a `firing` send_to in\n * the resolved config. Consent-gated + de-duped; no-ops server-side. Wire it from\n * `resolveConversionConfig(...)`.\n */\n firing?: Pick<ConversionConfigStore, \"getFiring\"> | TrackingConfigRuntime;\n}\n\n// Fire the on-site conversion(s) for a just-recorded sale. Per-service value = the sale's\n// amount when present, else the action's configured default from config (Google applies\n// its own default when neither is sent). transaction_id ties the gtag fire to the sale so\n// retries/reloads de-dup within the WEBPAGE action.\nfunction fireRecordedConversions(\n firing: Pick<ConversionConfigStore, \"getFiring\"> | TrackingConfigRuntime | undefined,\n input: {\n external_id?: string | null;\n amount_total_cents?: number | null;\n customer_email?: string | null;\n customer_phone?: string | null;\n },\n recorded: { service?: string | null; amount_cents: number | null }[],\n sale: Sale,\n currency: SupportedCurrency,\n): void {\n if (!firing) return;\n // Enhanced conversions: the sale's own customer identifiers are the\n // strongest possible user_data signal — pass them into every per-service\n // fire, and refresh the stash so later same-page event-goal fires match too.\n const userData: ConversionUserData | null =\n input.customer_email || input.customer_phone\n ? { email: input.customer_email ?? null, phone: input.customer_phone ?? null }\n : null;\n // Denied consent: the fires below no-op anyway, so don't retain the\n // identifiers in page memory either.\n if (userData && getConsentState() !== \"denied\") stashUserData(userData);\n const txnBase = input.external_id ?? sale.id;\n for (const item of recorded) {\n if (!item.service) continue;\n if (\"fireConversion\" in firing) {\n firing.fireConversion(item.service, {\n value: item.amount_cents != null ? fromMinor(item.amount_cents, currency) : undefined,\n currency,\n transactionId: `${txnBase}:${item.service}`,\n });\n continue;\n }\n const config = firing.getFiring(item.service);\n if (!config) continue;\n const cents = item.amount_cents ?? config.value_cents ?? null;\n fireConversionWithConsent(\n {\n sendTo: config.send_to,\n value: cents != null ? fromMinor(cents, currency) : null,\n currency: config.currency ?? currency,\n transactionId: `${txnBase}:${item.service}`,\n },\n { userData },\n );\n }\n}\n\n/** Phone-keyed customer rollups (sk-only; a public key gets a `403`). */\nexport interface SalesCustomersClient {\n list(query?: CustomerListQuery): Promise<CustomerListPage>;\n /** `id` is the customer's E.164 phone. */\n get(id: string, options?: CustomerGetOptions): Promise<CustomerGetResult>;\n summary(query?: CustomerSummaryQuery): Promise<CustomerKpis>;\n}\n\n/** Business config (low-sensitivity — accepts a public or secret key). */\nexport interface SalesBusinessClient {\n config(): Promise<BusinessConfig>;\n}\n\n/**\n * One isomorphic sales client — what a key may *do* is enforced by the backend,\n * not by hiding methods. A **public** key (`aranv_pk_…`) may `record` (the\n * backend rejects reads/CRUD from it with a `403`); a **secret** key\n * (`aranv_sk_…`), used **server-side only**, gets full read/list/update/delete.\n * Never ship a secret key in a browser bundle.\n *\n * Generic over the service-key union `TService` and the manual-conversion key union\n * `TConversion`: bind the types emitted by `@aranova/tracking-cli gen` (`AranovaService`\n * and `AranovaConversion`) for compile-time-checked `service` / `trackConversion` keys.\n */\nexport interface SalesClient<\n TService extends string = string,\n TConversion extends string = string,\n> {\n record(\n input: Omit<SaleInput, \"currency\" | \"occurred_at\" | \"service\" | \"services\"> & {\n service?: TService | null;\n // XOR with `service`: record multiple services in one sale, each priced\n // individually. `amount_total_cents` is then optional (the backend derives\n // it as the sum). Keys are checked against the codegen `TService` union.\n services?: Array<{ service: TService; amount_cents: number }>;\n currency?: SupportedCurrency;\n occurred_at?: string;\n },\n ): Promise<Sale>;\n /**\n * Record a revenue sale — the intent-revealing alias of {@link record}. Identical\n * behavior (same function reference): POSTs `/sales` and fires the on-site conversion\n * when the sale-goal is WEBPAGE-mapped. Use either for real revenue; use\n * {@link trackConversion} for a non-revenue on-page event.\n */\n recordSale(\n input: Omit<SaleInput, \"currency\" | \"occurred_at\" | \"service\" | \"services\"> & {\n service?: TService | null;\n services?: Array<{ service: TService; amount_cents: number }>;\n currency?: SupportedCurrency;\n occurred_at?: string;\n },\n ): Promise<Sale>;\n /**\n * Fire ONLY the on-site conversion for an event-goal `key` — for MANUAL event-goals\n * (`form_submit`, `phone_click`, `cta_click`) the consumer triggers explicitly. No `/sales`\n * write. No-ops when the goal isn't WEBPAGE-mapped (no resolved `send_to`) or the config\n * isn't wired. Consent-gated + de-duped. (Automatic event-goals fire themselves — no call.)\n */\n trackConversion(\n key: TConversion,\n options?: {\n transactionId?: string | null;\n value?: number | null;\n currency?: SupportedCurrency;\n /**\n * Enhanced conversions override: the visitor's own email/phone for this\n * conversion. Wins per-key over the automatic form-submit stash — the\n * escape hatch when a form's fields defeat the extraction heuristics.\n */\n userData?: ConversionUserData | null;\n },\n ): void;\n /** Keyset list with optional server sort + opt-in `total_count`. */\n list(query?: SaleListQueryV2): Promise<SaleCursorPage>;\n /**\n * Currency-grouped aggregations for the key's business. Additive v2 options:\n * calendar/custom ranges, IANA `timezone`, `granularity`, and `compare_to`.\n * Legacy `24h/7d/30d` keep their exact prior numbers. Secret key only.\n */\n summary(query: SaleSummaryQueryV2): Promise<SaleSummaryV2>;\n get(id: string): Promise<Sale>;\n update(\n id: string,\n patch: Omit<SaleUpdateInput, \"service\" | \"services\"> & {\n service?: TService | null;\n // Present ⇒ replaces the whole service set (XOR with `service`); the\n // backend recomputes `amount_total_cents` from the sum.\n services?: Array<{ service: TService; amount_cents: number }>;\n },\n ): Promise<Sale>;\n delete(id: string): Promise<void>;\n /** Phone-keyed customer rollups (sk-only). */\n customers: SalesCustomersClient;\n /** Business config (pk or sk). */\n business: SalesBusinessClient;\n /**\n * Website bookings into the shop's own system (public key, browser only). Reads the\n * booking rules from `firing` when it is the shared tracking-config runtime.\n */\n bookings: SalesBookingsClient;\n}\n\nexport function createSalesClient<\n TService extends string = string,\n TConversion extends string = string,\n>(config: SalesClientConfig): SalesClient<TService, TConversion> {\n // `firing` is what makes trackConversion do anything at all — without it the\n // method returns immediately, so its presence IS the manual-goal capability.\n // The sales LEDGER verbs are not reported: they are moving to the internal\n // app and are not a client-site capability.\n if (config.firing) registerCapability(\"conversion_goals_manual\");\n\n type RecordInput = Parameters<SalesClient<TService, TConversion>[\"record\"]>[0];\n\n async function record(input: RecordInput): Promise<Sale> {\n const currency = input.currency ?? config.defaultCurrency;\n if (!currency) {\n throw new Error(\n \"record: `currency` is required (pass it on the sale or set config.defaultCurrency)\",\n );\n }\n const body: SaleInput = {\n ...input,\n currency,\n occurred_at: input.occurred_at ?? new Date().toISOString(),\n };\n const sale = await salesRequest<Sale>(config, \"POST\", \"/sales\", body);\n // GAP28: fire the real-time on-site conversion(s) AFTER the sale is recorded, so the\n // gtag transaction_id and the offline path agree. Never throws (best-effort fire).\n const recorded = input.services?.length\n ? input.services.map((s) => ({\n service: s.service,\n amount_cents: s.amount_cents,\n }))\n : [\n {\n service: input.service,\n amount_cents: input.amount_total_cents ?? null,\n },\n ];\n fireRecordedConversions(config.firing, input, recorded, sale, currency);\n return sale;\n }\n\n return {\n record,\n // recordSale is the intent-revealing alias — same behavior, clearer call site.\n recordSale: record,\n\n trackConversion(key, options) {\n // Manual event-goal: fire the WEBPAGE conversion only (no /sales). No-op when the goal\n // isn't WEBPAGE-mapped or the config isn't wired.\n if (config.firing && \"fireConversion\" in config.firing) {\n // Enhanced conversions on the runtime path: the drain fires gtag\n // itself, reading user_data from the stash — persist the explicit\n // override there so it isn't lost to the queue.\n if (options?.userData && getConsentState() !== \"denied\") stashUserData(options.userData);\n config.firing.fireConversion(key, {\n value: options?.value ?? undefined,\n currency: options?.currency ?? config.defaultCurrency ?? undefined,\n transactionId: options?.transactionId ?? null,\n });\n return;\n }\n const firing = config.firing?.getFiring(key);\n if (!firing) return;\n const currency = firing.currency ?? options?.currency ?? config.defaultCurrency ?? null;\n const cents = firing.value_cents ?? null;\n const value =\n options?.value ??\n (cents != null && currency ? fromMinor(cents, currency as SupportedCurrency) : null);\n fireConversionWithConsent(\n {\n sendTo: firing.send_to,\n value,\n currency,\n transactionId: options?.transactionId ?? null,\n },\n { userData: options?.userData },\n );\n },\n\n async list(query) {\n // Pagination + sort travel at the top level; everything else is a filter.\n const { cursor, limit, sort, order, want_total, ...filters } = query ?? {};\n return salesRequest<SaleCursorPage>(config, \"POST\", \"/sales/query\", {\n filters,\n ...(limit !== undefined ? { limit } : {}),\n ...(cursor !== undefined ? { cursor } : {}),\n ...(sort !== undefined ? { sort } : {}),\n ...(order !== undefined ? { order } : {}),\n ...(want_total !== undefined ? { want_total } : {}),\n });\n },\n\n async summary(query) {\n const {\n range,\n include_categories,\n include_deleted_services,\n top_n,\n since,\n until,\n timezone,\n granularity,\n compare_to,\n ...filters\n } = query;\n return salesRequest<SaleSummaryV2>(config, \"POST\", \"/sales/summary\", {\n filters,\n ...(range !== undefined ? { range } : {}),\n ...(include_categories !== undefined ? { include_categories } : {}),\n ...(include_deleted_services !== undefined ? { include_deleted_services } : {}),\n ...(top_n !== undefined ? { top_n } : {}),\n ...(since !== undefined ? { since } : {}),\n ...(until !== undefined ? { until } : {}),\n ...(timezone !== undefined ? { timezone } : {}),\n ...(granularity !== undefined ? { granularity } : {}),\n ...(compare_to !== undefined ? { compare_to } : {}),\n });\n },\n\n async get(id) {\n return salesRequest<Sale>(config, \"GET\", `/sales/${id}`);\n },\n\n async update(id, patch) {\n return salesRequest<Sale>(config, \"PATCH\", `/sales/${id}`, patch);\n },\n\n async delete(id) {\n await salesRequest<void>(config, \"DELETE\", `/sales/${id}`);\n },\n\n customers: {\n async list(query) {\n const { segment, sort, order, cursor, limit, want_total, ...filters } = query ?? {};\n return salesRequest<CustomerListPage>(config, \"POST\", \"/customers/query\", {\n filters,\n ...(segment !== undefined ? { segment } : {}),\n ...(sort !== undefined ? { sort } : {}),\n ...(order !== undefined ? { order } : {}),\n ...(cursor !== undefined ? { cursor } : {}),\n ...(limit !== undefined ? { limit } : {}),\n ...(want_total !== undefined ? { want_total } : {}),\n });\n },\n async get(id, options) {\n const params = new URLSearchParams();\n if (options?.include_sales !== undefined)\n params.set(\"include_sales\", String(options.include_sales));\n if (options?.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options?.cursor != null) params.set(\"cursor\", options.cursor);\n const qs = params.toString();\n // `id` is the E.164 phone (`+1…`) — must be URL-encoded.\n return salesRequest<CustomerGetResult>(\n config,\n \"GET\",\n `/customers/${encodeURIComponent(id)}${qs ? `?${qs}` : \"\"}`,\n );\n },\n async summary(query) {\n const { range, since, until, timezone, compare_to, ...filters } = query ?? {};\n return salesRequest<CustomerKpis>(config, \"POST\", \"/customers/summary\", {\n filters,\n ...(range !== undefined ? { range } : {}),\n ...(since !== undefined ? { since } : {}),\n ...(until !== undefined ? { until } : {}),\n ...(timezone !== undefined ? { timezone } : {}),\n ...(compare_to !== undefined ? { compare_to } : {}),\n });\n },\n },\n\n business: {\n async config() {\n return salesRequest<BusinessConfig>(config, \"GET\", \"/business/config\");\n },\n },\n\n bookings: createSalesBookingsClient(config, config.firing),\n };\n}\n","import * as z from \"zod/mini\";\n\nimport {\n zSaleCreateSchema,\n zSaleItemInputSchema,\n zSaleServiceInputSchema,\n zSaleSource,\n zSupportedCurrency,\n} from \"./generated/zod.gen\";\n\n/**\n * Sales / Conversions wire schemas.\n *\n * The object shapes come from the backend's own spec — see\n * `../generated/zod.gen.ts` and `packages/tracking-core/openapi-ts.config.ts`.\n * This file adds only what JSON Schema cannot express: the `service`/`services`\n * XOR, and defaults Pydantic omits from the spec because `default_factory` is\n * not serialized (`items: []`) — composed onto the generated base, never\n * duplicating its shape.\n *\n * `saleUpdateSchema` stays hand-written: its non-nullable-on-update rule is a\n * backend `model_validator`, invisible to JSON Schema, so the generated shape\n * would accept `null` where the server returns 422.\n */\n\nexport const saleItemSchema = zSaleItemInputSchema;\nexport const saleServiceSchema = zSaleServiceInputSchema;\nexport const currencySchema = zSupportedCurrency;\n\nexport const SUPPORTED_CURRENCIES = currencySchema.options;\nexport type SupportedCurrency = (typeof SUPPORTED_CURRENCIES)[number];\n\nexport const SALE_SOURCES = zSaleSource.options;\nexport type SaleSource = (typeof SALE_SOURCES)[number];\n\nconst TRACKING_ENVIRONMENTS = [\"production\", \"development\"] as const;\n\nconst centsSchema = z.int().check(z.gte(0));\n// Free-form JSON object — the sale's extensibility escape hatch. Mirrors the\n// Pydantic `dict[str, Any]` (`additionalProperties: true`) on the wire.\nconst metadataSchema = z.record(z.string(), z.unknown());\n\n// Raw customer identity. Stored as-is on the backend for human display in\n// dashboards; hashing happens at writeback-upload time in the future Google\n// Ads reconciliation worker (see docs/tracking-package/conversion-writeback.md).\n// The SDK never hashes — pass values straight through.\nconst customerNameSchema = z.string().check(z.maxLength(200));\nconst customerPhoneSchema = z.string().check(z.maxLength(64));\nconst customerEmailSchema = z.email().check(z.maxLength(320));\n\n// The singular `service` (priced by `amount_total_cents`) and the plural\n// `services` (each priced individually) are mutually exclusive. When `services`\n// is present, `amount_total_cents` is optional and the backend derives it as the\n// sum; otherwise it is required. Shared by create + update.\n// Mini reports issues by pushing onto the payload rather than via a\n// `RefinementCtx` helper; `path` still scopes the message to a field.\ntype MiniCheckCtx = { value: unknown; issues: Array<Record<string, unknown>> };\n\nfunction addIssue(ctx: MiniCheckCtx, path: string[], message: string): void {\n ctx.issues.push({ code: \"custom\", input: ctx.value, path, message });\n}\n\nfunction refineServiceXor(\n val: {\n service?: string | null;\n services?: ReadonlyArray<{ service: string; amount_cents: number }> | null;\n amount_total_cents?: number | null;\n },\n ctx: MiniCheckCtx,\n { requireAmount }: { requireAmount: boolean },\n): void {\n if (val.services != null) {\n if (val.service != null) {\n addIssue(ctx, [\"services\"], \"pass either `service` or `services`, not both\");\n }\n if (val.services.length === 0) {\n addIssue(ctx, [\"services\"], \"`services` must not be empty\");\n }\n const keys = val.services.map((s) => s.service);\n if (new Set(keys).size !== keys.length) {\n addIssue(ctx, [\"services\"], \"`services` must not list the same service more than once\");\n }\n if (val.amount_total_cents != null) {\n const sum = val.services.reduce((acc, s) => acc + s.amount_cents, 0);\n if (val.amount_total_cents !== sum) {\n addIssue(\n ctx,\n [\"amount_total_cents\"],\n \"amount_total_cents must equal the sum of the services amounts \" +\n \"(omit it to derive it automatically)\",\n );\n }\n }\n } else if (requireAmount && val.amount_total_cents == null) {\n addIssue(\n ctx,\n [\"amount_total_cents\"],\n \"amount_total_cents is required unless `services` is provided\",\n );\n }\n}\n\n// `items` has a real (mutable) default server-side, but Pydantic doesn't\n// serialize a `default_factory=list` into the JSON Schema `default` keyword —\n// generation has no way to see it, so it's restored here, on the one field\n// where the spec is silent. Every other field's shape and default come\n// straight from `zSaleCreateSchema`.\nexport const saleCreateSchema = z\n .extend(zSaleCreateSchema, { items: z._default(z.array(saleItemSchema), []) })\n .check((ctx) => refineServiceXor(ctx.value, ctx, { requireAmount: true }));\n\nexport const saleUpdateSchema = z\n .strictObject({\n description: z.nullish(z.string()),\n service: z.nullish(z.string()),\n services: z.nullish(z.array(saleServiceSchema)),\n currency: z.optional(currencySchema),\n amount_total_cents: z.optional(centsSchema),\n occurred_at: z.optional(z.iso.datetime()),\n items: z.optional(z.array(saleItemSchema)),\n metadata: z.nullish(metadataSchema),\n customer_name: z.nullish(customerNameSchema),\n // Re-attest when changing a filler-looking name; the server clears the\n // prior attestation whenever `customer_name` changes.\n customer_name_placeholder_confirmed: z.optional(z.boolean()),\n customer_phone: z.nullish(customerPhoneSchema),\n customer_email: z.nullish(customerEmailSchema),\n // NOT NULL server-side: omit to leave unchanged (explicit null is rejected).\n sms_consent: z.optional(z.boolean()),\n })\n .check((ctx) => refineServiceXor(ctx.value, ctx, { requireAmount: false }));\n\nexport type SaleItemInput = z.input<typeof saleItemSchema>;\nexport type SaleServiceInput = z.input<typeof saleServiceSchema>;\n// Use the INPUT type so fields with Zod defaults (environment, items) and\n// occurred_at are optional for callers — the sales client fills them in.\nexport type SaleInput = z.input<typeof saleCreateSchema>;\nexport type SaleUpdateInput = z.input<typeof saleUpdateSchema>;\n\n// ---------------------------------------------------------------------------\n// Read shapes (server responses; not validated on the client)\n// ---------------------------------------------------------------------------\n\nexport interface SaleItem {\n id: string;\n external_item_id: string | null;\n name: string | null;\n category: string | null;\n quantity: string;\n unit_price_cents: number;\n unit_cost_cents: number | null;\n}\n\nexport interface SaleSourceFacts {\n invoice_id: string;\n /** The shop system's repair-order number. */\n invoice_code: string;\n first_order: boolean;\n fleet_customer: boolean;\n}\n\nexport interface SaleService {\n id: string;\n service_id: string;\n service_key: string;\n service_label: string;\n amount_cents: number;\n}\n\nexport interface Sale {\n id: string;\n business_id: string;\n business_name?: string | null;\n external_id: string | null;\n currency: SupportedCurrency;\n amount_total_cents: number;\n description: string | null;\n // Singular service fields are populated only for single-service sales (kept\n // for backwards compatibility); `services` is always the full set.\n service_id: string | null;\n service_key?: string | null;\n service_label?: string | null;\n services: SaleService[];\n occurred_at: string;\n environment: (typeof TRACKING_ENVIRONMENTS)[number];\n /** Which path created the sale: `api`, `admin` (dashboard), or a shop-system sync (`autoleap`). Server-set. */\n source: SaleSource;\n /** What the shop-system sync recorded about the invoice; `null` unless `source` is `autoleap`. Server-set. */\n source_facts: SaleSourceFacts | null;\n metadata: Record<string, unknown> | null;\n customer_name: string | null;\n /** Operator attestation that a filler-looking `customer_name` is genuine. */\n customer_name_placeholder_confirmed: boolean;\n /** Server timestamp for when the name attestation was recorded. */\n customer_name_placeholder_confirmed_at: string | null;\n customer_phone: string | null;\n customer_email: string | null;\n /** CASL consent attestation stored on the sale; SMS send paths gate on it server-side. */\n sms_consent: boolean;\n /** Server timestamp for when the consent attestation was recorded. */\n sms_consent_granted_at: string | null;\n created_at: string;\n updated_at: string;\n items: SaleItem[];\n}\n\n/**\n * The offset page shape returned by the internal admin list, which this client\n * never calls (`list()` is keyset and returns {@link SaleCursorPage}).\n *\n * @deprecated Unused by this client — no verb returns or accepts it. Kept for one release; removed in the next major.\n */\nexport interface SaleListPage {\n items: Sale[];\n total: number;\n}\n\nexport interface SaleCursorPage {\n items: Sale[];\n next_cursor: string | null;\n /** Populated only when `want_total` was requested (a single indexed COUNT). */\n total_count?: number;\n has_more?: boolean;\n}\n\n/**\n * Sortable columns on the internal admin (offset) list. `list()` is typed to\n * {@link SaleKeysetSortField}, which omits `customer_name` and `business_name`.\n *\n * @deprecated Unused by this client — no verb returns or accepts it. Kept for one release; removed in the next major.\n */\nexport type SaleSortField =\n | \"occurred_at\"\n | \"created_at\"\n | \"amount_total_cents\"\n | \"customer_name\"\n | \"business_name\";\n\nexport type SaleSortOrder = \"asc\" | \"desc\";\n\n/**\n * Comprehensive filter shape mirrored from the backend's `SaleQueryFilters`.\n *\n * `search` runs case-insensitively across `customer_name`, `customer_phone`,\n * `customer_email`, `description`, and `external_id` — the human-facing\n * columns. `service_id` is the resolved per-business service UUID (different\n * from the create-time `service` *key*).\n */\nexport interface SaleFilters {\n business_id?: string;\n external_id?: string;\n service_id?: string;\n /** E.164 phone — the canonical customer key; filters sales for one customer. */\n customer_phone?: string;\n currency?: SupportedCurrency;\n environment?: (typeof TRACKING_ENVIRONMENTS)[number];\n since?: string;\n until?: string;\n min_amount_cents?: number;\n max_amount_cents?: number;\n search?: string;\n}\n\n/**\n * Full query input for `SalesClient.list()` — filters + pagination.\n *\n * Pagination is **keyset (cursor)**: `next_cursor` returned by one page is\n * passed back as `cursor` on the next. `null` / undefined cursor = first page.\n *\n * Ordering is selectable via `sort`/`order` on {@link SaleListQueryV2}. A cursor\n * encodes a position under the sort it was minted with, so replaying it under a\n * different sort is rejected (422) — page with the sort you started with.\n */\n/**\n * @deprecated Superseded by {@link SaleListQueryV2}, which is what `list()` accepts.\n * Its ordering note was also stale — `sort`/`order` are supported. Removed in the next major.\n */\nexport interface SaleListQuery extends SaleFilters {\n limit?: number;\n cursor?: string | null;\n}\n\n/** Keyset-safe sort columns (NOT-NULL, indexed). */\nexport type SaleKeysetSortField = \"occurred_at\" | \"created_at\" | \"amount_total_cents\";\n\n/** v2 list query — adds server sort + opt-in total. */\nexport interface SaleListQueryV2 extends SaleListQuery {\n sort?: SaleKeysetSortField;\n order?: SaleSortOrder;\n /** Opt-in: a single indexed COUNT over the filtered set. */\n want_total?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Sales summary (aggregations). Read by `SalesClient.summary()`. Every monetary\n// figure is grouped **by currency** — USD and CAD cents are never summed into a\n// single total. Money stays integer cents; only `quantity` is a decimal string.\n// ---------------------------------------------------------------------------\n\nexport const TRACKING_RANGES = [\"24h\", \"7d\", \"30d\"] as const;\nexport type TrackingOverviewRange = (typeof TRACKING_RANGES)[number];\n\n/** Query input for `SalesClient.summary()` — filters + range + options. */\n/**\n * @deprecated The v1 summary query. `summary()` accepts {@link SaleSummaryQueryV2};\n * the v1 endpoint no longer exists. Removed in the next major.\n */\nexport interface SaleSummaryQuery extends SaleFilters {\n range: TrackingOverviewRange;\n /** Include the per-category line-item breakdown (extra join; default false). */\n include_categories?: boolean;\n /** Surface soft-deleted services individually (flagged \"(deleted)\") instead of\n * rolling them into a single \"Deleted services\" bucket. Default false. */\n include_deleted_services?: boolean;\n /** Cap on the by-service / by-category rows (1–50, default 10). */\n top_n?: number;\n}\n\nexport interface CurrencyRevenue {\n currency: SupportedCurrency;\n sale_count: number;\n revenue_cents: number;\n average_order_value_cents: number;\n}\n\nexport interface SalesServiceBreakdown {\n service_id: string | null;\n service_key: string | null;\n /** \"Unassigned\" when the sale has no service. */\n service_label: string | null;\n currency: SupportedCurrency;\n sale_count: number;\n revenue_cents: number;\n}\n\nexport interface SalesCategoryBreakdown {\n category: string | null;\n currency: SupportedCurrency;\n /** sum(unit_price_cents * quantity) — advisory, not authoritative. */\n revenue_cents: number;\n /** Decimal string. */\n quantity: string;\n}\n\nexport interface SalesTrendPoint {\n bucket_start: string;\n currency: SupportedCurrency;\n sale_count: number;\n revenue_cents: number;\n}\n\n/**\n * @deprecated The v1 summary response. `summary()` returns {@link SaleSummaryV2};\n * the v1 endpoint no longer exists. Removed in the next major.\n */\nexport interface SaleSummary {\n range: TrackingOverviewRange;\n business_id: string | null;\n metrics: {\n sale_count: number;\n distinct_customers: number;\n by_currency: CurrencyRevenue[];\n };\n by_service: SalesServiceBreakdown[];\n by_currency: CurrencyRevenue[];\n by_category: SalesCategoryBreakdown[];\n series: SalesTrendPoint[];\n}\n\n// ---------------------------------------------------------------------------\n// v2 dashboard surface (additive, back-compat). Reads only → plain interfaces,\n// not drift-checked. Legacy `24h/7d/30d` callers get the same numbers; new\n// ranges/options layer on tz-correct calendar windows + comparisons.\n// ---------------------------------------------------------------------------\n\nexport const NAMED_RANGES = [\n \"today\",\n \"yesterday\",\n \"wtd\",\n \"mtd\",\n \"qtd\",\n \"ytd\",\n \"24h\",\n \"7d\",\n \"30d\",\n \"90d\",\n \"custom\",\n] as const;\n/** Superset of `TrackingOverviewRange` (`24h`/`7d`/`30d` stay valid). */\nexport type NamedRange = (typeof NAMED_RANGES)[number];\nexport type Granularity = \"hour\" | \"day\" | \"week\" | \"month\" | \"auto\";\nexport type CompareTo = \"previous_period\" | \"previous_year\" | \"none\";\n\nexport interface DistinctCustomersByCurrency {\n currency: SupportedCurrency;\n distinct_customers: number;\n}\n\nexport interface SummaryWindow {\n since: string;\n until: string;\n}\n\nexport interface SummaryCurrencyDelta {\n currency: SupportedCurrency;\n revenue_cents_delta: number;\n /** Ratio (0.12 = +12%); null when previous revenue was 0. */\n revenue_pct_delta: number | null;\n sale_count_delta: number;\n}\n\nexport interface SummaryDeltas {\n by_currency: SummaryCurrencyDelta[];\n sale_count_delta: number;\n distinct_customers_delta: number;\n}\n\nexport interface SaleSummaryPrevious {\n window: SummaryWindow;\n metrics: SaleSummaryV2[\"metrics\"];\n by_currency: CurrencyRevenue[];\n series: SalesTrendPoint[];\n}\n\n/** Query input for `SalesClient.summary()` v2 — widened range + tz/compare options. */\nexport interface SaleSummaryQueryV2 extends SaleFilters {\n range?: NamedRange;\n /** Required when `range === 'custom'`. */\n since?: string;\n until?: string;\n /** IANA tz, e.g. `America/Toronto`. Default = business tz. */\n timezone?: string;\n granularity?: Granularity;\n compare_to?: CompareTo;\n include_categories?: boolean;\n include_deleted_services?: boolean;\n top_n?: number;\n}\n\n/** Superset of `SaleSummary` (assignable to it). */\nexport interface SaleSummaryV2 extends Omit<SaleSummary, \"range\" | \"metrics\"> {\n range: NamedRange;\n metrics: SaleSummary[\"metrics\"] & {\n distinct_customers_by_currency: DistinctCustomersByCurrency[];\n };\n timezone: string | null;\n window: SummaryWindow | null;\n granularity: string | null;\n previous: SaleSummaryPrevious | null;\n deltas: SummaryDeltas | null;\n}\n\n// ---------------------------------------------------------------------------\n// Customers — phone-keyed (a customer IS their E.164 phone). `customer_id` is\n// the E.164 string. Money is per-currency, never summed.\n// ---------------------------------------------------------------------------\n\nexport type CustomerSegment = \"new\" | \"returning\" | \"repeat\" | \"lapsed\";\nexport type CustomerSortField = \"total_spent\" | \"last_purchase\" | \"purchases\" | \"first_purchase\";\n\nexport interface CustomerCurrencyTotal {\n currency: SupportedCurrency;\n revenue_cents: number;\n sale_count: number;\n average_order_value_cents: number;\n}\n\nexport interface CustomerSummary {\n /** The E.164 phone — the canonical customer id. */\n customer_id: string;\n display_name: string | null;\n email: string | null;\n phone: string;\n first_purchase_at: string;\n last_purchase_at: string;\n purchase_count: number;\n segment: CustomerSegment;\n totals: CustomerCurrencyTotal[];\n}\n\nexport type CustomerProfile = CustomerSummary;\n\nexport interface CustomerListQuery extends Omit<SaleFilters, \"external_id\"> {\n segment?: CustomerSegment;\n sort?: CustomerSortField;\n order?: SaleSortOrder;\n cursor?: string | null;\n limit?: number;\n want_total?: boolean;\n}\n\nexport interface CustomerListPage {\n items: CustomerSummary[];\n next_cursor: string | null;\n total_count: number | null;\n}\n\nexport interface CustomerGetOptions {\n include_sales?: boolean;\n limit?: number;\n cursor?: string | null;\n}\n\nexport interface CustomerGetResult {\n customer: CustomerProfile;\n /** Present only when `include_sales: true` (and a business-scoped key). */\n sales?: SaleCursorPage | null;\n}\n\nexport interface CustomerSummaryQuery extends SaleFilters {\n range?: NamedRange;\n since?: string;\n until?: string;\n timezone?: string;\n compare_to?: CompareTo;\n}\n\nexport interface CustomerSegmentCount {\n segment: CustomerSegment;\n count: number;\n}\n\nexport interface CustomerCurrencyDelta {\n currency: SupportedCurrency;\n revenue_cents_delta: number;\n /** Ratio (0.12 = +12%); null when previous revenue was 0. */\n revenue_pct_delta: number | null;\n}\n\nexport interface CustomerKpisDeltas {\n total_customers_delta: number;\n new_customers_delta: number;\n returning_customers_delta: number;\n repeat_rate_delta: number;\n ltv_by_currency: CustomerCurrencyDelta[];\n}\n\nexport interface CustomerKpisPrevious {\n window_since: string;\n window_until: string;\n total_customers: number;\n new_customers: number;\n returning_customers: number;\n repeat_rate: number;\n ltv_by_currency: CustomerCurrencyTotal[];\n}\n\nexport interface CustomerKpis {\n range: NamedRange;\n timezone: string;\n window_since: string;\n window_until: string;\n total_customers: number;\n new_customers: number;\n returning_customers: number;\n repeat_rate: number;\n by_segment: CustomerSegmentCount[];\n ltv_by_currency: CustomerCurrencyTotal[];\n /** Present only when `compare_to` is set. */\n previous?: CustomerKpisPrevious | null;\n deltas?: CustomerKpisDeltas | null;\n}\n\n// ---------------------------------------------------------------------------\n// Business config — supersets `fetchServices()`.\n// ---------------------------------------------------------------------------\n\nexport interface BusinessConfigService {\n key: string;\n label: string;\n archived: boolean;\n}\n\nexport interface BusinessConfigFeatures {\n customers: boolean;\n comparisons: boolean;\n retention: boolean;\n}\n\n/** A built-in customer field a business can require on every sale. */\nexport type SaleBuiltinRequirableField =\n | \"customer_name\"\n | \"customer_phone\"\n | \"customer_email\"\n | \"description\";\n\n/** v1 value types. `enum` is single-select over the property's `options`. */\nexport type SalePropertyType = \"string\" | \"number\" | \"boolean\" | \"enum\" | \"date\";\n\nexport interface SalePropertyOption {\n key: string;\n label: string;\n}\n\n/** One custom sale property as the sale form sees it: overrides applied, options\n * already filtered to the business's enabled subset. */\nexport interface EffectiveSaleProperty {\n key: string;\n label: string;\n type: SalePropertyType;\n required: boolean;\n options?: SalePropertyOption[] | null;\n}\n\nexport interface BusinessConfig {\n business_id: string;\n display_name: string;\n timezone: string;\n primary_currency: SupportedCurrency;\n currencies: SupportedCurrency[];\n default_phone_country: string | null;\n services: BusinessConfigService[];\n features: BusinessConfigFeatures;\n // The sale-form contract. Read these BEFORE building a sale form: they say\n // what THIS business requires, so a missing field is a render decision rather\n // than a 422 discovered on write.\n builtin_required: SaleBuiltinRequirableField[];\n properties: EffectiveSaleProperty[];\n /** Whether the form should pre-tick the SMS consent box. A form default only —\n * the per-sale `sms_consent` attestation is what every send path gates on. */\n sms_consent_default_opt_in: boolean;\n}\n","// This file is auto-generated by @hey-api/openapi-ts\n\nimport * as z from \"zod/mini\";\n\n/**\n * AutoLeapSaleFacts\n *\n * What the AutoLeap sync stamps on a sale; the writeback picks its conversion action from it.\n */\nexport const zAutoLeapSaleFacts = z.object({\n first_order: z.boolean(),\n fleet_customer: z.boolean(),\n invoice_code: z.string(),\n invoice_id: z.string(),\n});\n\n/**\n * ErrorCode\n *\n * Stable discriminator for a failed request. Values are contract: never rename.\n */\nexport const zErrorCode = z.enum([\n \"missing_credentials\",\n \"invalid_api_key\",\n \"not_authenticated\",\n \"wrong_key_type\",\n \"origin_missing\",\n \"origin_not_allowed\",\n \"capability_denied\",\n \"session_required\",\n \"operator_only\",\n \"cross_tenant\",\n \"business_id_required\",\n \"bad_request\",\n \"not_found\",\n \"conflict\",\n \"gone\",\n \"incompatible_payload\",\n \"repeat_confirmation_required\",\n \"idempotency_conflict\",\n \"slot_unavailable\",\n \"calendar_not_connected\",\n \"calendar_disconnected\",\n \"booking_not_found\",\n \"booking_not_cancellable\",\n \"booking_not_reschedulable\",\n \"booking_not_synced\",\n \"manage_token_invalid\",\n \"google_error\",\n \"booking_unavailable\",\n \"booking_config_stale\",\n \"booking_rejected\",\n \"booking_attempt_mismatch\",\n \"calendar_not_owned\",\n \"calendar_is_sandbox\",\n \"sandbox_exists\",\n \"sandbox_key_too_long\",\n \"sandbox_not_found\",\n \"validation_error\",\n \"unknown_service\",\n \"invalid_sale_properties\",\n \"invalid_cursor\",\n \"rate_limited\",\n \"internal_error\",\n]);\n\n/**\n * SaleItemEntitySchema\n */\nexport const zSaleItemEntitySchema = z.object({\n category: z.nullable(z.string()),\n external_item_id: z.nullable(z.string()),\n id: z.uuid(),\n name: z.nullable(z.string()),\n quantity: z.string().check(z.regex(/^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$/)),\n unit_cost_cents: z.nullable(z.int()),\n unit_price_cents: z.int(),\n});\n\n/**\n * SaleItemInputSchema\n */\nexport const zSaleItemInputSchema = z.strictObject({\n category: z.nullish(z.string()),\n external_item_id: z.nullish(z.string()),\n name: z.nullish(z.string()),\n quantity: z.string().check(z.regex(/^\\d+(\\.\\d{1,3})?$/)),\n unit_cost_cents: z.nullish(z.int().check(z.gte(0))),\n unit_price_cents: z.int().check(z.gte(0)),\n});\n\n/**\n * SaleServiceEntitySchema\n *\n * A service covered by a sale, with its price and resolved taxonomy labels.\n */\nexport const zSaleServiceEntitySchema = z.object({\n amount_cents: z.int(),\n id: z.uuid(),\n mapped: z.nullable(z.boolean()),\n service_id: z.uuid(),\n service_key: z.string(),\n service_label: z.string(),\n});\n\n/**\n * SaleServiceInputSchema\n *\n * One service covered by a sale, at its own price. `service` is the\n * per-business service key, validated against the taxonomy at write time.\n */\nexport const zSaleServiceInputSchema = z.strictObject({\n amount_cents: z.int().check(z.gte(0)),\n service: z.string(),\n});\n\n/**\n * SaleSource\n *\n * Which path created a sale; server-set, never caller-writable.\n */\nexport const zSaleSource = z.enum([\"api\", \"admin\", \"autoleap\"]);\n\n/**\n * SmsAutomationState\n *\n * A sale's automation outcome, worst-first.\n *\n * Ordering is the rollup's precedence: a sale with both a failed send and a\n * withheld condition surfaces the failure, because that is what an operator\n * must act on first.\n */\nexport const zSmsAutomationState = z.enum([\n \"needs_check\",\n \"failed\",\n \"undelivered\",\n \"blocked_opted_out\",\n \"withheld\",\n \"queued\",\n \"sent\",\n \"delivered\",\n \"eligible\",\n \"not_applicable\",\n]);\n\n/**\n * SmsWithheldReason\n *\n * Why an outbound text cannot send right now; the trigger type picks the subset.\n *\n * Derived on read, except a form lead's, stored on `sms_form_lead_outcomes` (ADR-0016).\n * Shared by senders, operator 422s and read models, so operators read what the sender enforced.\n */\nexport const zSmsWithheldReason = z.enum([\n \"automation_disabled\",\n \"sms_disabled\",\n \"no_phone\",\n \"no_consent\",\n \"no_customer_identity\",\n \"missing_customer_name\",\n \"placeholder_customer_name\",\n \"opted_out\",\n \"already_messaged\",\n \"sender_not_ready\",\n \"missing_review_url\",\n \"no_handler\",\n \"dispatch_already_recorded\",\n \"text_back_disabled\",\n \"number_text_back_disabled\",\n \"number_not_active\",\n \"a2p_registration_required\",\n \"alert_recipient_cannot_receive_sms\",\n \"line_type_unresolved\",\n \"sale_source_sends_own_messages\",\n \"form_lead_disabled\",\n \"source_not_enabled\",\n \"confirmed_by_integration\",\n \"test_or_bot_session\",\n \"invalid_phone\",\n \"recently_texted\",\n]);\n\n/**\n * SaleSmsStatusSchema\n *\n * Computed per-sale automation rollup (see `services/sms_rollup_service.py`).\n *\n * Attached to sales list rows when `include_sms` is requested. Derived on read,\n * never stored — `state` reflects live configuration, so the same sale can move\n * from `withheld` to `eligible` when an operator fixes the underlying gap.\n */\nexport const zSaleSmsStatusSchema = z.object({\n can_send_now: z._default(z.boolean(), false),\n last_message_at: z.nullable(z.iso.datetime({ offset: true })),\n reason_detail: z.nullable(z.string()),\n state: zSmsAutomationState,\n suppressed_by_other_sale: z._default(z.boolean(), false),\n withheld_reason: z.nullable(zSmsWithheldReason),\n});\n\n/**\n * SupportedCurrency\n *\n * Accepted ISO-4217 currencies (extend as clients are onboarded).\n *\n * Single backend source of the accepted set; mirrored by the Zod\n * `z.enum([...])` on the client so the drift test keeps them in sync.\n */\nexport const zSupportedCurrency = z.enum([\"USD\", \"CAD\"]);\n\n/**\n * TrackingEnvironment\n *\n * Deployment environment for tracking sessions and events.\n *\n * Stamped on every session and event so the dashboard can filter test\n * traffic from real production data. The SDK reports this via the\n * `context.environment` field on the ingest payload.\n */\nexport const zTrackingEnvironment = z.enum([\"production\", \"development\"]);\n\n/**\n * SaleCreateSchema\n */\nexport const zSaleCreateSchema = z.strictObject({\n amount_total_cents: z.nullish(z.int().check(z.gte(0))),\n currency: zSupportedCurrency,\n customer_email: z.nullish(z.email().check(z.maxLength(320))),\n customer_name: z.nullish(z.string().check(z.maxLength(200))),\n customer_phone: z.nullish(z.string().check(z.maxLength(64))),\n description: z.nullish(z.string()),\n environment: z._default(z.optional(zTrackingEnvironment), \"production\"),\n external_id: z.nullish(z.string()),\n items: z.optional(z.array(zSaleItemInputSchema)),\n metadata: z.nullish(z.record(z.string(), z.unknown())),\n occurred_at: z.iso.datetime({ offset: true }),\n service: z.nullish(z.string()),\n services: z.nullish(z.array(zSaleServiceInputSchema)),\n sms_consent: z.nullish(z.boolean()),\n});\n\n/**\n * ValidationError\n */\nexport const zValidationError = z.object({\n ctx: z.optional(z.record(z.string(), z.unknown())),\n input: z.optional(z.unknown()),\n loc: z.array(z.union([z.string(), z.int()])),\n msg: z.string(),\n type: z.string(),\n});\n\n/**\n * HTTPValidationError\n */\nexport const zHttpValidationError = z.object({\n detail: z.optional(z.array(zValidationError)),\n});\n\n/**\n * ValidationErrorItem\n *\n * One field-level failure from FastAPI's request validation.\n */\nexport const zValidationErrorItem = z.object({\n loc: z.array(z.union([z.string(), z.int()])),\n msg: z.string(),\n type: z.string(),\n});\n\n/**\n * ApiErrorSchema\n *\n * The body of every non-2xx response.\n */\nexport const zApiErrorSchema = z.object({\n code: zErrorCode,\n context: z._default(z.nullish(z.record(z.string(), z.unknown())), null),\n detail: z.string(),\n errors: z._default(z.nullish(z.array(zValidationErrorItem)), null),\n request_id: z._default(z.nullish(z.string()), null),\n});\n\n/**\n * WritebackEmailSource\n *\n * Provenance of the email identifier the upload carried. NONE = no email\n * sent; SALE = the sale's own email; CUSTOMER = same-environment customer\n * fallback; HARVESTED = adopted from a matching form submit.\n */\nexport const zWritebackEmailSource = z.enum([\"NONE\", \"SALE\", \"CUSTOMER\", \"HARVESTED\"]);\n\n/**\n * WritebackEnrichmentStatus\n *\n * Outcome of the form-submit identity-enrichment scan at submit time.\n *\n * NONE = no form submit matched the sale's identity (also the pre-submit\n * default); MATCHED = ≥1 submit matched with a consistent identity;\n * AMBIGUOUS = matches carried >1 distinct emails, so nothing harvested was\n * trusted (phone-only upload, no gclid).\n */\nexport const zWritebackEnrichmentStatus = z.enum([\"NONE\", \"MATCHED\", \"AMBIGUOUS\"]);\n\n/**\n * WritebackSaleUploadCounts\n */\nexport const zWritebackSaleUploadCounts = z.object({\n failed: z._default(z.int(), 0),\n pending: z._default(z.int(), 0),\n skipped: z._default(z.int(), 0),\n submitted: z._default(z.int(), 0),\n total: z._default(z.int(), 0),\n uploaded: z._default(z.int(), 0),\n});\n\n/**\n * SaleWritebackStatusSchema\n *\n * Per-sale writeback rollup attached to the admin sales list rows when\n * `include_writeback` is requested (admin-only — the unified sales table's\n * Writeback column). Never-dispatched sales carry a dispatch readiness the\n * ledger-driven uploads feed can't express.\n */\nexport const zSaleWritebackStatusSchema = z.object({\n blocked_reason: z.nullable(z.string()),\n can_dispatch: z._default(z.boolean(), false),\n email_source: z.nullable(zWritebackEmailSource),\n enrichment_status: z.nullable(zWritebackEnrichmentStatus),\n gclid_attached: z._default(z.boolean(), false),\n identifiers_sent: z.nullable(z.string()),\n mapped_service_ids: z.array(z.uuid()),\n matched_submit_count: z.nullable(z.int()),\n needs_force: z._default(z.boolean(), false),\n rollup: z.enum([\n \"failed\",\n \"skipped\",\n \"pending\",\n \"submitted\",\n \"uploaded\",\n \"not_uploaded\",\n \"out_of_window\",\n \"blocked\",\n ]),\n stale: z._default(z.boolean(), false),\n uncovered_service_ids: z.array(z.uuid()),\n uploads: zWritebackSaleUploadCounts,\n});\n\n/**\n * SaleEntitySchema\n */\nexport const zSaleEntitySchema = z.object({\n amount_total_cents: z.int(),\n business_id: z.uuid(),\n business_name: z.nullable(z.string()),\n created_at: z.iso.datetime({ offset: true }),\n currency: z.string(),\n customer_email: z.nullable(z.string()),\n customer_name: z.nullable(z.string()),\n customer_name_placeholder_confirmed: z._default(z.boolean(), false),\n customer_name_placeholder_confirmed_at: z.nullable(z.iso.datetime({ offset: true })),\n customer_phone: z.nullable(z.string()),\n description: z.nullable(z.string()),\n environment: zTrackingEnvironment,\n external_id: z.nullable(z.string()),\n id: z.uuid(),\n items: z.array(zSaleItemEntitySchema),\n metadata: z.nullable(z.record(z.string(), z.unknown())),\n occurred_at: z.iso.datetime({ offset: true }),\n service_id: z.nullable(z.uuid()),\n service_key: z.nullable(z.string()),\n service_label: z.nullable(z.string()),\n services: z.array(zSaleServiceEntitySchema),\n sms_automation: z.nullable(zSaleSmsStatusSchema),\n sms_consent: z._default(z.boolean(), false),\n sms_consent_granted_at: z.nullable(z.iso.datetime({ offset: true })),\n source: zSaleSource,\n source_facts: z.nullable(zAutoLeapSaleFacts),\n updated_at: z.iso.datetime({ offset: true }),\n writeback: z.nullable(zSaleWritebackStatusSchema),\n});\n","import { salesRequest, type SalesTransportConfig } from \"./sales/transport\";\n\n/** A business's active service, as returned by `GET /tracking/services`. */\nexport interface PublicServiceItem {\n key: string;\n label: string;\n}\n\n/**\n * Fetch the caller's business's active service taxonomy. Accepts a public or\n * secret key (the taxonomy is low-sensitivity category names). Powers the\n * `@aranova/tracking-cli gen` codegen.\n */\nexport async function fetchServices(config: SalesTransportConfig): Promise<PublicServiceItem[]> {\n return salesRequest<PublicServiceItem[]>(config, \"GET\", \"/services\");\n}\n"],"mappings":"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","names":["email","email","iso","record","record","z","z"]}