@aranova/tracking-next 0.20.2 → 0.21.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/calendar.ts","../../tracking-core/src/resources/http/errors.ts","../../tracking-core/src/phone.ts","../../tracking-core/src/events/page-view.ts","../../tracking-core/src/session.ts","../../tracking-core/src/ingest.ts","../../tracking-core/src/resources/http/request.ts","../../tracking-core/src/resources/calendar/transport.ts","../../tracking-core/src/resources/intl/date.ts","../../tracking-core/src/resources/calendar/time.ts","../../tracking-core/src/resources/calendar/read-client.ts","../../tracking-core/src/resources/calendar/schema.ts","../../tracking-core/src/resources/calendar/slots.ts"],"sourcesContent":["// Browser-safe calendar SDK entry — `@aranova/tracking-next/calendar`.\n// React-free and write-free: reads take a public key. Booking writes live in\n// `./calendar-server`. Authored once in tracking-core; this is the tsup entry.\nexport * from \"../../tracking-core/src/calendar-public\";\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\n constructor(message: string, options: { status: number; code?: string; requestId?: string }) {\n super(message);\n this.name = \"AranovaApiError\";\n this.status = options.status;\n this.code = options.code;\n this.requestId = options.requestId;\n }\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","import { z } from \"zod\";\n\n/**\n * Metadata for the automatic `page_view` event.\n *\n * The SDK emits this on initial load, SPA route changes, and bfcache restores.\n * Consumers do not call `trackEvent('page_view', ...)`; registering\n * `automatic: { page_view: {} }` enables the SDK-owned trigger.\n */\nexport const pageViewMetadataSchema = z\n .object({\n page: z\n .object({\n title: z.string().nullable(),\n path: z.string(),\n search: z.string(),\n hash: z.string(),\n })\n .strict(),\n referrer: z.string().nullable(),\n // `.nullable().optional()` — absent (undefined) OR explicit null OR a\n // real viewport object. Mirrors Pydantic's `_Viewport | None = None`\n // on the backend side so the drift test stays clean.\n viewport: z\n .object({\n w: z.number(),\n h: z.number(),\n })\n .strict()\n .nullable()\n .optional(),\n })\n .strict();\n\nexport type PageViewMetadata = z.infer<typeof pageViewMetadataSchema>;\n\n/**\n * Registration config for automatic `page_view`.\n *\n * `page_view` is required in every trigger registry and currently has no\n * options. Use `{ page_view: {} }`.\n */\nexport const pageViewConfigSchema = z.object({}).strict();\nexport type PageViewConfig = z.infer<typeof pageViewConfigSchema>;\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\";\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 safeUuid(): string {\n if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\")\n return crypto.randomUUID();\n // Fallback for ancient browsers — not cryptographically perfect but unique enough.\n return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`;\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 safeUuid();\n\n const existing = readLocalStorage(VISITOR_STORAGE_KEY);\n if (existing && existing.length > 0) return existing;\n\n const fresh = safeUuid();\n writeLocalStorage(VISITOR_STORAGE_KEY, fresh);\n return fresh;\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: safeUuid(), 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: safeUuid(), last_event_at: now };\n writeLocalStorage(SESSION_STORAGE_KEY, JSON.stringify(fresh));\n return { id: fresh.id, isNew: true };\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","// 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 { getConsentChoice, getConsentState } from \"./consent\";\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 * 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 /** 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 };\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<void> {\n if (typeof fetch !== \"function\") return;\n try {\n 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 } catch {\n // fire-and-forget — never throw to the host site\n }\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 }\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 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 // 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 = 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 // Queue an sdk_heartbeat event at the start of every new session.\n function enqueueHeartbeat(): void {\n const metadata = buildHeartbeatMetadata(\n config.surface,\n sdkVersion,\n packageName,\n config.triggers ?? null,\n activeGtagIds,\n );\n queue.push({\n event_type: \"sdk_heartbeat\",\n page_url: typeof window === \"undefined\" ? null : window.location.href,\n metadata: metadata as unknown as Record<string, unknown>,\n occurred_at: new Date().toISOString(),\n });\n }\n\n if (initialSession.isNew) {\n enqueueHeartbeat();\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 }\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 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 first_page: firstPage,\n consent_state: consentSnapshot(),\n context,\n };\n }\n\n function scheduleFlush(): void {\n if (flushTimer !== null || destroyed) return;\n flushTimer = setTimeout(() => {\n flushTimer = null;\n void flush();\n }, flushIntervalMs);\n }\n\n function clearScheduledFlush(): void {\n if (flushTimer !== null) {\n clearTimeout(flushTimer);\n flushTimer = null;\n }\n }\n\n async function flush(): Promise<void> {\n if (queue.length === 0) return;\n\n const events = queue.slice(0, HARD_MAX_BATCH);\n queue = queue.slice(events.length);\n clearScheduledFlush();\n\n const body: IngestRequestBody = {\n session: buildSessionPayload(),\n events,\n };\n\n const serialized = JSON.stringify(body);\n\n // Prefer fetch + keepalive for better error visibility. sendBeacon is the\n // pagehide fallback path because it survives navigation away.\n await postWithFetch(eventsUrl, serialized, config.apiKey, identityHeaders, false);\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 page_url: input.pageUrl ?? (typeof window === \"undefined\" ? null : window.location.href),\n metadata: input.metadata ?? null,\n occurred_at: occurredAt,\n });\n\n if (queue.length >= maxQueueSize) {\n void flush();\n } else {\n scheduleFlush();\n }\n }\n\n function flushOnUnload(): void {\n if (queue.length === 0) return;\n const events = queue.slice(0, HARD_MAX_BATCH);\n queue = queue.slice(events.length);\n clearScheduledFlush();\n\n const body: IngestRequestBody = {\n session: buildSessionPayload(),\n events,\n };\n const serialized = JSON.stringify(body);\n void postWithFetch(eventsUrl, serialized, config.apiKey, identityHeaders, true);\n }\n\n if (typeof window !== \"undefined\") {\n window.addEventListener(\"pagehide\", flushOnUnload);\n window.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState === \"hidden\") flushOnUnload();\n });\n }\n\n return {\n trackEvent,\n flush,\n flushBeacon: flushOnUnload,\n getSessionId: () => sessionId,\n getVisitorId: () => visitorId,\n destroy: () => {\n destroyed = true;\n // Fire any pending events through the keepalive path before tearing\n // down. Critical for React StrictMode in dev, where the provider's\n // first mount is immediately unmounted and its 2s debounce would\n // otherwise drop the initial page_view on the floor. Uses fetch\n // keepalive so the request survives the component tearing down.\n if (queue.length > 0) {\n flushOnUnload();\n }\n clearScheduledFlush();\n queue = [];\n if (typeof window !== \"undefined\") {\n window.removeEventListener(\"pagehide\", flushOnUnload);\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 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 }\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 });\n }\n\n if (response.status === 204) return undefined as T;\n return (await response.json()) as T;\n}\n","import type { ApiTransportConfig } from \"../http/request\";\nimport { apiRequest } from \"../http/request\";\n\nexport type CalendarTransportConfig = ApiTransportConfig;\n\n/** Prefixes `/calendar` so callers pass resource-relative paths. */\nexport async function calendarRequest<T>(\n config: CalendarTransportConfig,\n method: string,\n path: string,\n body?: unknown,\n): Promise<T> {\n return apiRequest<T>(config, method, `/calendar${path}`, body);\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 { formatDateInTz } from \"../intl/date\";\n\nexport { formatDateInTz };\n\n/** The visitor's own zone, so a booking page defaults to their local times. */\nexport function resolveBrowserTimeZone(): string {\n try {\n return Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\";\n } catch {\n return \"UTC\";\n }\n}\n\n/** Parts of an instant as seen in a specific zone. */\nfunction partsInTz(date: Date, timeZone: string): Record<string, number> {\n const formatter = new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n hour12: false,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n second: \"2-digit\",\n });\n const out: Record<string, number> = {};\n for (const part of formatter.formatToParts(date)) {\n if (part.type !== \"literal\") out[part.type] = Number(part.value);\n }\n // `hour` comes back as 24 at midnight under hour12:false in some engines.\n if (out.hour === 24) out.hour = 0;\n return out;\n}\n\n/** Offset in minutes between UTC and `timeZone` at a given instant. */\nexport function offsetMinutesAt(date: Date, timeZone: string): number {\n const p = partsInTz(date, timeZone);\n const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);\n return (asUtc - Math.floor(date.getTime() / 1000) * 1000) / 60_000;\n}\n\n/**\n * The instant matching a wall-clock time in `timeZone`.\n *\n * Two passes because the offset itself depends on the instant: guess with the\n * offset at the naive time, then re-solve with the offset that guess landed in.\n * That second pass is what makes DST-transition days come out right.\n */\nexport function instantFromWallClock(\n timeZone: string,\n year: number,\n month: number,\n day: number,\n hour = 0,\n minute = 0,\n): Date {\n const naive = Date.UTC(year, month - 1, day, hour, minute);\n const firstGuess = new Date(naive - offsetMinutesAt(new Date(naive), timeZone) * 60_000);\n const corrected = naive - offsetMinutesAt(firstGuess, timeZone) * 60_000;\n return new Date(corrected);\n}\n\n/** `YYYY-MM-DD` for an instant as seen in `timeZone`. */\nexport function localDateKey(iso: string, timeZone: string): string {\n const p = partsInTz(new Date(iso), timeZone);\n const pad = (n: number) => String(n).padStart(2, \"0\");\n return `${p.year}-${pad(p.month)}-${pad(p.day)}`;\n}\n\nexport function startOfDayInTz(dateKey: string, timeZone: string): Date {\n const [year, month, day] = dateKey.split(\"-\").map(Number);\n return instantFromWallClock(timeZone, year, month, day, 0, 0);\n}\n\nexport function endOfDayInTz(dateKey: string, timeZone: string): Date {\n const start = startOfDayInTz(dateKey, timeZone);\n // Add a calendar day rather than 24h: DST days are 23 or 25 hours long.\n const p = partsInTz(start, timeZone);\n return instantFromWallClock(timeZone, p.year, p.month, p.day + 1, 0, 0);\n}\n\nexport function durationMinutes(startIso: string, endIso: string): number {\n return Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60_000);\n}\n\n/** `10:00 – 10:30` in the given zone. */\nexport function formatSlotRange(\n startIso: string,\n endIso: string,\n timeZone: string,\n locale?: string,\n): string {\n const opts: Intl.DateTimeFormatOptions = { hour: \"2-digit\", minute: \"2-digit\", timeZone };\n const fmt = new Intl.DateTimeFormat(locale, opts);\n return `${fmt.format(new Date(startIso))} – ${fmt.format(new Date(endIso))}`;\n}\n","import { AranovaApiError } from \"../http/errors\";\nimport type { BusyResponse, CalendarErrorCode, CalendarSummary } from \"./schema\";\nimport type { CalendarTransportConfig } from \"./transport\";\nimport { calendarRequest } from \"./transport\";\nimport { resolveBrowserTimeZone } from \"./time\";\n\n/**\n * The browser-side calendar client — reads only.\n *\n * There is deliberately no write verb on this type. A public key ships in a JS\n * bundle and its Origin allowlist is a browser-enforced header a script forges\n * trivially; a booking is not a database row but an event on a real person's\n * calendar that mails their contacts. Writes live in\n * `@aranova/tracking-next/calendar-server`, which is `server-only`.\n */\nexport interface CalendarReadClientConfig extends CalendarTransportConfig {\n /** Falls back to the visitor's own zone. */\n defaultTimeZone?: string;\n /** Required when the key is not already scoped to one business. */\n businessId?: string;\n}\n\nexport interface BusyQuery {\n /**\n * ISO 8601 instants WITH an offset — the API rejects a naive or date-only\n * value with a 400. `startOfDayInTz`/`endOfDayInTz` turn a picked date into\n * the right pair for a given zone.\n */\n since: string;\n until: string;\n timeZone?: string;\n}\n\nexport interface CalendarReadClient<TCalendarKey extends string = string> {\n /** Labels, timezones and status — everything a widget needs before booking. */\n listCalendars(): Promise<CalendarSummary[]>;\n /** Busy intervals only. Never the contents of the grantee's events. */\n busy(calendarKey: TCalendarKey, query: BusyQuery): Promise<BusyResponse>;\n}\n\nfunction withBusiness(config: CalendarReadClientConfig, path: string): string {\n if (!config.businessId) return path;\n const separator = path.includes(\"?\") ? \"&\" : \"?\";\n return `${path}${separator}business_id=${encodeURIComponent(config.businessId)}`;\n}\n\nexport function createCalendarReadClient<TCalendarKey extends string = string>(\n config: CalendarReadClientConfig,\n): CalendarReadClient<TCalendarKey> {\n return {\n async listCalendars() {\n return calendarRequest<CalendarSummary[]>(config, \"GET\", withBusiness(config, \"/calendars\"));\n },\n async busy(calendarKey, query) {\n const params = new URLSearchParams({\n since: query.since,\n until: query.until,\n });\n const timeZone = query.timeZone ?? config.defaultTimeZone ?? resolveBrowserTimeZone();\n params.set(\"time_zone\", timeZone);\n return calendarRequest<BusyResponse>(\n config,\n \"GET\",\n withBusiness(config, `/${encodeURIComponent(calendarKey)}/busy?${params.toString()}`),\n );\n },\n };\n}\n\n/** Narrow an unknown error to a specific calendar failure code. */\nexport function isCalendarError(\n error: unknown,\n code?: CalendarErrorCode,\n): error is AranovaApiError {\n if (!(error instanceof AranovaApiError)) return false;\n return code === undefined || error.code === code;\n}\n","import { z } from \"zod\";\n\n// Mirrored by `apps/api/src/schemas/tracking_calendar.py` and pinned by the\n// event-schema drift test. Keep both sides in step.\n//\n// The location union is a plain `z.union`, deliberately: a discriminated union\n// emits `oneOf` + discriminator metadata on the Python side and `anyOf` here,\n// which would fail the drift snapshot. Both sides stay plain.\n\n/** Bumped when the durable `metadata` payload changes shape. */\nexport const CALENDAR_METADATA_SCHEMA_VERSION = 1;\n\nexport const MAX_ATTENDEES = 20;\nexport const MAX_REMINDER_OVERRIDES = 5;\n/** Google's ceiling for `reminders.overrides[].minutes`: four weeks. */\nexport const MAX_REMINDER_MINUTES = 40_320;\nexport const MAX_BOOKING_MINUTES = 1_440;\n\nexport const BOOKING_SOURCES = [\"website\", \"phone\", \"walk_in\", \"import\", \"operator\"] as const;\nexport const BOOKING_STATUSES = [\"pending\", \"confirmed\", \"cancelled\", \"no_show\"] as const;\nexport const GOOGLE_SYNC_STATES = [\"queued\", \"synced\", \"uncertain\", \"failed\", \"drifted\"] as const;\nexport const CALENDAR_SYNC_STATUSES = [\n \"pending_invite\",\n \"awaiting_calendar_selection\",\n \"connected\",\n \"needs_reconnect\",\n \"revoked\",\n \"disabled\",\n] as const;\n\n/** Every failure the calendar API can hand back, so a site can branch on it. */\nexport const CALENDAR_ERROR_CODES = [\n \"slot_unavailable\",\n \"calendar_not_connected\",\n \"calendar_disconnected\",\n \"booking_not_found\",\n \"outside_booking_window\",\n \"rate_limited\",\n \"google_error\",\n] as const;\nexport type CalendarErrorCode = (typeof CALENDAR_ERROR_CODES)[number];\n\nexport type BookingStatus = (typeof BOOKING_STATUSES)[number];\nexport type GoogleSyncState = (typeof GOOGLE_SYNC_STATES)[number];\nexport type CalendarSyncStatus = (typeof CALENDAR_SYNC_STATUSES)[number];\nexport type JsonScalar = string | number | boolean | null;\n\nconst isoInstant = z.string().datetime({ offset: true });\n\n/**\n * Does the runtime actually know this zone?\n *\n * `Intl.DateTimeFormat` rather than `Intl.supportedValuesOf(\"timeZone\")`: the\n * latter returns canonical zones only and omits live aliases (`Asia/Calcutta`,\n * `US/Pacific`, `Europe/Kiev`) that the API's `ZoneInfo` accepts. Validating\n * against it would reject values the server stores happily.\n */\nfunction isKnownTimeZone(timeZone: string): boolean {\n try {\n new Intl.DateTimeFormat(undefined, { timeZone });\n return true;\n } catch {\n return false;\n }\n}\n\n// The refinement is invisible to `zod-to-json-schema` — unrepresentable\n// constraints are dropped — so `events.schema.json` and the drift test against\n// the Pydantic mirror are unaffected while both sides gain the real check.\nconst ianaTz = z\n .string()\n .min(1)\n .max(64)\n .regex(/^[A-Za-z0-9_+\\-/]+$/)\n .refine(isKnownTimeZone, { message: \"unknown IANA time zone\" });\n\nexport const bookingAttendeeSchema = z\n .object({\n email: z.string().max(320).email(),\n display_name: z.string().max(200).nullable().optional(),\n optional: z.boolean().default(false),\n })\n .strict();\n\nexport const bookingReminderSchema = z\n .object({\n method: z.enum([\"email\", \"popup\"]),\n minutes_before: z.number().int().min(0).max(MAX_REMINDER_MINUTES),\n })\n .strict();\n\nexport const bookingRemindersSchema = z\n .object({\n use_calendar_defaults: z.boolean().default(true),\n overrides: z.array(bookingReminderSchema).max(MAX_REMINDER_OVERRIDES).default([]),\n })\n .strict();\n\nexport const bookingLocationSchema = z.union([\n z.object({ kind: z.literal(\"google_meet\") }).strict(),\n z.object({ kind: z.literal(\"physical\"), address: z.string().max(1024) }).strict(),\n z.object({ kind: z.literal(\"phone\"), phone: z.string().max(64) }).strict(),\n z.object({ kind: z.literal(\"custom\"), text: z.string().max(1024) }).strict(),\n]);\n\n/**\n * The nullable form, flattened.\n *\n * `bookingLocationSchema.nullable()` would nest one `anyOf` inside another,\n * while Pydantic's `BookingLocation | None` flattens to a single list — the\n * drift snapshot compares structure, so both sides emit the flat shape.\n */\nexport const bookingLocationNullableSchema = z.union([...bookingLocationSchema.options, z.null()]);\n\nexport const bookingCustomerSchema = z\n .object({\n // Required: Google needs an address to send the invite that confirms the\n // booking, and that invite is the whole confirmation mechanism.\n email: z.string().max(320).email(),\n name: z.string().max(200).nullable().optional(),\n phone: z.string().max(64).nullable().optional(),\n sms_consent: z.boolean().default(false),\n })\n .strict();\n\nconst jsonScalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);\n\n/**\n * Named attribution keys plus a flat escape hatch.\n *\n * `custom` is flat and scalar-only on purpose: it mirrors trivially in Pydantic,\n * stays JSONB-queryable, and bounds what a caller can push. Only `custom` is\n * ever mirrored to Google's `extendedProperties`; the named keys stay in our\n * database.\n */\nexport const bookingMetadataSchema = z\n .object({\n schema_version: z.number().int().default(CALENDAR_METADATA_SCHEMA_VERSION),\n source: z.enum(BOOKING_SOURCES).nullable().optional(),\n service: z.string().max(64).nullable().optional(),\n visitor_id: z.string().max(64).nullable().optional(),\n session_id: z.string().max(64).nullable().optional(),\n gclid: z.string().max(256).nullable().optional(),\n fbclid: z.string().max(256).nullable().optional(),\n utm_source: z.string().max(128).nullable().optional(),\n utm_medium: z.string().max(128).nullable().optional(),\n utm_campaign: z.string().max(128).nullable().optional(),\n utm_term: z.string().max(128).nullable().optional(),\n utm_content: z.string().max(128).nullable().optional(),\n landing_page: z.string().max(2048).nullable().optional(),\n referrer: z.string().max(2048).nullable().optional(),\n notes: z.string().max(2000).nullable().optional(),\n custom: z.record(jsonScalar).nullable().optional(),\n })\n .strict();\n\nfunction refineEndXor(\n value: { start_at: string; end_at?: string | null; duration_minutes?: number | null },\n ctx: z.RefinementCtx,\n): void {\n const hasEnd = value.end_at !== undefined && value.end_at !== null;\n const hasDuration = value.duration_minutes !== undefined && value.duration_minutes !== null;\n if (hasEnd === hasDuration) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: \"provide exactly one of end_at or duration_minutes\",\n path: [\"end_at\"],\n });\n return;\n }\n if (hasEnd && new Date(value.end_at as string) <= new Date(value.start_at)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: \"end_at must be after start_at\",\n path: [\"end_at\"],\n });\n }\n}\n\nexport const bookingCreateSchema = z\n .object({\n calendar_key: z.string().min(1).max(64),\n // Free-form: appointment types are the client site's own vocabulary, not\n // configuration the dashboard holds.\n appointment_type: z.string().max(64).nullable().optional(),\n start_at: isoInstant,\n end_at: isoInstant.nullable().optional(),\n duration_minutes: z.number().int().positive().max(MAX_BOOKING_MINUTES).nullable().optional(),\n /** The booker's zone — distinct from the business's and the calendar's. */\n time_zone: ianaTz,\n title: z.string().max(300).nullable().optional(),\n description: z.string().max(8000).nullable().optional(),\n location: bookingLocationNullableSchema.optional(),\n customer: bookingCustomerSchema,\n attendees: z.array(bookingAttendeeSchema).max(MAX_ATTENDEES).default([]),\n send_invites: z.boolean().default(true),\n reminders: bookingRemindersSchema.nullable().optional(),\n metadata: bookingMetadataSchema.nullable().optional(),\n idempotency_key: z.string().max(128).nullable().optional(),\n buffer_before_minutes: z.number().int().min(0).max(MAX_BOOKING_MINUTES).default(0),\n buffer_after_minutes: z.number().int().min(0).max(MAX_BOOKING_MINUTES).default(0),\n })\n .strict()\n .superRefine(refineEndXor);\n\nexport const bookingRescheduleSchema = z\n .object({\n start_at: isoInstant,\n end_at: isoInstant.nullable().optional(),\n duration_minutes: z.number().int().positive().max(MAX_BOOKING_MINUTES).nullable().optional(),\n time_zone: ianaTz,\n notify_attendees: z.boolean().default(true),\n reason: z.string().max(500).nullable().optional(),\n })\n .strict()\n .superRefine(refineEndXor);\n\nexport const bookingCancelSchema = z\n .object({\n reason: z.string().max(500).nullable().optional(),\n notify_attendees: z.boolean().default(true),\n })\n .strict();\n\n// `z.input` so Zod defaults stay optional for callers.\nexport type BookingCreateInput = z.input<typeof bookingCreateSchema>;\nexport type BookingRescheduleInput = z.input<typeof bookingRescheduleSchema>;\nexport type BookingCancelInput = z.input<typeof bookingCancelSchema>;\nexport type BookingAttendee = z.input<typeof bookingAttendeeSchema>;\nexport type BookingReminders = z.input<typeof bookingRemindersSchema>;\nexport type BookingLocation = z.input<typeof bookingLocationSchema>;\nexport type BookingCustomer = z.input<typeof bookingCustomerSchema>;\nexport type BookingMetadata<\n TCustom extends Record<string, JsonScalar> = Record<string, JsonScalar>,\n> = Omit<z.input<typeof bookingMetadataSchema>, \"custom\"> & { custom?: TCustom | null };\n\n// ---------------------------------------------------------------------------\n// Read shapes — server responses, trusted and therefore not re-validated here.\n// ---------------------------------------------------------------------------\n\nexport interface CalendarSummary {\n calendar_key: string;\n label: string;\n time_zone: string;\n status: CalendarSyncStatus;\n /** Secret-key only. A public key gets `null`: whose Google account is behind\n * a calendar is a third party's identity, not the client site's business. */\n google_account_email?: string | null;\n granted_scopes?: string[] | null;\n connected_at?: string | null;\n}\n\nexport interface BusyInterval {\n start_at: string;\n end_at: string;\n source: \"google\" | \"booking\";\n}\n\nexport interface BusyResponse {\n calendar_key: string;\n time_zone: string;\n window: { since: string; until: string };\n busy: BusyInterval[];\n generated_at: string;\n}\n\nexport interface Booking {\n id: string;\n calendar_key: string;\n status: BookingStatus;\n google_sync_state: GoogleSyncState;\n start_at: string;\n end_at: string;\n time_zone: string;\n appointment_type: string | null;\n title: string;\n customer: BookingCustomer;\n google_html_link: string | null;\n meet_url: string | null;\n /** Returned only on create — the capability credential for manage links. */\n manage_token?: string | null;\n}\n","import type { BusyInterval } from \"./schema\";\nimport { instantFromWallClock, offsetMinutesAt } from \"./time\";\n\n/**\n * Slot computation lives in the client site, not in our database.\n *\n * Opening hours, slot length, notice and buffers differ per client and per use\n * case; modelling them as dashboard config would mean every new client needs\n * data entry before their site works. The API answers only \"when is this person\n * busy\" — this pure helper turns that plus the site's own hours into offerable\n * slots.\n */\n\nexport interface OpeningInterval {\n /** Local wall-clock `HH:MM` in the calendar's zone. */\n start: string;\n end: string;\n}\n\nexport interface OpeningHours {\n /** 0 = Sunday … 6 = Saturday. A missing weekday is closed. */\n weekly: Partial<Record<number, OpeningInterval[]>>;\n /** `YYYY-MM-DD` → intervals, replacing that day's weekly rule entirely. */\n overrides?: Record<string, OpeningInterval[]>;\n /** `YYYY-MM-DD` dates that are closed regardless of the weekly rule. */\n closedDates?: string[];\n}\n\nexport interface ComputeSlotsOptions {\n busy: BusyInterval[];\n openingHours: OpeningHours;\n timeZone: string;\n durationMinutes: number;\n granularityMinutes?: number;\n minNoticeMinutes?: number;\n bufferBeforeMinutes?: number;\n bufferAfterMinutes?: number;\n windowDays?: number;\n now?: Date;\n}\n\nexport interface Slot {\n start_at: string;\n end_at: string;\n}\n\nexport interface ComputeSlotsResult {\n slots: Slot[];\n nextAvailableAt: string | null;\n}\n\nfunction parseHhMm(value: string): [number, number] {\n const [hour, minute] = value.split(\":\").map(Number);\n return [hour || 0, minute || 0];\n}\n\nfunction dateKeyOf(date: Date, timeZone: string): string {\n const fmt = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n });\n return fmt.format(date);\n}\n\n/** Weekday of a `YYYY-MM-DD` local date, read at noon UTC so no offset can\n * shift it across a day boundary. */\nfunction weekdayFromDateKey(dateKey: string): number {\n const [year, month, day] = dateKey.split(\"-\").map(Number);\n return new Date(Date.UTC(year, month - 1, day, 12)).getUTCDay();\n}\n\n/** Midday of the local date containing `at` — a stable anchor to step from,\n * immune to a midnight DST jump. */\nfunction startOfLocalDay(at: Date, timeZone: string): Date {\n const [year, month, day] = dateKeyOf(at, timeZone).split(\"-\").map(Number);\n return instantFromWallClock(timeZone, year, month, day, 12, 0);\n}\n\nfunction overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {\n return aStart < bEnd && aEnd > bStart;\n}\n\nexport function computeSlots(options: ComputeSlotsOptions): ComputeSlotsResult {\n const {\n busy,\n openingHours,\n timeZone,\n durationMinutes,\n granularityMinutes = 15,\n minNoticeMinutes = 0,\n bufferBeforeMinutes = 0,\n bufferAfterMinutes = 0,\n windowDays = 30,\n now = new Date(),\n } = options;\n\n const earliest = now.getTime() + minNoticeMinutes * 60_000;\n const busyRanges = busy.map((interval) => [\n new Date(interval.start_at).getTime(),\n new Date(interval.end_at).getTime(),\n ]);\n\n const slots: Slot[] = [];\n // Advance by CALENDAR days, not by 86_400_000 ms. A DST day is 23 or 25 hours\n // long, so fixed-millisecond stepping skips a local date entirely at spring\n // forward and visits one twice at fall back — in America/Toronto, adding 24h\n // from 2026-03-08T04:30Z jumps straight from the 7th to the 9th.\n let cursor = startOfLocalDay(now, timeZone);\n for (let dayOffset = 0; dayOffset < windowDays; dayOffset += 1) {\n const dateKey = dateKeyOf(cursor, timeZone);\n const [year, month, day] = dateKey.split(\"-\").map(Number);\n // `Date.UTC` normalises day 32 into the next month, so this needs no\n // month-length handling.\n cursor = instantFromWallClock(timeZone, year, month, day + 1, 12, 0);\n\n if (openingHours.closedDates?.includes(dateKey)) continue;\n\n const intervals =\n openingHours.overrides?.[dateKey] ?? openingHours.weekly[weekdayFromDateKey(dateKey)] ?? [];\n for (const interval of intervals) {\n const [openHour, openMinute] = parseHhMm(interval.start);\n const [closeHour, closeMinute] = parseHhMm(interval.end);\n const opensAt = instantFromWallClock(timeZone, year, month, day, openHour, openMinute);\n const closesAt = instantFromWallClock(timeZone, year, month, day, closeHour, closeMinute);\n\n for (\n let start = opensAt.getTime();\n start + durationMinutes * 60_000 <= closesAt.getTime();\n start += granularityMinutes * 60_000\n ) {\n const end = start + durationMinutes * 60_000;\n if (start < earliest) continue;\n\n // A slot is unavailable if its BUFFERED span touches anything busy —\n // the buffers are what keep a gap between appointments.\n const blockedStart = start - bufferBeforeMinutes * 60_000;\n const blockedEnd = end + bufferAfterMinutes * 60_000;\n const taken = busyRanges.some(([busyStart, busyEnd]) =>\n overlaps(blockedStart, blockedEnd, busyStart, busyEnd),\n );\n if (taken) continue;\n\n slots.push({\n start_at: new Date(start).toISOString(),\n end_at: new Date(end).toISOString(),\n });\n }\n }\n }\n\n slots.sort((a, b) => a.start_at.localeCompare(b.start_at));\n return { slots, nextAvailableAt: slots[0]?.start_at ?? null };\n}\n\n/** Group slots by local date, the shape every date-picker UI wants. */\nexport function groupSlotsByDay(\n slots: Slot[],\n timeZone: string,\n): { date: string; slots: Slot[] }[] {\n const byDay = new Map<string, Slot[]>();\n for (const slot of slots) {\n const key = dateKeyOf(new Date(slot.start_at), timeZone);\n const bucket = byDay.get(key);\n if (bucket) bucket.push(slot);\n else byDay.set(key, [slot]);\n }\n return [...byDay.entries()]\n .map(([date, daySlots]) => ({ date, slots: daySlots }))\n .sort((a, b) => a.date.localeCompare(b.date));\n}\n\nexport { offsetMinutesAt };\n"],"mappings":"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","names":["import_zod","durationMinutes"]}
@@ -0,0 +1,457 @@
1
+ // ../tracking-core/src/resources/http/errors.ts
2
+ var AranovaApiError = class extends Error {
3
+ constructor(message, options) {
4
+ super(message);
5
+ this.name = "AranovaApiError";
6
+ this.status = options.status;
7
+ this.code = options.code;
8
+ this.requestId = options.requestId;
9
+ }
10
+ };
11
+
12
+ // ../tracking-core/src/phone.ts
13
+ import { AsYouType, parsePhoneNumberFromString } from "libphonenumber-js";
14
+
15
+ // ../tracking-core/src/events/page-view.ts
16
+ import { z } from "zod";
17
+ var pageViewMetadataSchema = z.object({
18
+ page: z.object({
19
+ title: z.string().nullable(),
20
+ path: z.string(),
21
+ search: z.string(),
22
+ hash: z.string()
23
+ }).strict(),
24
+ referrer: z.string().nullable(),
25
+ // `.nullable().optional()` — absent (undefined) OR explicit null OR a
26
+ // real viewport object. Mirrors Pydantic's `_Viewport | None = None`
27
+ // on the backend side so the drift test stays clean.
28
+ viewport: z.object({
29
+ w: z.number(),
30
+ h: z.number()
31
+ }).strict().nullable().optional()
32
+ }).strict();
33
+ var pageViewConfigSchema = z.object({}).strict();
34
+
35
+ // ../tracking-core/src/session.ts
36
+ var SESSION_IDLE_MS = 30 * 60 * 1e3;
37
+
38
+ // ../tracking-core/src/ingest.ts
39
+ var API_KEY_HEADER = "X-Aranova-Api-Key";
40
+ var SDK_VERSION_HEADER = "X-Aranova-Sdk-Version";
41
+ var SDK_PACKAGE_HEADER = "X-Aranova-Sdk-Package";
42
+ var SDK_SURFACE_HEADER = "X-Aranova-Sdk-Surface";
43
+ var SDK_ENVIRONMENT_HEADER = "X-Aranova-Sdk-Environment";
44
+
45
+ // ../tracking-core/src/resources/http/request.ts
46
+ function identityHeaders(config) {
47
+ const headers = { [API_KEY_HEADER]: config.apiKey };
48
+ if (config.sdkVersion) headers[SDK_VERSION_HEADER] = config.sdkVersion;
49
+ if (config.packageName) headers[SDK_PACKAGE_HEADER] = config.packageName;
50
+ if (config.surface) headers[SDK_SURFACE_HEADER] = config.surface;
51
+ if (config.environment) headers[SDK_ENVIRONMENT_HEADER] = config.environment;
52
+ return headers;
53
+ }
54
+ function joinUrl(endpoint, path) {
55
+ return `${endpoint.replace(/\/$/, "")}${path}`;
56
+ }
57
+ async function apiRequest(config, method, path, body, extraHeaders) {
58
+ const headers = { ...identityHeaders(config), ...extraHeaders };
59
+ if (body !== void 0) headers["Content-Type"] = "application/json";
60
+ const response = await fetch(joinUrl(config.endpoint, path), {
61
+ method,
62
+ headers,
63
+ body: body === void 0 ? void 0 : JSON.stringify(body)
64
+ });
65
+ if (!response.ok) {
66
+ let detail;
67
+ let code;
68
+ try {
69
+ const parsed = await response.json();
70
+ if (parsed && typeof parsed === "object") {
71
+ const record = parsed;
72
+ if (typeof record.detail === "string") detail = record.detail;
73
+ if (typeof record.code === "string") code = record.code;
74
+ }
75
+ } catch {
76
+ }
77
+ throw new AranovaApiError(detail ?? response.statusText ?? "Request failed", {
78
+ status: response.status,
79
+ code,
80
+ requestId: response.headers.get("x-request-id") ?? void 0
81
+ });
82
+ }
83
+ if (response.status === 204) return void 0;
84
+ return await response.json();
85
+ }
86
+
87
+ // ../tracking-core/src/resources/calendar/transport.ts
88
+ async function calendarRequest(config, method, path, body) {
89
+ return apiRequest(config, method, `/calendar${path}`, body);
90
+ }
91
+
92
+ // ../tracking-core/src/resources/intl/date.ts
93
+ function formatDateInTz(iso, timeZone, opts, locale) {
94
+ const date = new Date(iso);
95
+ if (Number.isNaN(date.getTime())) return iso;
96
+ return new Intl.DateTimeFormat(locale, {
97
+ year: "numeric",
98
+ month: "short",
99
+ day: "2-digit",
100
+ hour: "2-digit",
101
+ minute: "2-digit",
102
+ ...opts,
103
+ timeZone
104
+ }).format(date);
105
+ }
106
+
107
+ // ../tracking-core/src/resources/calendar/time.ts
108
+ function resolveBrowserTimeZone() {
109
+ try {
110
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
111
+ } catch {
112
+ return "UTC";
113
+ }
114
+ }
115
+ function partsInTz(date, timeZone) {
116
+ const formatter = new Intl.DateTimeFormat("en-US", {
117
+ timeZone,
118
+ hour12: false,
119
+ year: "numeric",
120
+ month: "2-digit",
121
+ day: "2-digit",
122
+ hour: "2-digit",
123
+ minute: "2-digit",
124
+ second: "2-digit"
125
+ });
126
+ const out = {};
127
+ for (const part of formatter.formatToParts(date)) {
128
+ if (part.type !== "literal") out[part.type] = Number(part.value);
129
+ }
130
+ if (out.hour === 24) out.hour = 0;
131
+ return out;
132
+ }
133
+ function offsetMinutesAt(date, timeZone) {
134
+ const p = partsInTz(date, timeZone);
135
+ const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
136
+ return (asUtc - Math.floor(date.getTime() / 1e3) * 1e3) / 6e4;
137
+ }
138
+ function instantFromWallClock(timeZone, year, month, day, hour = 0, minute = 0) {
139
+ const naive = Date.UTC(year, month - 1, day, hour, minute);
140
+ const firstGuess = new Date(naive - offsetMinutesAt(new Date(naive), timeZone) * 6e4);
141
+ const corrected = naive - offsetMinutesAt(firstGuess, timeZone) * 6e4;
142
+ return new Date(corrected);
143
+ }
144
+ function localDateKey(iso, timeZone) {
145
+ const p = partsInTz(new Date(iso), timeZone);
146
+ const pad = (n) => String(n).padStart(2, "0");
147
+ return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
148
+ }
149
+ function startOfDayInTz(dateKey, timeZone) {
150
+ const [year, month, day] = dateKey.split("-").map(Number);
151
+ return instantFromWallClock(timeZone, year, month, day, 0, 0);
152
+ }
153
+ function endOfDayInTz(dateKey, timeZone) {
154
+ const start = startOfDayInTz(dateKey, timeZone);
155
+ const p = partsInTz(start, timeZone);
156
+ return instantFromWallClock(timeZone, p.year, p.month, p.day + 1, 0, 0);
157
+ }
158
+ function durationMinutes(startIso, endIso) {
159
+ return Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 6e4);
160
+ }
161
+ function formatSlotRange(startIso, endIso, timeZone, locale) {
162
+ const opts = { hour: "2-digit", minute: "2-digit", timeZone };
163
+ const fmt = new Intl.DateTimeFormat(locale, opts);
164
+ return `${fmt.format(new Date(startIso))} \u2013 ${fmt.format(new Date(endIso))}`;
165
+ }
166
+
167
+ // ../tracking-core/src/resources/calendar/read-client.ts
168
+ function withBusiness(config, path) {
169
+ if (!config.businessId) return path;
170
+ const separator = path.includes("?") ? "&" : "?";
171
+ return `${path}${separator}business_id=${encodeURIComponent(config.businessId)}`;
172
+ }
173
+ function createCalendarReadClient(config) {
174
+ return {
175
+ async listCalendars() {
176
+ return calendarRequest(config, "GET", withBusiness(config, "/calendars"));
177
+ },
178
+ async busy(calendarKey, query) {
179
+ const params = new URLSearchParams({
180
+ since: query.since,
181
+ until: query.until
182
+ });
183
+ const timeZone = query.timeZone ?? config.defaultTimeZone ?? resolveBrowserTimeZone();
184
+ params.set("time_zone", timeZone);
185
+ return calendarRequest(
186
+ config,
187
+ "GET",
188
+ withBusiness(config, `/${encodeURIComponent(calendarKey)}/busy?${params.toString()}`)
189
+ );
190
+ }
191
+ };
192
+ }
193
+ function isCalendarError(error, code) {
194
+ if (!(error instanceof AranovaApiError)) return false;
195
+ return code === void 0 || error.code === code;
196
+ }
197
+
198
+ // ../tracking-core/src/resources/calendar/schema.ts
199
+ import { z as z2 } from "zod";
200
+ var CALENDAR_METADATA_SCHEMA_VERSION = 1;
201
+ var MAX_ATTENDEES = 20;
202
+ var MAX_REMINDER_OVERRIDES = 5;
203
+ var MAX_REMINDER_MINUTES = 40320;
204
+ var MAX_BOOKING_MINUTES = 1440;
205
+ var BOOKING_SOURCES = ["website", "phone", "walk_in", "import", "operator"];
206
+ var BOOKING_STATUSES = ["pending", "confirmed", "cancelled", "no_show"];
207
+ var GOOGLE_SYNC_STATES = ["queued", "synced", "uncertain", "failed", "drifted"];
208
+ var CALENDAR_SYNC_STATUSES = [
209
+ "pending_invite",
210
+ "awaiting_calendar_selection",
211
+ "connected",
212
+ "needs_reconnect",
213
+ "revoked",
214
+ "disabled"
215
+ ];
216
+ var CALENDAR_ERROR_CODES = [
217
+ "slot_unavailable",
218
+ "calendar_not_connected",
219
+ "calendar_disconnected",
220
+ "booking_not_found",
221
+ "outside_booking_window",
222
+ "rate_limited",
223
+ "google_error"
224
+ ];
225
+ var isoInstant = z2.string().datetime({ offset: true });
226
+ function isKnownTimeZone(timeZone) {
227
+ try {
228
+ new Intl.DateTimeFormat(void 0, { timeZone });
229
+ return true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+ var ianaTz = z2.string().min(1).max(64).regex(/^[A-Za-z0-9_+\-/]+$/).refine(isKnownTimeZone, { message: "unknown IANA time zone" });
235
+ var bookingAttendeeSchema = z2.object({
236
+ email: z2.string().max(320).email(),
237
+ display_name: z2.string().max(200).nullable().optional(),
238
+ optional: z2.boolean().default(false)
239
+ }).strict();
240
+ var bookingReminderSchema = z2.object({
241
+ method: z2.enum(["email", "popup"]),
242
+ minutes_before: z2.number().int().min(0).max(MAX_REMINDER_MINUTES)
243
+ }).strict();
244
+ var bookingRemindersSchema = z2.object({
245
+ use_calendar_defaults: z2.boolean().default(true),
246
+ overrides: z2.array(bookingReminderSchema).max(MAX_REMINDER_OVERRIDES).default([])
247
+ }).strict();
248
+ var bookingLocationSchema = z2.union([
249
+ z2.object({ kind: z2.literal("google_meet") }).strict(),
250
+ z2.object({ kind: z2.literal("physical"), address: z2.string().max(1024) }).strict(),
251
+ z2.object({ kind: z2.literal("phone"), phone: z2.string().max(64) }).strict(),
252
+ z2.object({ kind: z2.literal("custom"), text: z2.string().max(1024) }).strict()
253
+ ]);
254
+ var bookingLocationNullableSchema = z2.union([...bookingLocationSchema.options, z2.null()]);
255
+ var bookingCustomerSchema = z2.object({
256
+ // Required: Google needs an address to send the invite that confirms the
257
+ // booking, and that invite is the whole confirmation mechanism.
258
+ email: z2.string().max(320).email(),
259
+ name: z2.string().max(200).nullable().optional(),
260
+ phone: z2.string().max(64).nullable().optional(),
261
+ sms_consent: z2.boolean().default(false)
262
+ }).strict();
263
+ var jsonScalar = z2.union([z2.string(), z2.number(), z2.boolean(), z2.null()]);
264
+ var bookingMetadataSchema = z2.object({
265
+ schema_version: z2.number().int().default(CALENDAR_METADATA_SCHEMA_VERSION),
266
+ source: z2.enum(BOOKING_SOURCES).nullable().optional(),
267
+ service: z2.string().max(64).nullable().optional(),
268
+ visitor_id: z2.string().max(64).nullable().optional(),
269
+ session_id: z2.string().max(64).nullable().optional(),
270
+ gclid: z2.string().max(256).nullable().optional(),
271
+ fbclid: z2.string().max(256).nullable().optional(),
272
+ utm_source: z2.string().max(128).nullable().optional(),
273
+ utm_medium: z2.string().max(128).nullable().optional(),
274
+ utm_campaign: z2.string().max(128).nullable().optional(),
275
+ utm_term: z2.string().max(128).nullable().optional(),
276
+ utm_content: z2.string().max(128).nullable().optional(),
277
+ landing_page: z2.string().max(2048).nullable().optional(),
278
+ referrer: z2.string().max(2048).nullable().optional(),
279
+ notes: z2.string().max(2e3).nullable().optional(),
280
+ custom: z2.record(jsonScalar).nullable().optional()
281
+ }).strict();
282
+ function refineEndXor(value, ctx) {
283
+ const hasEnd = value.end_at !== void 0 && value.end_at !== null;
284
+ const hasDuration = value.duration_minutes !== void 0 && value.duration_minutes !== null;
285
+ if (hasEnd === hasDuration) {
286
+ ctx.addIssue({
287
+ code: z2.ZodIssueCode.custom,
288
+ message: "provide exactly one of end_at or duration_minutes",
289
+ path: ["end_at"]
290
+ });
291
+ return;
292
+ }
293
+ if (hasEnd && new Date(value.end_at) <= new Date(value.start_at)) {
294
+ ctx.addIssue({
295
+ code: z2.ZodIssueCode.custom,
296
+ message: "end_at must be after start_at",
297
+ path: ["end_at"]
298
+ });
299
+ }
300
+ }
301
+ var bookingCreateSchema = z2.object({
302
+ calendar_key: z2.string().min(1).max(64),
303
+ // Free-form: appointment types are the client site's own vocabulary, not
304
+ // configuration the dashboard holds.
305
+ appointment_type: z2.string().max(64).nullable().optional(),
306
+ start_at: isoInstant,
307
+ end_at: isoInstant.nullable().optional(),
308
+ duration_minutes: z2.number().int().positive().max(MAX_BOOKING_MINUTES).nullable().optional(),
309
+ /** The booker's zone — distinct from the business's and the calendar's. */
310
+ time_zone: ianaTz,
311
+ title: z2.string().max(300).nullable().optional(),
312
+ description: z2.string().max(8e3).nullable().optional(),
313
+ location: bookingLocationNullableSchema.optional(),
314
+ customer: bookingCustomerSchema,
315
+ attendees: z2.array(bookingAttendeeSchema).max(MAX_ATTENDEES).default([]),
316
+ send_invites: z2.boolean().default(true),
317
+ reminders: bookingRemindersSchema.nullable().optional(),
318
+ metadata: bookingMetadataSchema.nullable().optional(),
319
+ idempotency_key: z2.string().max(128).nullable().optional(),
320
+ buffer_before_minutes: z2.number().int().min(0).max(MAX_BOOKING_MINUTES).default(0),
321
+ buffer_after_minutes: z2.number().int().min(0).max(MAX_BOOKING_MINUTES).default(0)
322
+ }).strict().superRefine(refineEndXor);
323
+ var bookingRescheduleSchema = z2.object({
324
+ start_at: isoInstant,
325
+ end_at: isoInstant.nullable().optional(),
326
+ duration_minutes: z2.number().int().positive().max(MAX_BOOKING_MINUTES).nullable().optional(),
327
+ time_zone: ianaTz,
328
+ notify_attendees: z2.boolean().default(true),
329
+ reason: z2.string().max(500).nullable().optional()
330
+ }).strict().superRefine(refineEndXor);
331
+ var bookingCancelSchema = z2.object({
332
+ reason: z2.string().max(500).nullable().optional(),
333
+ notify_attendees: z2.boolean().default(true)
334
+ }).strict();
335
+
336
+ // ../tracking-core/src/resources/calendar/slots.ts
337
+ function parseHhMm(value) {
338
+ const [hour, minute] = value.split(":").map(Number);
339
+ return [hour || 0, minute || 0];
340
+ }
341
+ function dateKeyOf(date, timeZone) {
342
+ const fmt = new Intl.DateTimeFormat("en-CA", {
343
+ timeZone,
344
+ year: "numeric",
345
+ month: "2-digit",
346
+ day: "2-digit"
347
+ });
348
+ return fmt.format(date);
349
+ }
350
+ function weekdayFromDateKey(dateKey) {
351
+ const [year, month, day] = dateKey.split("-").map(Number);
352
+ return new Date(Date.UTC(year, month - 1, day, 12)).getUTCDay();
353
+ }
354
+ function startOfLocalDay(at, timeZone) {
355
+ const [year, month, day] = dateKeyOf(at, timeZone).split("-").map(Number);
356
+ return instantFromWallClock(timeZone, year, month, day, 12, 0);
357
+ }
358
+ function overlaps(aStart, aEnd, bStart, bEnd) {
359
+ return aStart < bEnd && aEnd > bStart;
360
+ }
361
+ function computeSlots(options) {
362
+ const {
363
+ busy,
364
+ openingHours,
365
+ timeZone,
366
+ durationMinutes: durationMinutes2,
367
+ granularityMinutes = 15,
368
+ minNoticeMinutes = 0,
369
+ bufferBeforeMinutes = 0,
370
+ bufferAfterMinutes = 0,
371
+ windowDays = 30,
372
+ now = /* @__PURE__ */ new Date()
373
+ } = options;
374
+ const earliest = now.getTime() + minNoticeMinutes * 6e4;
375
+ const busyRanges = busy.map((interval) => [
376
+ new Date(interval.start_at).getTime(),
377
+ new Date(interval.end_at).getTime()
378
+ ]);
379
+ const slots = [];
380
+ let cursor = startOfLocalDay(now, timeZone);
381
+ for (let dayOffset = 0; dayOffset < windowDays; dayOffset += 1) {
382
+ const dateKey = dateKeyOf(cursor, timeZone);
383
+ const [year, month, day] = dateKey.split("-").map(Number);
384
+ cursor = instantFromWallClock(timeZone, year, month, day + 1, 12, 0);
385
+ if (openingHours.closedDates?.includes(dateKey)) continue;
386
+ const intervals = openingHours.overrides?.[dateKey] ?? openingHours.weekly[weekdayFromDateKey(dateKey)] ?? [];
387
+ for (const interval of intervals) {
388
+ const [openHour, openMinute] = parseHhMm(interval.start);
389
+ const [closeHour, closeMinute] = parseHhMm(interval.end);
390
+ const opensAt = instantFromWallClock(timeZone, year, month, day, openHour, openMinute);
391
+ const closesAt = instantFromWallClock(timeZone, year, month, day, closeHour, closeMinute);
392
+ for (let start = opensAt.getTime(); start + durationMinutes2 * 6e4 <= closesAt.getTime(); start += granularityMinutes * 6e4) {
393
+ const end = start + durationMinutes2 * 6e4;
394
+ if (start < earliest) continue;
395
+ const blockedStart = start - bufferBeforeMinutes * 6e4;
396
+ const blockedEnd = end + bufferAfterMinutes * 6e4;
397
+ const taken = busyRanges.some(
398
+ ([busyStart, busyEnd]) => overlaps(blockedStart, blockedEnd, busyStart, busyEnd)
399
+ );
400
+ if (taken) continue;
401
+ slots.push({
402
+ start_at: new Date(start).toISOString(),
403
+ end_at: new Date(end).toISOString()
404
+ });
405
+ }
406
+ }
407
+ }
408
+ slots.sort((a, b) => a.start_at.localeCompare(b.start_at));
409
+ return { slots, nextAvailableAt: slots[0]?.start_at ?? null };
410
+ }
411
+ function groupSlotsByDay(slots, timeZone) {
412
+ const byDay = /* @__PURE__ */ new Map();
413
+ for (const slot of slots) {
414
+ const key = dateKeyOf(new Date(slot.start_at), timeZone);
415
+ const bucket = byDay.get(key);
416
+ if (bucket) bucket.push(slot);
417
+ else byDay.set(key, [slot]);
418
+ }
419
+ return [...byDay.entries()].map(([date, daySlots]) => ({ date, slots: daySlots })).sort((a, b) => a.date.localeCompare(b.date));
420
+ }
421
+ export {
422
+ AranovaApiError,
423
+ BOOKING_SOURCES,
424
+ BOOKING_STATUSES,
425
+ CALENDAR_ERROR_CODES,
426
+ CALENDAR_METADATA_SCHEMA_VERSION,
427
+ CALENDAR_SYNC_STATUSES,
428
+ GOOGLE_SYNC_STATES,
429
+ MAX_ATTENDEES,
430
+ MAX_BOOKING_MINUTES,
431
+ MAX_REMINDER_MINUTES,
432
+ MAX_REMINDER_OVERRIDES,
433
+ bookingAttendeeSchema,
434
+ bookingCancelSchema,
435
+ bookingCreateSchema,
436
+ bookingCustomerSchema,
437
+ bookingLocationNullableSchema,
438
+ bookingLocationSchema,
439
+ bookingMetadataSchema,
440
+ bookingReminderSchema,
441
+ bookingRemindersSchema,
442
+ bookingRescheduleSchema,
443
+ computeSlots,
444
+ createCalendarReadClient,
445
+ durationMinutes,
446
+ endOfDayInTz,
447
+ formatDateInTz,
448
+ formatSlotRange,
449
+ groupSlotsByDay,
450
+ instantFromWallClock,
451
+ isCalendarError,
452
+ localDateKey,
453
+ offsetMinutesAt,
454
+ resolveBrowserTimeZone,
455
+ startOfDayInTz
456
+ };
457
+ //# sourceMappingURL=calendar.mjs.map