@appfunnel-dev/sdk 0.17.0 → 2.0.0-canary.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/index.d.ts CHANGED
@@ -1,386 +1,1751 @@
1
- import { A as AppFunnelConfig, P as PageDefinition, V as VariableValue, U as UserState, L as LocaleState, T as TranslationState, N as NavigationState, a as ProductsState, b as TrackingState, c as PaymentState, D as DeviceInfo, d as PageData, F as FunnelState } from './internal-BBWDTB-l.js';
2
- export { C as ConditionConfig, e as ConditionGroupConfig, f as FunnelProvider, g as FunnelProviderProps, h as FunnelSettings, i as PageConfig, j as PageState, k as PageType, l as ProductConfig, m as ProductsConfig, n as Progress, R as RouteConfig, o as RuntimeProduct, p as VariableConfig, q as VariableType } from './internal-BBWDTB-l.js';
3
- import * as react from 'react';
4
- import { Appearance } from '@stripe/stripe-js';
5
- import * as react_jsx_runtime from 'react/jsx-runtime';
6
- export { toast } from 'sonner';
1
+ import { ReactNode, ComponentType, ButtonHTMLAttributes } from 'react';
2
+ import { ToasterProps } from 'sonner';
3
+ export { ToasterProps, toast } from 'sonner';
4
+
5
+ type VariableType = 'string' | 'number' | 'boolean' | 'stringArray';
6
+ type VariableValue = string | number | boolean | string[] | null | undefined;
7
+ interface VariableConfig {
8
+ type: VariableType;
9
+ default?: VariableValue;
10
+ persist?: boolean;
11
+ }
12
+
13
+ declare class VariableStore {
14
+ private state;
15
+ private listeners;
16
+ /** Tracks which keys changed in the last mutation — used by scoped notify */
17
+ private changedKeys;
18
+ constructor(initial: Record<string, VariableValue>);
19
+ getState(): Record<string, VariableValue>;
20
+ get(key: string): VariableValue;
21
+ set(key: string, value: VariableValue): void;
22
+ setState(updater: (prev: Record<string, VariableValue>) => Record<string, VariableValue>): void;
23
+ /** Batch set multiple variables at once */
24
+ setMany(updates: Record<string, VariableValue>): void;
25
+ /**
26
+ * Subscribe to store changes.
27
+ *
28
+ * @param listener - callback fired when relevant keys change
29
+ * @param scope - optional scope to limit notifications:
30
+ * - `{ keys: ['data.x', 'user.email'] }` — only fire when these exact keys change
31
+ * - `{ prefix: 'answers.' }` — only fire when any key starting with this prefix changes
32
+ * - omit or `{}` — fire on every change (global listener)
33
+ */
34
+ subscribe(listener: () => void, scope?: {
35
+ keys?: string[];
36
+ prefix?: string;
37
+ }): () => void;
38
+ private notify;
39
+ }
7
40
 
8
41
  /**
9
- * Type helper for appfunnel.config.ts.
10
- * Returns the config object unchanged exists purely for TypeScript autocomplete.
11
- */
12
- declare function defineConfig(config: AppFunnelConfig): AppFunnelConfig;
13
- /**
14
- * Define page metadata co-located with the page component.
15
- * Export this from your page file alongside the default React component.
16
- *
17
- * @example
18
- * ```tsx
19
- * // pages/quiz.tsx
20
- * import { definePage, useVariable, useNavigation } from '@appfunnel-dev/sdk'
42
+ * v2 store seeding — builds a {@link VariableStore} holding only the **writable**
43
+ * namespaces (`user` / `responses` / `data`), each persisted to the backend.
21
44
  *
22
- * export const page = definePage({
23
- * name: 'Quiz',
24
- * type: 'default',
25
- * routes: [
26
- * { to: 'checkout', when: { variable: 'plan', equals: 'premium' } },
27
- * { to: 'success' }, // fallback
28
- * ],
29
- * })
45
+ * Seeds the {@link VariableStore} with v2 prefixes (no legacy `answers.`/`query.`
46
+ * aliasing). Read-only computed values do NOT live
47
+ * here — see {@link ./context}. Three deliberate namespaces:
30
48
  *
31
- * export default function Quiz() {
32
- * // ...
33
- * }
34
- * ```
35
- *
36
- * The CLI build step collects all `page` exports and assembles them into
37
- * the full config (pages map + routes map + computed route manifest for analytics).
49
+ * - `user.*` persistent identity attributes (cross-session)
50
+ * - `responses.*` the clean, aggregated funnel answers
51
+ * - `data.*` working/scratch state — persisted (so reload + return survive)
52
+ * but kept separate from responses so analytics aggregates only
53
+ * real answers and `data` can hold intermediate garbage
38
54
  */
39
- declare function definePage(definition: PageDefinition): PageDefinition;
40
55
 
56
+ /** v2 namespace → store-key prefix (sans trailing dot). 1:1, no legacy aliasing. */
57
+ declare const NAMESPACE_PREFIX: {
58
+ readonly user: "user";
59
+ readonly responses: "responses";
60
+ readonly data: "data";
61
+ };
62
+ type WritableNamespace = keyof typeof NAMESPACE_PREFIX;
63
+ interface FunnelStoreConfig {
64
+ responses?: Record<string, VariableConfig>;
65
+ data?: Record<string, VariableConfig>;
66
+ }
41
67
  /**
42
- * Read/write a single variable.
68
+ * Seed a store from a funnel's declared `responses`/`data` defaults, then layer
69
+ * server / session-restored values on top (highest priority).
70
+ */
71
+ declare function createFunnelStore(config?: FunnelStoreConfig, sessionValues?: Record<string, VariableValue>): VariableStore;
72
+
73
+ /**
74
+ * v2 `context` — the **read-only, computed** namespace.
75
+ *
76
+ * Everything the funnel can *read* but never *write*: the device, the visitor's
77
+ * locale, the current page, acquisition params (UTM), the session, and system
78
+ * facts (funnel id, test/live mode, clock). It supersedes the v0 flat
79
+ * `page.*`/`device.*`/`browser.*`/`os.*`/`query.*`/`metadata.*` system variables
80
+ * (see {@link ./systemVariables}) by folding them into one nested object.
43
81
  *
44
- * **Advanced** this hook is not intended for normal page usage.
45
- * Prefer accessing variables through the standard page props instead.
82
+ * Why it's *not* in the {@link ./variableStore}: the store holds **writable**
83
+ * state (`user`/`responses`/`data`) that persists + re-renders per key. Context
84
+ * is derived and read-only, so it lives as a plain object on a React context —
85
+ * no subscriptions, no churn. The one moving part (`page`) is merged into the
86
+ * snapshot from navigation state at read time.
46
87
  *
47
- * ```tsx
48
- * const [email, setEmail] = useVariable<string>('email')
49
- * const [count, setCount] = useVariable<number>('count')
50
- * const [agreed, setAgreed] = useVariable<boolean>('agreed')
51
- * const [tags, setTags] = useVariable<string[]>('tags')
52
- * ```
88
+ * Authors read it through hooks (`useDevice()`, `useLocale()`, `usePage()`,
89
+ * `useUtm()`) or inside routing/condition predicates as `s.context.*`.
90
+ */
91
+ interface DeviceContext {
92
+ type: 'mobile' | 'tablet' | 'desktop';
93
+ isMobile: boolean;
94
+ isTablet: boolean;
95
+ screenWidth: number;
96
+ screenHeight: number;
97
+ viewportWidth: number;
98
+ viewportHeight: number;
99
+ colorDepth: number;
100
+ pixelRatio: number;
101
+ }
102
+ interface BrowserContext {
103
+ name: string;
104
+ version: string;
105
+ userAgent: string;
106
+ language: string;
107
+ cookieEnabled: boolean;
108
+ online: boolean;
109
+ }
110
+ interface OsContext {
111
+ name: string;
112
+ timezone: string;
113
+ }
114
+ interface LocaleContext {
115
+ /** Full locale, e.g. `en-US`. */
116
+ locale: string;
117
+ /** Language subtag, e.g. `en`. */
118
+ language: string;
119
+ /** Region subtag, e.g. `US` (may be empty). */
120
+ region: string;
121
+ /** IANA timezone, e.g. `America/New_York`. */
122
+ timeZone: string;
123
+ }
124
+ interface PageContext {
125
+ /** Current page key. */
126
+ key: string;
127
+ /** Current page URL slug (`meta.slug`, defaults to the key) — what the renderer puts in the path. */
128
+ slug: string;
129
+ /** 0-based position in the visited history. */
130
+ index: number;
131
+ /** Total pages in the (expected) flow. */
132
+ total: number;
133
+ /** 0–100. */
134
+ progressPercentage: number;
135
+ /** Epoch ms the current page was entered. */
136
+ startedAt: number;
137
+ }
138
+ /** Acquisition / ad params captured on entry (the old `query.*` utm slice). */
139
+ interface UtmContext {
140
+ source?: string;
141
+ medium?: string;
142
+ campaign?: string;
143
+ content?: string;
144
+ term?: string;
145
+ [key: string]: string | undefined;
146
+ }
147
+ /**
148
+ * Per-network ad **click-ids** captured from the entry URL — the matching signals
149
+ * the server CAPI needs. Named per network (not a generic bag) so the platform +
150
+ * each integration map them precisely; `[key]` catches any others.
151
+ */
152
+ interface ClickIdContext {
153
+ /** Meta / Facebook */
154
+ fbclid?: string;
155
+ /** Google Ads (+ gbraid/wbraid for app↔web) */
156
+ gclid?: string;
157
+ gbraid?: string;
158
+ wbraid?: string;
159
+ /** Microsoft / Bing */
160
+ msclkid?: string;
161
+ /** TikTok */
162
+ ttclid?: string;
163
+ /** Twitter / X */
164
+ twclid?: string;
165
+ /** Snapchat */
166
+ sccid?: string;
167
+ /** LinkedIn */
168
+ li_fat_id?: string;
169
+ /** Pinterest */
170
+ epik?: string;
171
+ /** Reddit */
172
+ rdt_cid?: string;
173
+ [key: string]: string | undefined;
174
+ }
175
+ /**
176
+ * The full acquisition snapshot captured once on entry — the funnel reads `utm` /
177
+ * `clickIds` off {@link FunnelContext}; the SDK forwards this whole thing to the
178
+ * tracker (`setAcquisition`) so the platform persists it **first-class**: per-visit
179
+ * on the session + **first-touch (write-once) on the Customer**, and the click-ids /
180
+ * `_fbp`/`_fbc` cookies feed CAPI. Not stored as a generic attribute/answer.
181
+ */
182
+ interface Acquisition {
183
+ utm: UtmContext;
184
+ clickIds: ClickIdContext;
185
+ /** CAPI cookies (`_fbp`/`_fbc`) read from `document.cookie` (not funnel-readable). */
186
+ cookies: {
187
+ fbp?: string;
188
+ fbc?: string;
189
+ };
190
+ /** `document.referrer` at entry. */
191
+ referrer: string;
192
+ /** Entry URL path (the landing page). */
193
+ landingPath: string;
194
+ }
195
+ interface SessionContext {
196
+ /** Server session id; null until resolved. */
197
+ id: string | null;
198
+ /** Epoch ms the session began. */
199
+ startedAt: number;
200
+ /** `document.referrer` at entry. */
201
+ referrer: string;
202
+ }
203
+ /**
204
+ * Durable identity for **stable experiment bucketing** (phase-4 §4.2). The
205
+ * preferred seed is `customerId` once known; before identification it's the
206
+ * signed anonymous `visitorId` (P1). It is deliberately **not** the session id or
207
+ * fingerprint — a returning visitor (e.g. via winback) must keep their variant
208
+ * across sessions/devices (landmine #4). Both null = unidentifiable traffic, and
209
+ * the experiment layer falls back to control rather than bucket on nothing.
53
210
  */
54
- declare function useVariable<T extends VariableValue>(id: string): [T, (value: T) => void];
211
+ interface IdentityContext {
212
+ /** Known customer id (post-identification). The preferred bucketing seed. */
213
+ customerId: string | null;
214
+ /** Signed anonymous visitor id. The fallback bucketing seed. */
215
+ visitorId: string | null;
216
+ }
217
+ interface SystemContext {
218
+ funnelId: string;
219
+ campaignId: string;
220
+ /** Render mode — `test` for preview/QA, `live` for public traffic. */
221
+ mode: 'test' | 'live';
222
+ /** Epoch ms captured at build time (stable per render, not a live clock). */
223
+ now: number;
224
+ }
225
+ /** The whole read-only computed namespace, as read in predicates: `s.context.*`. */
226
+ interface FunnelContext {
227
+ device: DeviceContext;
228
+ browser: BrowserContext;
229
+ os: OsContext;
230
+ locale: LocaleContext;
231
+ page: PageContext;
232
+ utm: UtmContext;
233
+ /** Per-network ad click-ids captured on entry (funnel-readable; also forwarded for CAPI). */
234
+ clickIds: ClickIdContext;
235
+ session: SessionContext;
236
+ identity: IdentityContext;
237
+ system: SystemContext;
238
+ }
239
+ /**
240
+ * Assemble the full {@link Acquisition} snapshot from the context + `document`
241
+ * (cookies, referrer, path) — captured once on entry, forwarded to the tracker for
242
+ * first-class persistence (session + first-touch Customer) + CAPI.
243
+ */
244
+ declare function buildAcquisition(context: FunnelContext): Acquisition;
245
+ interface BuildContextOptions {
246
+ funnelId?: string;
247
+ campaignId?: string;
248
+ mode?: 'test' | 'live';
249
+ /** Stable build-time clock (avoids `Date.now()` churn in renders/tests). */
250
+ now?: number;
251
+ sessionId?: string | null;
252
+ /** Known customer id — the preferred stable bucketing seed (phase-4 §4.2). */
253
+ customerId?: string | null;
254
+ /** Signed anonymous visitor id — the fallback bucketing seed. */
255
+ visitorId?: string | null;
256
+ /** Overrides for any slice — used by the server to hydrate `utm`/`session`. */
257
+ overrides?: Partial<FunnelContext>;
258
+ }
259
+ /**
260
+ * Build the read-only {@link FunnelContext} once, at mount. `page` starts at the
261
+ * first page and is replaced per navigation when assembling a snapshot.
262
+ */
263
+ declare function buildContext(opts?: BuildContextOptions): FunnelContext;
55
264
 
56
265
  /**
57
- * Read all variables (including system variables). Read-only.
58
- * Use useVariable() to write individual variables.
266
+ * v2 tracking seam the funnel runtime emits a **fixed event taxonomy**, but
267
+ * never talks to the network itself. It calls a {@link Tracker} the platform/CLI
268
+ * injects via {@link FunnelProvider}. The real implementation is the existing
269
+ * {@link ./tracker}.`FunnelTracker` (session id, ad attribution, debounced
270
+ * persistence, CAPI fan-out); tests and local dev use {@link createConsoleTracker}.
271
+ *
272
+ * The taxonomy is preserved verbatim from v0 so analytics keep working:
273
+ * funnel.start · page.view · page.exit · user.registered · experiment.exposure ·
274
+ * checkout.start · checkout.payment_added · checkout.failed · purchase.complete
59
275
  *
60
- * **Advanced** this hook is not intended for normal page usage.
61
- * Prefer accessing variables through the standard page props instead.
276
+ * Where each fires (all from the SDK runtime):
277
+ * - `funnel.start` / `page.view` / `page.exit` / `experiment.exposure` navigation
278
+ * ({@link ../flow/flow}.`FunnelView`); `page.exit` carries duration + active (visible) time.
279
+ * - `user.registered` — `identify()`, the first time `user.email` is written.
280
+ * - `checkout.*` / `purchase.complete` — the commerce primitives ({@link ../commerce/checkout}).
281
+ * `checkout.payment_added` is reported by an inline driver via `onPaymentAdded` (optional).
62
282
  *
63
- * Warning: this hook subscribes to ALL variable changes, so the
64
- * component will re-render on every single variable update.
283
+ * A `responses.set(...)` does **not** emit a discrete event values are batched into
284
+ * `userData` and persisted (debounced). Only the events above fire.
65
285
  */
66
- declare function useVariables(): Record<string, VariableValue>;
67
286
 
68
- type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD';
287
+ interface FunnelEventDataMap {
288
+ 'funnel.start': Record<string, never>;
289
+ 'page.view': {
290
+ pageId: string;
291
+ pageKey?: string;
292
+ pageName?: string;
293
+ isInitial?: boolean;
294
+ eventId?: string;
295
+ };
296
+ 'page.exit': {
297
+ pageId: string;
298
+ durationMs: number;
299
+ activeMs: number;
300
+ };
301
+ 'user.registered': {
302
+ email: string;
303
+ eventId?: string;
304
+ };
305
+ 'checkout.start': {
306
+ productId?: string;
307
+ priceId?: string;
308
+ productName?: string;
309
+ };
310
+ 'checkout.payment_added': Record<string, never>;
311
+ 'checkout.failed': {
312
+ category: string;
313
+ declineCode?: string;
314
+ productId?: string;
315
+ intent?: string;
316
+ };
317
+ 'purchase.complete': {
318
+ amount?: number;
319
+ currency?: string;
320
+ email?: string;
321
+ eventId?: string;
322
+ };
323
+ 'experiment.exposure': {
324
+ experimentId: string;
325
+ variant: string;
326
+ };
327
+ }
328
+ declare function newEventId(): string;
329
+ type KnownFunnelEvent = keyof FunnelEventDataMap;
330
+ /**
331
+ * The surface the runtime needs from a tracker. A subset of `FunnelTracker`, so
332
+ * the real one satisfies it directly; the console tracker is a trivial stub.
333
+ */
334
+ interface Tracker {
335
+ /** Emit a known event (typed) or any custom event name. */
336
+ track<E extends KnownFunnelEvent>(event: E, data?: FunnelEventDataMap[E], userData?: Record<string, unknown>): void;
337
+ track(event: string, data?: Record<string, unknown>, userData?: Record<string, unknown>): void;
338
+ /**
339
+ * Identify by email — fires `user.registered` (idempotent in the impl). The
340
+ * optional `eventId` is the browser↔CAPI dedup key for the registration event.
341
+ */
342
+ identify(email: string, eventId?: string): void;
343
+ /** Push the latest variable snapshot for batched/debounced persistence. */
344
+ setVariables?(variables: Record<string, unknown>): void;
345
+ /**
346
+ * The acquisition snapshot (utm + click-ids + `_fbp`/`_fbc` + referrer), pushed
347
+ * once on entry. The platform persists it first-class — per-visit on the session +
348
+ * **first-touch (write-once) on the Customer** — and feeds it to CAPI. Optional;
349
+ * the console/test trackers ignore it.
350
+ */
351
+ setAcquisition?(acquisition: Acquisition): void;
352
+ }
353
+ /** A no-op-ish tracker that logs to the console — for tests, preview, local dev. */
354
+ declare function createConsoleTracker(opts?: {
355
+ silent?: boolean;
356
+ }): Tracker;
357
+ /**
358
+ * `const { track } = useTracker()` — emit a custom event. Auto events
359
+ * (page.view, identify, purchase, …) fire on their own; this is the escape
360
+ * hatch for funnel-specific events (`track('quiz_skipped', { step: 3 })`).
361
+ */
362
+ declare function useTracker(): {
363
+ track: Tracker['track'];
364
+ };
69
365
 
70
366
  /**
71
- * Read built-in user state (email, name, customerId).
367
+ * v2 `products` the **read-only** product catalog.
368
+ *
369
+ * **AppFunnel does not manage currency.** Multi-currency is entirely the payment
370
+ * provider's job: the provider (or its preview API) resolves the visitor's price,
371
+ * and we receive a single resolved `{ amount, currency }` per product. No FX, no
372
+ * currency selection, no conversion on our side. (FX appears only for frozen-USD
373
+ * *reporting*, never display — phase-3 §3.6b.) A provider that doesn't localize
374
+ * simply gives one currency.
72
375
  *
73
- * Only re-renders when a `user.*` variable changes.
74
- * For reading/writing arbitrary user fields, use `useUserProperty('fieldName')`.
376
+ * Our only job is **display**: pick the product by logical id and **format** its
377
+ * amount. Formatting happens at read time in the funnel's **active locale** (so
378
+ * switching language reformats `kr 79,00` ⇄ `$9.99`), while the **currency** is
379
+ * whatever the provider resolved. Currency selection is the platform's; the
380
+ * authoring funnel only ever names a product id.
75
381
  */
76
- declare function useUser(): UserState;
382
+
383
+ type Interval = 'day' | 'week' | 'month' | 'year' | 'one_time';
384
+ /** What the platform hands the funnel per product (a single provider-resolved price). */
385
+ interface ProductInput {
386
+ id: string;
387
+ name?: string;
388
+ displayName?: string;
389
+ /** Provider-resolved price in **minor units** (the provider chose the currency). */
390
+ amount: number;
391
+ /** Currency of `amount`, as the provider resolved it (e.g. `DKK`). */
392
+ currency: string;
393
+ interval?: Interval;
394
+ intervalCount?: number;
395
+ trialDays?: number;
396
+ /** Trial price in minor units (omit/0 = free trial). */
397
+ trialAmount?: number;
398
+ /** Settlement provider — opaque to the author, used by the checkout driver. */
399
+ provider?: string;
400
+ }
401
+ /** A money value with a locale-formatted string. */
402
+ interface Money {
403
+ /** Major units, e.g. `79`. */
404
+ amount: number;
405
+ /** Minor units, e.g. `7900`. */
406
+ minor: number;
407
+ currency: string;
408
+ /** Locale-formatted, e.g. `kr 79,00` / `€9,99` / `$9.99`. */
409
+ formatted: string;
410
+ }
77
411
  /**
78
- * Read/write a user property by field name.
79
- * Auto-prefixes with `user.` no need to define in config.
80
- *
81
- * ```tsx
82
- * const [dob, setDob] = useUserProperty('dateOfBirth')
83
- * const [gender, setGender] = useUserProperty('gender')
84
- * ```
412
+ * The product with its amounts resolved (currency + period math), **before**
413
+ * locale formattingwhat the catalog stores. {@link useProduct} formats it.
85
414
  */
86
- declare function useUserProperty(field: string): [string, (value: string) => void];
415
+ interface ResolvedProduct {
416
+ id: string;
417
+ name: string;
418
+ displayName: string;
419
+ provider: string;
420
+ currency: string;
421
+ priceMinor: number;
422
+ perDayMinor: number;
423
+ perWeekMinor: number;
424
+ perMonthMinor: number;
425
+ perYearMinor: number;
426
+ period: string;
427
+ periodly: string;
428
+ hasTrial: boolean;
429
+ trialDays: number;
430
+ trialMinor: number;
431
+ }
432
+ /** Display-ready product the author reads (every amount locale-formatted). */
433
+ interface Product {
434
+ id: string;
435
+ name: string;
436
+ displayName: string;
437
+ provider: string;
438
+ price: Money;
439
+ /** e.g. `month`, `quarter`, `one-time`. */
440
+ period: string;
441
+ /** e.g. `monthly`, `quarterly`, `one-time`. */
442
+ periodly: string;
443
+ /** Period-normalized prices (for "just $0.27/day" style copy). */
444
+ perDay: Money;
445
+ perWeek: Money;
446
+ perMonth: Money;
447
+ perYear: Money;
448
+ hasTrial: boolean;
449
+ trialDays: number;
450
+ trialPrice: Money | null;
451
+ }
452
+ /** Resolve a {@link ProductInput} into amounts + period math (no formatting yet). */
453
+ declare function resolveProduct(input: ProductInput): ResolvedProduct;
454
+ /** Build the id → {@link ResolvedProduct} catalog. Formatting happens at read time. */
455
+ declare function buildCatalog(inputs: ProductInput[]): Map<string, ResolvedProduct>;
456
+ /** Format a {@link ResolvedProduct} into a display {@link Product} in `locale`. */
457
+ declare function formatProduct(r: ResolvedProduct, locale: string): Product;
458
+ /** `useProduct('monthly')` — one product, formatted in the active locale. */
459
+ declare function useProduct(id: string | undefined): Product | undefined;
460
+ /** `useProducts()` — every product, formatted in the active locale, in order. */
461
+ declare function useProducts(): Product[];
462
+
87
463
  /**
88
- * Read/write the user's date of birth.
89
- * Always stores the value as YYYY-MM-DD regardless of input format.
464
+ * v2 commerce primitives — the two intents from phase-3, made trivial.
90
465
  *
91
- * @param format - How the input string should be interpreted (default: MM/DD/YYYY).
466
+ * - `<Checkout product surface>` the **purchase** primitive. The `surface`
467
+ * prop picks presentation; some surfaces render **inline** (express wallets,
468
+ * inline card, provider iframe), some **open on click** (sheet/popup/redirect).
469
+ * `<Checkout>` covers both: it renders inline surfaces and renders a trigger
470
+ * button for the rest (`asChild` to use your own button).
471
+ * - `<Upsell product>` — the **upsell** primitive. Off-session charge to the
472
+ * saved method, no card re-entry, one tap.
473
+ * - `useCheckout()` — the headless hook under both, always available for a
474
+ * fully-custom CTA.
92
475
  *
93
- * ```tsx
94
- * const [dob, setDob] = useDateOfBirth() // defaults to MM/DD/YYYY
95
- * const [dob, setDob] = useDateOfBirth('DD/MM/YYYY') // explicit EU format
476
+ * The SDK never talks to a provider. It owns the state machine + the analytics
477
+ * (`checkout.start` / `purchase.complete`) + post-charge navigation, and
478
+ * delegates the actual charge/render to an injected {@link CheckoutDriver}
479
+ * (the real one is platform-side; tests use {@link createMockDriver}).
96
480
  *
97
- * setDob('03/15/1990') // stored as "1990-03-15"
98
- * setDob('03151990') // stored as "1990-03-15"
99
- * ```
481
+ * Which product is charged is the author's bound variable, captured at render
482
+ * (inline surfaces) or at `open()` time (trigger surfaces).
100
483
  */
101
- declare function useDateOfBirth(format?: DateFormat): [string, (value: string) => void];
102
484
 
485
+ type CheckoutSurface = 'express' | 'element' | 'embedded' | 'sheet' | 'popup' | 'redirect';
486
+ type CheckoutProvider = 'stripe' | 'paddle' | 'whop' | 'primer' | 'solidgate';
487
+ type CheckoutIntent = 'purchase' | 'upsell';
488
+ /** Surfaces that render inline (vs. those that open on click). */
489
+ declare const INLINE_SURFACES: ReadonlySet<CheckoutSurface>;
490
+ declare function isInlineSurface(surface: CheckoutSurface): boolean;
103
491
  /**
104
- * Read/write a single response variable (answers.*).
105
- *
106
- * ```tsx
107
- * const [goal, setGoal] = useResponse<string>('goal')
108
- * const [interests, setInterests] = useResponse<string[]>('interests')
109
- * ```
492
+ * The kind of off-session upsell (researched against Funnelfox's changelog, which
493
+ * ships these as distinct features). The discriminating real-world constraint is
494
+ * **stacking a 2nd concurrent subscription** — Paddle can't, SolidGate/Stripe can.
110
495
  */
111
- declare function useResponse<T extends VariableValue>(key: string): [T, (value: T) => void];
496
+ type UpsellKind = 'subscription' | 'one-time' | 'upgrade';
497
+ /** Reliability of an off-session upsell charge AFTER a purchase on a surface. */
498
+ type OffSessionReliability = 'reliable' | 'conditional' | 'none';
499
+ interface SurfaceCapability {
500
+ purchase: boolean;
501
+ /** Apple/Google Pay available on this surface. */
502
+ wallets: boolean;
503
+ /** Reliability of an off-session upsell after a purchase on this surface. */
504
+ offSession: OffSessionReliability;
505
+ }
506
+ interface ProviderProfile {
507
+ /** Merchant-of-Record (handles tax/compliance) vs. a PSP you settle through. */
508
+ isMoR: boolean;
509
+ /** Upsell kinds this provider can charge off-session (one-click, no re-entry). */
510
+ offSessionUpsells: UpsellKind[];
511
+ /** Who charges the saved method off-session: you (PSP) or the provider (MoR/managed). */
512
+ tokenModel: 'merchant' | 'provider';
513
+ /** Routes across multiple processors (Primer; SolidGate also offers this) vs. a single acquirer. */
514
+ orchestrator: boolean;
515
+ /** Supported surfaces → per-surface capabilities. **Absent = not supported.** */
516
+ surfaces: Partial<Record<CheckoutSurface, SurfaceCapability>>;
517
+ }
112
518
  /**
113
- * Read all response variables as a flat object (without the `answers.` prefix).
519
+ * Provider capabilities (researched 2026-06-23 against provider docs + Funnelfox).
114
520
  *
115
- * Only re-renders when an `answers.*` variable changes.
521
+ * Three real-world corrections to the naive model:
522
+ * 1. **Off-session upsell support is per-provider by *kind*** (`offSessionUpsells`):
523
+ * Paddle can't stack a 2nd concurrent subscription (upgrade + one-time only);
524
+ * Stripe/SolidGate/Whop/Primer can do all kinds.
525
+ * 2. **Off-session *reliability* varies by the paywall surface** (`SurfaceCapability.
526
+ * offSession`): Stripe **managed** checkout (`embedded`/`redirect`) upsells as a
527
+ * direct charge against Stripe's vault → `conditional` (may decline); Elements
528
+ * surfaces are `reliable`.
529
+ * 3. **Surface availability is a hard constraint.** MoR/managed providers can't
530
+ * expose a raw card field to your own UI: Paddle has no `express`/`element`/your
531
+ * `sheet`; Whop has no raw `element`/`express`; Primer has no hosted `redirect`;
532
+ * Stripe/SolidGate have no `popup`. A missing entry means "not possible."
533
+ */
534
+ declare const PROVIDER_PROFILES: Record<CheckoutProvider, ProviderProfile>;
535
+ interface ValidationResult {
536
+ ok: boolean;
537
+ reason?: string;
538
+ /** Non-fatal caveat (e.g. Paddle upgrade-only). */
539
+ note?: string;
540
+ }
541
+ /** Surfaces a provider supports, for the editor/AI to steer authors. */
542
+ declare function surfacesFor(provider: CheckoutProvider): CheckoutSurface[];
543
+ /** Whether a provider is a Merchant of Record (it owns tax/compliance). */
544
+ declare function isMerchantOfRecord(provider: CheckoutProvider): boolean;
545
+ /** Whether a provider routes across multiple processors (Primer, SolidGate). */
546
+ declare function isOrchestrator(provider: CheckoutProvider): boolean;
547
+ /**
548
+ * Validate a (provider, surface) for a **purchase**: the surface must exist for
549
+ * the provider and accept a payment. Surface availability is the real constraint
550
+ * (e.g. Paddle has no `element`).
551
+ */
552
+ declare function validateCheckout(provider: CheckoutProvider, surface: CheckoutSurface): ValidationResult;
553
+ /**
554
+ * Validate an **off-session upsell** of `kind` after a purchase made on
555
+ * `paywallSurface`. Combines provider support for the kind (Paddle can't stack a
556
+ * 2nd subscription) with the paywall surface's off-session reliability (Stripe
557
+ * managed checkout is `conditional` — returned as a `note`, not a hard block).
558
+ */
559
+ declare function validateUpsell(provider: CheckoutProvider, paywallSurface: CheckoutSurface, kind?: UpsellKind): ValidationResult;
560
+ type CheckoutStatus = 'idle' | 'loading' | 'requires_action' | 'success' | 'error';
561
+ interface CheckoutRequest {
562
+ product: string;
563
+ intent: CheckoutIntent;
564
+ surface?: CheckoutSurface;
565
+ /** For `upsell` intent — which kind of off-session charge (default `subscription`). */
566
+ kind?: UpsellKind;
567
+ }
568
+ /**
569
+ * Normalized failure category — the driver/platform maps each provider's decline
570
+ * codes into one of these so funnels can route/recover by reason. `authentication_
571
+ * required` (3DS/SCA) and `requires_payment_method` are the cases an **off-session**
572
+ * upsell can't satisfy → recover on-session (re-collect a card).
573
+ */
574
+ type CheckoutErrorCategory = 'card_declined' | 'insufficient_funds' | 'expired_card' | 'authentication_required' | 'requires_payment_method' | 'processing_error' | 'canceled' | 'unknown';
575
+ interface CheckoutError {
576
+ category: CheckoutErrorCategory;
577
+ message: string;
578
+ /** Raw provider decline code, if any (e.g. Stripe `card_declined`/`insufficient_funds`). */
579
+ declineCode?: string;
580
+ /** Whether re-attempting (e.g. an on-session re-collection) may succeed. */
581
+ retryable: boolean;
582
+ }
583
+ /** Build a {@link CheckoutError} (driver/test helper). */
584
+ declare function checkoutError(category: CheckoutErrorCategory, message: string, extra?: {
585
+ declineCode?: string;
586
+ retryable?: boolean;
587
+ }): CheckoutError;
588
+ interface CheckoutResult {
589
+ ok: boolean;
590
+ error?: CheckoutError;
591
+ /** Charged amount in minor units (for the purchase event). */
592
+ amountMinor?: number;
593
+ currency?: string;
594
+ /**
595
+ * The **server-minted** dedup id for the purchase event — the platform creates it
596
+ * when the charge settles and fires CAPI with it; the SDK emits `purchase.complete`
597
+ * with the same id so the browser pixel dedupes against the server. (Server is the
598
+ * source of truth for payment events; the SDK doesn't mint these.)
599
+ */
600
+ eventId?: string;
601
+ }
602
+ interface CheckoutCallbacks {
603
+ onSuccess: (result: CheckoutResult) => void;
604
+ onError: (error: CheckoutError) => void;
605
+ /**
606
+ * The inline surface reports a valid payment method was entered (e.g. Stripe's
607
+ * Payment Element became `complete`), before the charge. Drives the
608
+ * `checkout.payment_added` analytics step; optional — drivers that can't observe
609
+ * it (or off-session charges) simply never call it.
610
+ */
611
+ onPaymentAdded?: () => void;
612
+ }
613
+ /**
614
+ * The provider seam. The platform injects the real one (Stripe/Paddle/Whop/…);
615
+ * tests use {@link createMockDriver}.
116
616
  *
117
- * ```tsx
118
- * const responses = useResponses()
119
- * // { goal: 'weight_loss', interests: ['yoga', 'running'] }
120
- * ```
617
+ * Most providers ship their **own React SDK** (`@stripe/react-stripe-js`,
618
+ * `@paddle/paddle-js`, Primer's React components, …). A driver's `renderInline`
619
+ * returns exactly that: the provider's React element mounted in our chrome (inline,
620
+ * or inside the `<Sheet>`). Those provider SDKs live in the **driver/platform**
621
+ * package, not our core funnel SDK — which is why the driver is injected: the core
622
+ * stays lean and provider-agnostic, and you only pull the SDK for the provider you use.
121
623
  */
122
- declare function useResponses(): Record<string, VariableValue>;
624
+ interface CheckoutDriver {
625
+ provider: CheckoutProvider;
626
+ /** Trigger a charge (trigger surfaces + programmatic). Resolves when settled. */
627
+ start(req: CheckoutRequest): Promise<CheckoutResult>;
628
+ /** Render an inline surface (e.g. the provider's React payment element); report via callbacks. */
629
+ renderInline?(req: CheckoutRequest, cb: CheckoutCallbacks): ReactNode;
630
+ }
631
+ interface MockDriverOptions {
632
+ /** The result a successful charge resolves with. */
633
+ result?: CheckoutResult;
634
+ /** Make **off-session** charges (no surface) fail — to exercise the upsell fallback. */
635
+ failOffSession?: CheckoutError | boolean;
636
+ }
637
+ /** A console/no-op driver — tests, preview, local dev. Succeeds unless configured to fail. */
638
+ declare function createMockDriver(provider?: CheckoutProvider, options?: MockDriverOptions): CheckoutDriver;
639
+ interface UseCheckoutOptions {
640
+ intent?: CheckoutIntent;
641
+ /** For `upsell` intent — which kind of off-session charge (default `subscription`). */
642
+ kind?: UpsellKind;
643
+ /** Advance to the next page after a successful charge (default: true). */
644
+ advanceOnSuccess?: boolean;
645
+ onSuccess?: (result: CheckoutResult) => void;
646
+ onError?: (error: CheckoutError) => void;
647
+ }
648
+ interface CheckoutHandle {
649
+ /** Start the charge for a product; resolves with the result (no surface = off-session). */
650
+ open: (product: string, surface?: CheckoutSurface) => Promise<CheckoutResult>;
651
+ status: CheckoutStatus;
652
+ /** The last failure, structured so you can route/recover by `category`. */
653
+ error: CheckoutError | null;
654
+ isLoading: boolean;
655
+ reset: () => void;
656
+ /** Build the callbacks an inline surface reports through (used by `<Checkout>`). */
657
+ callbacksFor: (product: string, surface?: CheckoutSurface) => CheckoutCallbacks & {
658
+ onStart: () => void;
659
+ };
660
+ }
661
+ /**
662
+ * The headless checkout hook. Owns the state machine + analytics + post-charge
663
+ * navigation; delegates the charge to the injected {@link CheckoutDriver}.
664
+ */
665
+ declare function useCheckout(opts?: UseCheckoutOptions): CheckoutHandle;
666
+ interface CheckoutProps extends UseCheckoutOptions {
667
+ /** Logical product id (the author's bound selection). */
668
+ product: string;
669
+ surface: CheckoutSurface;
670
+ /** Render your own trigger element instead of the default button (trigger surfaces). */
671
+ asChild?: boolean;
672
+ /** Trigger label / your trigger element. */
673
+ children?: ReactNode;
674
+ className?: string;
675
+ }
676
+ /**
677
+ * The purchase primitive. Three presentation modes by `surface`:
678
+ * - **inline** (`express`/`element`/`embedded`) — the driver renders the
679
+ * provider UI in place (keyed by product so a selection change remounts it);
680
+ * - **SDK sheet** (`sheet`) — a trigger opens the AppFunnel-owned {@link Sheet}
681
+ * and the driver renders the provider element inside it;
682
+ * - **provider-hosted** (`popup`/`redirect`) — a trigger hands off to the
683
+ * provider's own overlay/page.
684
+ */
685
+ declare function Checkout({ product, surface, asChild, children, className, ...options }: CheckoutProps): ReactNode;
686
+ interface UpsellProps extends Omit<UseCheckoutOptions, 'intent'> {
687
+ product: string;
688
+ /**
689
+ * If the off-session charge fails, present this surface **on-session** to
690
+ * re-collect a card and charge again — e.g. `fallback="sheet"` opens the
691
+ * payment sheet, `fallback="popup"` the provider overlay. Omit to just report
692
+ * the failure (via `onError` / the `checkout.failed` event).
693
+ */
694
+ fallback?: CheckoutSurface;
695
+ asChild?: boolean;
696
+ children?: ReactNode;
697
+ className?: string;
698
+ }
699
+ /**
700
+ * The upsell primitive — an off-session, one-tap charge to the saved method (no
701
+ * surface, no card re-entry). On failure, optionally `fallback` to an on-session
702
+ * surface to re-collect and retry.
703
+ */
704
+ declare function Upsell({ product, fallback, asChild, children, className, ...options }: UpsellProps): ReactNode;
123
705
 
124
706
  /**
125
- * Read all URL query parameters as a flat object (without the `query.` prefix).
707
+ * The funnel **spine** the constrained, declarative configuration authored as
708
+ * code (`defineFunnel` / `pageMeta` / `definePage`) yet **dashboard-editable**
709
+ * (static AST parse → pretty UI → codegen) and compiled to the manifest IR.
710
+ *
711
+ * Principle: **presentation = code** (the page components), **configuration =
712
+ * data** (this spine). See docs/platform-v2/06-funnel-project-model.md.
126
713
  *
127
- * Only re-renders when a `query.*` variable changes.
714
+ * Routing is **declarative nodes + code predicates** (decided — doc 06): a
715
+ * page's `next` is an ordered list of {@link Route}s, each with a **static `to`
716
+ * page key** (so the dashboard can draw every possible edge without executing
717
+ * anything) and an optional `when` **gate** that returns true/false. The gate is
718
+ * either a declarative {@link Condition} (one field — UI-editable data) or a code
719
+ * {@link Predicate} (`s => boolean` — the escape hatch, opaque body but the edge
720
+ * is still visible). First matching route wins; a route with no `when` is the
721
+ * fallback. Multiple conditions = multiple routes (or boolean logic in one
722
+ * predicate). Omit `next` entirely for the default linear flow.
128
723
  */
129
- declare function useQueryParams(): Record<string, string>;
130
724
  /**
131
- * Read a single query parameter by name.
132
- *
133
- * ```tsx
134
- * const utmSource = useQueryParam('utm_source')
135
- * ```
725
+ * Read-only view of every namespace a routing/condition predicate can branch
726
+ * on. `user`/`responses`/`data` are the writable namespaces; `context` is the
727
+ * read-only computed one ({@link FunnelContext}).
728
+ */
729
+ interface FunnelSnapshot {
730
+ user: Record<string, VariableValue>;
731
+ responses: Record<string, VariableValue>;
732
+ data: Record<string, VariableValue>;
733
+ context: FunnelContext;
734
+ }
735
+ /** A code-predicate gate — `s => s.responses.goal === 'gain'`. */
736
+ type Predicate = (s: FunnelSnapshot) => boolean;
737
+ type ConditionOp = 'eq' | 'neq' | 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'exists' | 'empty' | 'contains';
738
+ /**
739
+ * A declarative gate over **one** namespaced field — UI-editable data the
740
+ * dashboard renders as a condition row (`field` `op` `value`). `field` is a
741
+ * dotted path into the snapshot: `responses.goal`, `user.country`,
742
+ * `context.device.isMobile`.
743
+ */
744
+ interface Condition {
745
+ field: string;
746
+ op: ConditionOp;
747
+ value?: VariableValue | VariableValue[];
748
+ }
749
+ /** An edge gate: either declarative data ({@link Condition}) or a {@link Predicate}. */
750
+ type Gate = Condition | Predicate;
751
+ /**
752
+ * One routing edge out of a page. `to` is a **static** target key (the dashboard
753
+ * reads these to draw the flow graph); `when` gates the edge (omit = fallback).
754
+ */
755
+ interface Route {
756
+ to: string;
757
+ when?: Gate;
758
+ }
759
+ /** Evaluate a declarative {@link Condition} against a snapshot. */
760
+ declare function evaluateCondition(cond: Condition, s: FunnelSnapshot): boolean;
761
+ /** Evaluate either gate kind to a boolean. */
762
+ declare function evaluateGate(gate: Gate, s: FunnelSnapshot): boolean;
763
+ /**
764
+ * Resolve an ordered list of routes to a target page key, or `undefined` to fall
765
+ * through to the linear next. First route whose gate passes (or has no `when`)
766
+ * wins.
767
+ */
768
+ declare function resolveRoute(routes: Route[] | undefined, s: FunnelSnapshot): string | undefined;
769
+ type PageType = 'default' | 'email-capture' | 'paywall' | 'upsell' | 'finish';
770
+ interface PageMeta {
771
+ /** Page kind — drives behavior + analytics (e.g. `paywall`, `upsell`). */
772
+ type?: PageType;
773
+ /** URL slug; defaults to the page key. */
774
+ slug?: string;
775
+ /**
776
+ * Ordered routing edges out of this page (first match wins). Each has a static
777
+ * `to` key + an optional `when` gate. Omit for the default linear flow.
778
+ */
779
+ next?: Route[];
780
+ /**
781
+ * Entry precondition for **deep-link / reload** restoration. When the runtime is
782
+ * asked to start *on this page* (not via in-funnel navigation), it restores there
783
+ * only if `guard` passes against the restored snapshot — otherwise it falls back
784
+ * to the start page. e.g. an upsell page: `guard: s => s.data.purchased === true`.
785
+ * Reaching the page via `next()` is unaffected (routing already gated it). Omit =
786
+ * always restorable. Same gate kind as routing (`Condition` or predicate).
787
+ */
788
+ guard?: Gate;
789
+ }
790
+ /**
791
+ * Identity helper for a page's co-located metadata.
792
+ * `export const meta = pageMeta({ type: 'paywall', next: s => … })`.
793
+ */
794
+ declare function pageMeta(meta: PageMeta): PageMeta;
795
+ /**
796
+ * The effective entry guard for a page: an explicit `meta.guard` if the author set
797
+ * one, **otherwise** the page-type default (e.g. paywall/upsell need email). The
798
+ * explicit guard fully **overrides** the default — it's not combined. Returns
799
+ * `undefined` when neither applies.
800
+ */
801
+ declare function entryGuard(meta?: PageMeta): Gate | undefined;
802
+ /**
803
+ * Identity helper for a page component. The page is plain React that reaches the
804
+ * runtime through hooks (`useResponse`, `useNavigation`, …) — there is no `sdk`
805
+ * prop. `export default definePage(function Welcome() { … })`.
806
+ */
807
+ declare function definePage<P extends object = Record<string, never>>(component: ComponentType<P>): ComponentType<P>;
808
+ /** A page in the flow: its key + (optional) co-located meta. */
809
+ interface FlowPage {
810
+ key: string;
811
+ meta?: PageMeta;
812
+ }
813
+ /**
814
+ * Decide the next page from `currentKey`:
815
+ * 1. the current page's `next` routes ({@link resolveRoute}), if one matches, else
816
+ * 2. the next page in file order (the default linear flow), else
817
+ * 3. `undefined` (end of funnel / current key not found).
818
+ */
819
+ declare function nextPage(pages: FlowPage[], currentKey: string, s: FunnelSnapshot): string | undefined;
820
+ /**
821
+ * Every page a visitor *could* go to next from `currentKey` — all route targets
822
+ * (regardless of gate, since we don't know which will pass) plus the linear next.
823
+ * Used to **prefetch** the likely-next page chunks; not for navigation.
824
+ */
825
+ declare function outgoingKeys(pages: FlowPage[], currentKey: string): string[];
826
+ /**
827
+ * The number of pages on the path from `startKey` to a `finish` (or the end of
828
+ * the flow) **given the current state** — the denominator for progress. It walks
829
+ * the actual routing ({@link nextPage}) rather than counting all files, so a
830
+ * branched funnel reaches 100% on whichever path the visitor's answers select.
831
+ * Re-traced per navigation; a visited set guards against routing cycles.
832
+ */
833
+ declare function expectedPathLength(pages: FlowPage[], startKey: string, s: FunnelSnapshot): number;
834
+ interface FunnelLocales {
835
+ default: string;
836
+ supported: string[];
837
+ fallback?: string;
838
+ }
839
+ /**
840
+ * The funnel-level spine. Flow is mostly inferred from page file order + each
841
+ * page's `next`; this holds the funnel-wide config. Stays **thin** — routing
842
+ * lives co-located on pages (doc 06: "funnel.ts stays thin").
136
843
  */
137
- declare function useQueryParam(key: string): string;
844
+ interface FunnelDefinition {
845
+ id: string;
846
+ /** `responses.*` runtime variables (default values). */
847
+ responses?: Record<string, VariableConfig>;
848
+ /** `data.*` working/scratch variables (default values). */
849
+ data?: Record<string, VariableConfig>;
850
+ /** Product ids from the platform catalog this funnel uses (prices resolved at render). */
851
+ products?: string[];
852
+ locales?: FunnelLocales;
853
+ /**
854
+ * When `true`, the funnel shows the **geo-specific** currency/price the payment
855
+ * provider resolves (Stripe `currency_options`, Paddle preview, Whop, SolidGate,
856
+ * …). When `false` (default), a single fixed currency is used. AppFunnel does no
857
+ * FX — this only toggles whether the platform asks the provider for the geo
858
+ * price; the SDK just displays whatever resolved `{amount, currency}` it's handed.
859
+ * (See docs/platform-v2 phase-3 §3.6.)
860
+ */
861
+ locationAwareCurrency?: boolean;
862
+ }
863
+ /** Identity helper for a funnel definition. */
864
+ declare function defineFunnel(def: FunnelDefinition): FunnelDefinition;
138
865
 
139
866
  /**
140
- * Read/write a single data variable (data.*).
867
+ * v2 localization translated **text** (prices already localize via `Intl` in
868
+ * {@link ./catalog}). Per doc 05: locales live in the funnel source — supported
869
+ * locales + default + fallback are funnel config ({@link FunnelLocales}), and
870
+ * each locale is a flat `key → string` **catalog** (`messages/en.json`, …),
871
+ * versioned + published with the funnel. The dashboard's manual translation
872
+ * table edits exactly these catalogs.
141
873
  *
142
- * ```tsx
143
- * const [tier, setTier] = useData<string>('selectedPlanTier')
144
- * const [seen, setSeen] = useData<boolean>('hasSeenOnboarding')
145
- * ```
874
+ * Lookup walks a **fallback chain** (active → fallback → default) so a missing
875
+ * string never blanks — it falls through, and only as a last resort returns the
876
+ * key (flagged in dev). Interpolation is simple `{{var}}`; plurals use the
877
+ * built-in `Intl.PluralRules`; numbers/dates/percent use `Intl`. No ICU, no rich
878
+ * markup (markup stays in TSX).
146
879
  */
147
- declare function useData<T extends VariableValue>(key: string): [T, (value: T) => void];
148
880
 
881
+ /** locale → (message key → string). The shape of `messages/<locale>.json`. */
882
+ type MessageCatalog = Record<string, Record<string, string>>;
883
+ type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
884
+ type Direction = 'ltr' | 'rtl';
885
+ /** Whether a locale is right-to-left (by language subtag). */
886
+ declare function isRtl(locale: string): boolean;
149
887
  /**
150
- * Get the user's locale from the browser.
151
- *
152
- * Provides language, region, and currency info derived from the browser.
153
- * Combine with i18n libraries (react-i18next, next-intl, etc.) for translations.
154
- *
155
- * ```tsx
156
- * const { language, region, currency } = useLocale()
157
- * // language: 'en', region: 'US', currency: 'USD'
158
- * ```
888
+ * Resolve the active locale: `override` (URL/param) → `detected` (device) →
889
+ * funnel default, each only if supported (matched by exact tag or language
890
+ * subtag). Mirrors doc 05's resolution chain (the geo/header steps happen
891
+ * server-side and arrive via `override`/`detected`).
892
+ */
893
+ declare function resolveLocale(config: FunnelLocales | undefined, detected: string, override?: string): string;
894
+ interface TFunction {
895
+ /** Translate a key, with optional `{{var}}` params. Missing → fallback → key. */
896
+ (key: string, params?: Record<string, string | number>): string;
897
+ /** Locale-correct plural: `t.plural(n, { one: '# day', other: '# days' })` (`#` → n). */
898
+ plural: (n: number, forms: Partial<Record<PluralCategory, string>>, params?: Record<string, string | number>) => string;
899
+ }
900
+ interface Formatters {
901
+ number: (n: number, opts?: Intl.NumberFormatOptions) => string;
902
+ date: (d: Date | number, opts?: Intl.DateTimeFormatOptions) => string;
903
+ percent: (n: number, opts?: Intl.NumberFormatOptions) => string;
904
+ }
905
+ interface Translation {
906
+ t: TFunction;
907
+ fmt: Formatters;
908
+ /** The active (resolved) locale. */
909
+ locale: string;
910
+ /** Switch locale at runtime (preview / language picker). */
911
+ setLocale: (locale: string) => void;
912
+ /** Text direction for the active locale. */
913
+ dir: Direction;
914
+ /** The funnel's supported locales. */
915
+ locales: string[];
916
+ }
917
+ /**
918
+ * `const { t, fmt, locale, dir } = useTranslation()` — translated text +
919
+ * locale-aware `Intl` formatters for the active funnel locale.
159
920
  */
160
- declare function useLocale(): LocaleState;
921
+ declare function useTranslation(): Translation;
161
922
 
162
923
  /**
163
- * Access translations for the current locale.
924
+ * Toast a thin convenience re-export of {@link https://sonner.emilkowal.ski | sonner}.
164
925
  *
165
- * ```tsx
166
- * const { t, locale, setLocale } = useTranslation()
926
+ * Authors call `toast.success(...)` / `toast.error(...)` anywhere in their funnel.
927
+ * `FunnelProvider` auto-mounts {@link FunnelToaster} (opt out with `toaster={false}`),
928
+ * so toasts work with no setup; render it yourself if you want a custom location.
167
929
  *
168
- * return (
169
- * <div>
170
- * <h1>{t('welcome', { name: 'John' })}</h1>
171
- * <button onClick={() => setLocale('de')}>Deutsch</button>
172
- * </div>
173
- * )
174
- * ```
930
+ * This is the *one* opinionated UI we re-export — kept deliberately thin (just the
931
+ * imperative `toast` + a defaulted `<Toaster>`), because error/info surfacing is a
932
+ * near-universal funnel need and sonner is tiny. Everything else stays headless.
175
933
  */
176
- declare function useTranslation(): TranslationState;
934
+
935
+ /** Sonner's `<Toaster>` with funnel defaults (top-center); pass any `ToasterProps` to override. */
936
+ declare function FunnelToaster(props?: ToasterProps): ReactNode;
177
937
 
178
938
  /**
179
- * Navigation hookevaluates routes, navigates between pages, tracks progress.
939
+ * **Runtime page-level experiments** A/B/n on a *single page inside one funnel*,
940
+ * resolved at render time (phase-4). No funnel duplication: a slot (e.g. the page
941
+ * `welcome`) has alternate versions filed as siblings (`welcome@b.tsx`), and the
942
+ * runtime shows **one** of them per visitor, collapsing the losers out of the
943
+ * linear flow. The flow keeps stable **slot keys** (the control's key); only the
944
+ * rendered component swaps — so routing, the manifest, and analytics all key on the
945
+ * slot, not the variant.
946
+ *
947
+ * **Where the wiring lives:** the *variant pages* are code (compiled into the
948
+ * bundle). The *experiment wiring* — slot, variant→page map, weights, status — is
949
+ * **operational data the platform owns** (a DB record, edited by the dashboard,
950
+ * changed without recompiling the funnel). The SDK is storage-agnostic: the
951
+ * platform hands the runtime experiment records to `FunnelProvider`, the same way
952
+ * it hands in resolved product prices. This module just resolves them.
953
+ *
954
+ * **The only source-side marker** is the `@` in a variant file's key
955
+ * (`welcome@b`): it tells the *build* (manifest, flow graph) that a page is an
956
+ * off-flow variant of its slot, so the structure is correct without the DB. The
957
+ * label/weight/experiment-id are not in source.
180
958
  *
181
- * Only re-renders when the router's current page changes (not on variable changes).
182
- * Variables are read lazily at navigation time callbacks are referentially stable.
959
+ * Bucketing is a deterministic FNV-1a hash, **namespaced by experiment id** so
960
+ * independent experiments bucket orthogonally (the basis for layering 100+ tests,
961
+ * §4.3). The seed is the durable identity ({@link bucketingSeed}) — never the
962
+ * session/fingerprint — so a returning visitor keeps their variant (landmine #4).
963
+ * Stability across an *add-a-variant* edit is the platform's job: an experiment's
964
+ * variant set is immutable while running (reweighting / adding an arm = a new
965
+ * experiment version), so within one record the assignment never moves.
966
+ *
967
+ * This module is pure (no React, no I/O) → it unit-tests trivially and the
968
+ * dashboard/server can reuse the same assignment math.
183
969
  */
184
- declare function useNavigation(): NavigationState;
185
970
 
186
971
  /**
187
- * Products hook access product list, selected product, and selection handler.
188
- *
189
- * Only re-renders when `products.selectedProductId` changes.
972
+ * FNV-1a 32-bit hash unsigned int. Deterministic and isomorphic (no `crypto`,
973
+ * runs the same in the browser, a worker, and a build). The same math v0 used,
974
+ * kept because it's sound — only the *seed* changes (identity, not session).
190
975
  */
191
- declare function useProducts(): ProductsState;
192
-
976
+ declare function fnv1a(input: string): number;
977
+ /** Map a seed string to a stable float in `[0, 1)`. */
978
+ declare function hashToUnit(seed: string): number;
193
979
  /**
194
- * Tracking hook fire custom events.
980
+ * Pick a variant key by weight from a stable unit position `u ∈ [0,1)`. Variants
981
+ * are walked in declaration order and the first whose cumulative weight band
982
+ * contains `u` wins, so the same `u` always lands in the same variant.
195
983
  */
196
- declare function useTracking(): TrackingState;
197
-
984
+ declare function pickByWeight(weights: Record<string, number>, u: number): string;
985
+ /**
986
+ * Deterministically assign a visitor (`seed`) to a variant of one experiment.
987
+ * The hash is **namespaced by `experimentId`** so two experiments on the same
988
+ * traffic don't correlate (orthogonal layering).
989
+ */
990
+ declare function assignVariant(experimentId: string, seed: string, weights: Record<string, number>): string;
991
+ /**
992
+ * The stable bucketing seed: the known `customerId`, else the signed `visitorId`.
993
+ * Never the session id or fingerprint (landmine #4). `null` when neither is known
994
+ * — the caller then serves control instead of bucketing on nothing.
995
+ */
996
+ declare function bucketingSeed(context: FunnelContext): string | null;
997
+ /** Split a page key into its slot + optional variant: `welcome@b` → `{ slot, variant: 'b' }`. */
998
+ declare function parseSlotKey(key: string): {
999
+ slot: string;
1000
+ variant?: string;
1001
+ };
1002
+ /** True if a page key is an off-flow variant (`welcome@b`), by the source-side `@` marker. */
1003
+ declare function isVariantKey(key: string): boolean;
1004
+ /** One arm of an experiment: which page renders, at what weight. */
1005
+ interface ExperimentVariant {
1006
+ /** The page key this arm renders (the slot's control, or a `@variant` sibling). */
1007
+ page: string;
1008
+ /** Relative weight in the split (need not sum to 100; zero = paused arm). */
1009
+ weight: number;
1010
+ }
1011
+ /**
1012
+ * A runtime experiment record — the operational wiring the **platform** owns and
1013
+ * hands to the SDK (not authored in the funnel source). The variant *pages* are
1014
+ * code in the bundle; this is the live config over them.
1015
+ */
1016
+ interface RuntimeExperiment {
1017
+ id: string;
1018
+ /** The page key holding the flow position (the anchor / control). */
1019
+ slot: string;
1020
+ /** label → arm. One arm's `page` is the `slot` (control); others are `@variant` siblings. */
1021
+ variants: Record<string, ExperimentVariant>;
1022
+ /** Lifecycle. Absent = running. */
1023
+ status?: 'running' | 'paused' | 'stopped';
1024
+ /** When stopped, the variant label everyone sees until promotion bakes it into the slot. */
1025
+ winner?: string;
1026
+ /** Primary metric (analytics only; not used for resolution). */
1027
+ metric?: string;
1028
+ }
1029
+ interface ExperimentResolution {
1030
+ /** experiment id → assigned variant label. */
1031
+ assignments: Record<string, string>;
1032
+ /** Page keys forming the effective linear flow (variant siblings removed), in order. */
1033
+ activeKeys: string[];
1034
+ /** Slot key → the page key whose component should render there. */
1035
+ render: Record<string, string>;
1036
+ /** Variant page key → its slot key. For remapping a route that targets a variant. */
1037
+ slotOf: Record<string, string>;
1038
+ /** Slot key → the experiment id it hosts (drives the exposure event). */
1039
+ experimentOf: Record<string, string>;
1040
+ }
1041
+ /**
1042
+ * Resolve which page version each experiment slot shows for this visitor, and
1043
+ * which page keys remain in the linear flow.
1044
+ *
1045
+ * Variant pages are detected structurally by the `@` in their key, so they
1046
+ * collapse out of the linear flow even for a slot with no active experiment.
1047
+ * For each *running* experiment the visitor is bucketed (stable on `seed`); a
1048
+ * *stopped* one serves its `winner`; a *paused* one (or no seed) serves the slot.
1049
+ */
1050
+ declare function resolveExperiments(pages: {
1051
+ key: string;
1052
+ }[], experiments: RuntimeExperiment[], seed: string | null): ExperimentResolution;
1053
+ /** One referential problem with an experiment record. */
1054
+ interface ExperimentIssue {
1055
+ /** The offending experiment id (`'*'` for a cross-experiment issue). */
1056
+ experimentId: string;
1057
+ code: 'slot_missing' | 'slot_is_variant' | 'slot_conflict' | 'duplicate_id' | 'variant_page_missing' | 'variant_slot_mismatch' | 'no_control' | 'no_traffic' | 'negative_weight' | 'bad_winner' | 'single_arm' | 'orphan_variant';
1058
+ message: string;
1059
+ }
1060
+ interface ExperimentValidation {
1061
+ ok: boolean;
1062
+ errors: ExperimentIssue[];
1063
+ warnings: ExperimentIssue[];
1064
+ }
198
1065
  /**
199
- * Payment hook reads payment-related variables and provides a purchase action.
1066
+ * Cross-check experiment records against the funnel's pages the publish-time /
1067
+ * experiment-edit gate that catches the dangling references the runtime can't see
1068
+ * (the experiment wiring lives in the DB; the pages live in the build, so they can
1069
+ * drift). Pure: hand it the runtime experiments + the funnel's page keys (e.g.
1070
+ * `manifest.pages`).
200
1071
  *
201
- * Only re-renders when payment/card variables change.
1072
+ * Errors block; warnings inform. `orphan_variant` is a warning, not an error — a
1073
+ * `@variant` file with no running experiment is dead-but-harmless (a paused test, or
1074
+ * one not wired yet).
202
1075
  */
203
- declare function usePayment(): PaymentState;
1076
+ declare function validateExperiments(experiments: RuntimeExperiment[], pages: {
1077
+ key: string;
1078
+ }[]): ExperimentValidation;
204
1079
 
205
1080
  /**
206
- * Read device, browser, and OS metadata.
1081
+ * v2 namespace layer over the flat {@link VariableStore}.
1082
+ *
1083
+ * **The public surface is hooks, not a god object.** Reactive reads use
1084
+ * `useState`-shaped tuple hooks (`const [goal, setGoal] = useResponse('goal')`);
1085
+ * read-only computed values use `useDevice()`/`useLocale()`/`usePage()`/`useUtm()`.
1086
+ * `useFunnel()` returns an **imperative, non-subscribing** handle for use in
1087
+ * handlers/effects only — it is not the documented primary API and carries no
1088
+ * `sdk` branding.
207
1089
  *
208
- * These values are set once on mount and don't change, so this hook
209
- * will rarely (if ever) re-render.
1090
+ * Three writable namespaces (`user`/`responses`/`data`) + the read-only
1091
+ * `context`. Entitlements are **not** here — they're the separate app/IdentitySDK.
210
1092
  *
211
- * ```tsx
212
- * const { device, browser, os } = useDeviceInfo()
213
- * if (device.isMobile) { ... }
214
- * ```
1093
+ * All writes route through {@link Namespace.set}, so persistence + identify fire
1094
+ * from one place (the "magic behind set()", doc 07 §Layer 1).
1095
+ */
1096
+ /** A typed view over one writable namespace's slice of the store. */
1097
+ declare class Namespace<T extends Record<string, VariableValue> = Record<string, VariableValue>> {
1098
+ private readonly store;
1099
+ readonly name: WritableNamespace;
1100
+ /** Side-effect run after every write (persistence, identify). */
1101
+ private readonly onWrite;
1102
+ private readonly dot;
1103
+ constructor(store: VariableStore, name: WritableNamespace,
1104
+ /** Side-effect run after every write (persistence, identify). */
1105
+ onWrite: (ns: WritableNamespace, values: Partial<T>) => void);
1106
+ /** Fully-qualified store key for a namespace-relative key. */
1107
+ storeKey(key: string): string;
1108
+ /** The store-key prefix this namespace reads/writes (e.g. `user.`). */
1109
+ get prefix(): string;
1110
+ /** Read the whole namespace as a flat object (prefix stripped). */
1111
+ getAll(): T;
1112
+ /** Read one key, or the whole namespace when called with no argument. */
1113
+ get<K extends keyof T & string>(key: K): T[K];
1114
+ get(): T;
1115
+ /** Write one or more keys from a plain object (batched + side-effected). */
1116
+ set(values: Partial<T>): void;
1117
+ }
1118
+ /**
1119
+ * The imperative funnel handle (`useFunnel()`). Non-reactive — for event
1120
+ * handlers and effects. Reactive render reads go through the hooks instead.
215
1121
  */
216
- declare function useDeviceInfo(): DeviceInfo;
1122
+ interface Funnel {
1123
+ user: Namespace;
1124
+ responses: Namespace;
1125
+ data: Namespace;
1126
+ /** Read-only computed namespace. `page` reflects the latest navigation. */
1127
+ readonly context: FunnelContext;
1128
+ /** Live page-level experiment records (platform/DB-sourced); the runtime buckets visitors into them. */
1129
+ readonly experiments: RuntimeExperiment[];
1130
+ /** Emit a custom analytics event. */
1131
+ track: Tracker['track'];
1132
+ /** A point-in-time snapshot for predicates (routing/conditions). */
1133
+ snapshot(): FunnelSnapshot;
1134
+ }
1135
+ interface FunnelProviderProps {
1136
+ /** Mount from a funnel definition (the normal case). */
1137
+ config?: FunnelDefinition;
1138
+ /** Server / session-restored values overriding declared defaults. */
1139
+ sessionValues?: Record<string, VariableValue>;
1140
+ /** The platform tracker; defaults to a silent console tracker (tests/dev). */
1141
+ tracker?: Tracker;
1142
+ /** Computed-context seed (funnel id, test/live mode, server-hydrated utm/session). */
1143
+ context?: Parameters<typeof buildContext>[0];
1144
+ /** Products with their provider-resolved price (`{ amount, currency }`). */
1145
+ products?: ProductInput[];
1146
+ /** Live page-level experiment records, platform/DB-sourced (not authored in funnel code). */
1147
+ experiments?: RuntimeExperiment[];
1148
+ /** The platform checkout driver; defaults to a mock that resolves successfully. */
1149
+ checkout?: CheckoutDriver;
1150
+ /** Message catalogs per locale (`messages/<locale>.json`) for translation. */
1151
+ messages?: MessageCatalog;
1152
+ /** Explicit locale override (URL/param/geo); wins over the detected device locale. */
1153
+ locale?: string;
1154
+ /** Auto-mount the toast `<Toaster>` (default true). Pass props to customize, or `false` to opt out. */
1155
+ toaster?: boolean | ToasterProps;
1156
+ /** Advanced / testing: supply a pre-built store instead of a `config`. */
1157
+ store?: VariableStore;
1158
+ children: ReactNode;
1159
+ }
1160
+ /**
1161
+ * Mounts a funnel: builds the writable {@link VariableStore} + the read-only
1162
+ * {@link FunnelContext} (once) and provides the runtime to the tree via hooks.
1163
+ */
1164
+ declare function FunnelProvider({ config, sessionValues, tracker, context: contextOpts, products, experiments, checkout, messages, locale: localeOverride, toaster, store, children, }: FunnelProviderProps): ReactNode;
1165
+ /**
1166
+ * The imperative handle. Does **not** subscribe — use it in event handlers and
1167
+ * effects (`useFunnel().responses.set({...})`). For reactive reads use the hooks.
1168
+ */
1169
+ declare function useFunnel(): Funnel;
1170
+ /**
1171
+ * `const [goal, setGoal] = useResponse('goal')` — one funnel answer,
1172
+ * `useState`-shaped and per-key reactive. Writing persists + (later) feeds
1173
+ * analytics aggregation.
1174
+ */
1175
+ declare const useResponse: (key: string) => [VariableValue, (value: VariableValue) => void];
1176
+ /**
1177
+ * `const [height, setHeight] = useUserAttribute('height')` — any field on the
1178
+ * user. The platform routes known fields → typed column, custom → attribute;
1179
+ * the author doesn't specify which. Writing `email` also identifies the visitor.
1180
+ */
1181
+ declare const useUserAttribute: (key: string) => [VariableValue, (value: VariableValue) => void];
1182
+ /**
1183
+ * `const [score, setScore] = useData('score')` — working/scratch state. Persisted
1184
+ * (survives reload/return) but kept out of `responses` aggregation.
1185
+ */
1186
+ declare const useData: (key: string) => [VariableValue, (value: VariableValue) => void];
1187
+ /**
1188
+ * The headless field hook — `useField('responses.goal')` — the building block
1189
+ * the styleable components (`<Choice>`) wrap. Takes a namespaced path and
1190
+ * returns `{ value, set }`. Accepts only writable namespaces.
1191
+ */
1192
+ declare function useField(path: string): {
1193
+ value: VariableValue;
1194
+ set: (value: VariableValue) => void;
1195
+ };
1196
+ /** `useDevice()` — viewport, device type, pixel ratio (read-only). */
1197
+ declare function useDevice(): FunnelContext['device'];
1198
+ /** `useLocale()` — resolved locale/language/region/timezone (read-only). */
1199
+ declare function useLocale(): FunnelContext['locale'];
1200
+ /** `useUtm()` — the 5 utm params captured on entry (read-only). */
1201
+ declare function useUtm(): FunnelContext['utm'];
1202
+ /** `useClickIds()` — per-network ad click-ids captured on entry (read-only). */
1203
+ declare function useClickIds(): FunnelContext['clickIds'];
1204
+ /** `useSystem()` — funnel id, test/live mode, build clock (read-only). */
1205
+ declare function useSystem(): FunnelContext['system'];
1206
+ /** Read a dotted path out of context, e.g. `useContextValue('device.isMobile')`. */
1207
+ declare function useContextValue(path: string): unknown;
217
1208
 
218
- interface SafeAreaInsets {
219
- top: number;
220
- right: number;
221
- bottom: number;
222
- left: number;
1209
+ /** What a page's `load` resolves to — a component, or a module default-exporting one. */
1210
+ type PageModule = ComponentType | {
1211
+ default: ComponentType;
1212
+ };
1213
+ /**
1214
+ * A page wired into the runtime. Provide **either** an eager `Component` (small
1215
+ * funnels, tests) **or** a lazy `load` (code-split: the renderer hands a dynamic
1216
+ * `import()` so a big funnel ships one chunk per page, not the whole funnel).
1217
+ */
1218
+ interface RuntimePage {
1219
+ key: string;
1220
+ meta?: PageMeta;
1221
+ /** Eager component. Provide this or `load`. */
1222
+ Component?: ComponentType;
1223
+ /** Lazy loader for a code-split chunk. Provide this or `Component`. */
1224
+ load?: () => Promise<PageModule>;
1225
+ }
1226
+ interface NavigationState {
1227
+ /** The current page's read-only context (key, index, total, progress). */
1228
+ page: PageContext;
1229
+ /** Advance via the current page's `next` predicate, else the linear next. */
1230
+ next: () => void;
1231
+ /** Go to the previous page in history. */
1232
+ back: () => void;
1233
+ /** Jump to a specific page key. */
1234
+ go: (key: string) => void;
1235
+ /** Warm a page's code-split chunk ahead of navigation (e.g. on link hover). No-op for eager pages. */
1236
+ prefetch: (key: string) => void;
1237
+ /**
1238
+ * The page keys the visitor could go to next, already resolved to the chunk that
1239
+ * would render (experiment variant applied). The **renderer** maps these to chunk
1240
+ * URLs and emits `<link rel="modulepreload">` (the browser-native, non-executing,
1241
+ * SSR-able prefetch — see v0 `HeadlessFunnelRenderer`). The SDK's `load()`-based
1242
+ * `prefetch` is the fallback for mounts without a modulepreload manager.
1243
+ */
1244
+ nextCandidates: string[];
1245
+ canGoBack: boolean;
223
1246
  }
224
1247
  /**
225
- * Returns the current safe-area insets (in px) from `env(safe-area-inset-*)`.
1248
+ * `const variant = useExperiment('welcome-headline')` the variant key this
1249
+ * visitor is assigned in an experiment (`undefined` if not in one). Page-variant
1250
+ * swapping is automatic; this is for branching *within* a page or custom logging.
1251
+ */
1252
+ declare function useExperiment(id: string): string | undefined;
1253
+ /**
1254
+ * Renders the funnel: shows the current page and drives navigation. `next()`
1255
+ * resolves the current page's `next` predicate against the live snapshot
1256
+ * (writable namespaces + read-only `context`), falling back to the linear next
1257
+ * page in file order (see {@link nextPage}).
1258
+ *
1259
+ * Auto-emits `funnel.start` on mount and `page.view` on each page change, and
1260
+ * keeps `context.page` in sync so predicates and `usePage()` see the live page.
226
1261
  *
227
- * Useful for positioning fixed elements above the Safari home-indicator bar.
1262
+ * Pass a `layout` (a `{ children }` component) for **persistent chrome** — a
1263
+ * header, progress bar, background — rendered *inside* the nav context but
1264
+ * *outside* the page swap, so it mounts once and never remounts on navigation
1265
+ * (and can read `usePage`/`useNavigation`).
228
1266
  *
229
- * ```tsx
230
- * const { bottom } = useSafeArea()
231
- * <div style={{ position: 'fixed', bottom: 0, paddingBottom: bottom }} />
232
- * ```
1267
+ * Must live inside `<FunnelProvider>`.
233
1268
  */
234
- declare function useSafeArea(): SafeAreaInsets;
1269
+ declare function FunnelView({ pages, initialKey, layout, fallback, prefetch: prefetchMode, onNavigate, }: {
1270
+ pages: RuntimePage[];
1271
+ initialKey?: string;
1272
+ layout?: ComponentType<{
1273
+ children: ReactNode;
1274
+ }>;
1275
+ /** Shown in the page area while a lazy page's chunk loads (the layout persists around it). */
1276
+ fallback?: ReactNode;
1277
+ /**
1278
+ * `'auto'` (default): the SDK warms next-likely chunks by calling `load()`.
1279
+ * `'off'`: the **renderer** owns prefetch (via `<link rel="modulepreload">` over
1280
+ * `useNavigation().nextCandidates`) — avoids the redundant `load()` execution.
1281
+ */
1282
+ prefetch?: 'auto' | 'off';
1283
+ /**
1284
+ * Fires on every page change (incl. the initial page) with the current page key
1285
+ * + slug. The **renderer** uses it to sync the URL path (`history.replaceState`,
1286
+ * v0-style — funnel back is in-memory, not browser history). Fires for the
1287
+ * *resolved* page, so if an entry `guard` bounced a deep-link to the start, the
1288
+ * URL gets corrected.
1289
+ */
1290
+ onNavigate?: (key: string, slug: string) => void;
1291
+ }): ReactNode;
1292
+ /** `const { next, back, go, page } = useNavigation()`. */
1293
+ declare function useNavigation(): NavigationState;
1294
+ /** `usePage()` — the current page's read-only context, reactive to navigation. */
1295
+ declare function usePage(): PageContext;
235
1296
 
236
- interface KeyboardState {
237
- /** Whether the virtual keyboard is currently visible */
238
- isOpen: boolean;
239
- /** Height of the keyboard in pixels (0 when closed) */
240
- height: number;
1297
+ /**
1298
+ * The two-tier component model (doc 07 §"magic ≠ black box"): the magic lives in
1299
+ * headless hooks (`useField`, `useNavigation`); these styleable components are
1300
+ * thin, restyle-able wrappers over them. Every `<Choice>` reduces to `useField`
1301
+ * + your own JSX — eject any time. They pass `className`/DOM props straight
1302
+ * through and never dictate a pixel beyond the minimum.
1303
+ */
1304
+ interface ChoiceOption {
1305
+ value: string;
1306
+ label?: ReactNode;
1307
+ }
1308
+ interface ChoiceProps {
1309
+ /** Writable namespaced path to bind, e.g. `responses.goal`. */
1310
+ bind: string;
1311
+ /** The selectable options (a bare string is shorthand for `{value, label}`). */
1312
+ options: (string | ChoiceOption)[];
1313
+ /** Class applied to each option button. */
1314
+ className?: string;
1315
+ /** Class applied to the selected option button (in addition to `className`). */
1316
+ selectedClassName?: string;
1317
+ /** Advance navigation after a choice (handy for single-tap question pages). */
1318
+ advanceOnSelect?: boolean;
1319
+ }
1320
+ /**
1321
+ * Single-select bound to a writable field. `<Choice bind="responses.goal"
1322
+ * options={["lose", "gain"]} />` — selecting persists + (later) tracks; pass
1323
+ * `advanceOnSelect` to move to the next page on tap.
1324
+ */
1325
+ declare function Choice({ bind, options, className, selectedClassName, advanceOnSelect, }: ChoiceProps): ReactNode;
1326
+ interface NextProps extends ButtonHTMLAttributes<HTMLButtonElement> {
1327
+ children?: ReactNode;
1328
+ }
1329
+ /** A button that advances the funnel (the current page's `next`, else linear). */
1330
+ declare function Next({ children, onClick, ...rest }: NextProps): ReactNode;
1331
+ interface BackProps extends ButtonHTMLAttributes<HTMLButtonElement> {
1332
+ children?: ReactNode;
241
1333
  }
1334
+ /** A button that returns to the previous page; hidden when there's no history. */
1335
+ declare function Back({ children, onClick, ...rest }: BackProps): ReactNode;
1336
+
242
1337
  /**
243
- * Detects the virtual keyboard on mobile devices.
1338
+ * Imperative, promise-based modal registry our trimmed adaptation of
1339
+ * eBay's `@ebay/nice-modal-react` (MIT). The model: a module-level registry +
1340
+ * a reducer-backed store, so any code can `showModal(MyModal)` from anywhere
1341
+ * (no prop-drilling, no rendering the modal where you trigger it) and get back a
1342
+ * **promise** that resolves when the modal calls `resolve()` — perfect for
1343
+ * confirm/upsell/detail flows.
244
1344
  *
245
- * Uses the VirtualKeyboard API where available, with a
246
- * visualViewport fallback for Safari/older browsers.
1345
+ * Every modal is keyed by a **stable id**, which also makes it natural to bind
1346
+ * analytics later (open/close per id, like pages).
247
1347
  *
248
- * The visualViewport path follows the approach described at
249
- * https://martijnhols.nl/blog/how-to-get-document-height-ios-safari-osk
250
- * to handle iOS Safari's delayed viewport updates on keyboard close.
1348
+ * Pair it with the controlled chrome in {@link ./overlay}: a `defineModal`
1349
+ * component renders a `<Sheet open={m.visible} onClose={m.hide}>`/`<Modal>`.
1350
+ * The runtime ({@link ./namespaces}.FunnelProvider) mounts {@link ModalRoot} for
1351
+ * you, so there's no provider to wire.
251
1352
  *
252
- * ```tsx
253
- * const { isOpen, height } = useKeyboard()
254
- * <div style={{ position: 'fixed', bottom: isOpen ? height : 0 }} />
255
- * ```
1353
+ * Trimmed vs upstream: dropped the antd/mui/bootstrap binding adapters and the
1354
+ * standalone `<Provider>` (folded into the funnel runtime).
256
1355
  */
257
- declare function useKeyboard(): KeyboardState;
258
1356
 
1357
+ interface ModalState {
1358
+ id: string;
1359
+ args?: Record<string, unknown>;
1360
+ visible?: boolean;
1361
+ delayVisible?: boolean;
1362
+ keepMounted?: boolean;
1363
+ }
1364
+ /** The handle `useModal()` returns to control one modal. */
1365
+ interface ModalHandler<Props = Record<string, unknown>> {
1366
+ id: string;
1367
+ args?: Record<string, unknown>;
1368
+ visible: boolean;
1369
+ keepMounted: boolean;
1370
+ show: (args?: Props) => Promise<unknown>;
1371
+ hide: () => Promise<unknown>;
1372
+ remove: () => void;
1373
+ /** Resolve the promise returned by `show()` (e.g. a confirm result). */
1374
+ resolve: (value?: unknown) => void;
1375
+ /** Reject the promise returned by `show()`. */
1376
+ reject: (value?: unknown) => void;
1377
+ /** Resolve the promise returned by `hide()` (after a close transition). */
1378
+ resolveHide: (value?: unknown) => void;
1379
+ }
1380
+ /** Register a component as an id-keyed modal so the placeholder can render it. */
1381
+ declare function registerModal(id: string, comp: ComponentType<any>, props?: Record<string, unknown>): void;
1382
+ declare function unregisterModal(id: string): void;
1383
+ /** Show a modal (by component or id). Returns a promise resolved by `resolve()`. */
1384
+ declare function showModal<T = unknown>(modal: string | ComponentType<any>, args?: Record<string, unknown>): Promise<T>;
1385
+ /** Hide a modal. Returns a promise resolved by `resolveHide()` (post-transition). */
1386
+ declare function hideModal<T = unknown>(modal: string | ComponentType<any>): Promise<T>;
1387
+ /** Remove a modal from the tree (frees its state; better than just hiding). */
1388
+ declare function removeModal(modal: string | ComponentType<any>): void;
1389
+ declare function useModal(): ModalHandler;
1390
+ declare function useModal(modal: string, args?: Record<string, unknown>): ModalHandler;
1391
+ declare function useModal<P extends object>(modal: ComponentType<P>, args?: Partial<P>): ModalHandler<P>;
1392
+ interface ModalHocProps {
1393
+ id: string;
1394
+ defaultVisible?: boolean;
1395
+ keepMounted?: boolean;
1396
+ }
259
1397
  /**
260
- * Read page-level system variables.
1398
+ * Wrap a component into an id-keyed modal. Inside it, `useModal()` (no args)
1399
+ * gives the handle. Render a controlled `<Sheet>`/`<Modal>` with
1400
+ * `open={m.visible} onClose={m.hide}`.
1401
+ */
1402
+ declare function defineModal<P extends object>(Comp: ComponentType<P>): ComponentType<P & ModalHocProps>;
1403
+
1404
+ /**
1405
+ * Raw, **controlled** overlay chrome — `<Sheet>` (a drawer) and `<Modal>` (a
1406
+ * centered dialog). Headless and styleable (className/portal/dismiss), the same
1407
+ * two-tier contract as `<Choice>`: thin wrappers you restyle or eject.
261
1408
  *
262
- * Re-renders when page variables change (e.g. on navigation).
1409
+ * These are the presentation layer. For *imperative*, promise-based modals
1410
+ * triggered from anywhere, wrap one in `defineModal` and `showModal(...)`
1411
+ * (see {@link ./modals}) — that registry drives these controlled primitives.
1412
+ * They're also used directly (no registry) where local state is simpler, e.g.
1413
+ * the checkout `sheet` surface.
263
1414
  *
264
- * ```tsx
265
- * const { currentId, progressPercentage, startedAt } = usePageData()
266
- * ```
1415
+ * Deliberately minimal: portal to `document.body`, lock body scroll while open,
1416
+ * dismiss on Escape / backdrop. No animation engine, no focus-trap dependency.
267
1417
  */
268
- declare function usePageData(): PageData;
269
1418
 
1419
+ type SheetSide = 'bottom' | 'top' | 'left' | 'right';
1420
+ interface OverlayCommon {
1421
+ className?: string;
1422
+ backdropClassName?: string;
1423
+ dismissOnBackdrop?: boolean;
1424
+ ariaLabel?: string;
1425
+ children: ReactNode;
1426
+ }
1427
+ type SheetProps = OverlayCommon & {
1428
+ /** Edge the drawer slides from (default `bottom`). */
1429
+ side?: SheetSide;
1430
+ };
270
1431
  /**
271
- * Convenience hook that combines all SDK hooks.
272
- * Useful for simple funnels where you don't want multiple hook imports.
1432
+ * An edge-anchored drawer. Inside a `defineModal` it auto-binds — `<Sheet>…`;
1433
+ * outside one, control it with `open`/`onClose`.
273
1434
  */
274
- declare function useFunnel(): FunnelState;
1435
+ declare function Sheet(props: SheetProps): ReactNode;
1436
+ type ModalProps = OverlayCommon;
1437
+ /** A centered dialog. Auto-binds to its `defineModal` — `<Modal>…`. */
1438
+ declare function Modal(props: ModalProps): ReactNode;
275
1439
 
276
- interface StripePaymentHandle {
277
- /** Programmatically submit the payment form */
278
- submit: () => Promise<void>;
1440
+ /**
1441
+ * The **`window.appfunnel` event bus** — the client-side integration surface
1442
+ * (port of v0 `admin/lib/funnel/utils/appfunnelRuntime.ts`).
1443
+ *
1444
+ * Every taxonomy event the runtime emits is mirrored here, so on-page pixels and
1445
+ * custom scripts integrate by **subscribing** — `window.appfunnel.on('purchase.complete', …)`
1446
+ * — instead of the funnel hand-injecting pixel config per page. The pre-built pixel
1447
+ * loaders (Meta/TikTok/GTM/Clarity) are just subscribers; CAPI stays the server-side
1448
+ * default (this is the *client* sink of the same events). See doc 08 §3.
1449
+ *
1450
+ * Two deliberate properties vs. v0:
1451
+ * - **Replay history** (`events`): async pixels load *after* `funnel.start`/`page.view`
1452
+ * fire, so a late subscriber would miss them. The bus keeps a bounded history a
1453
+ * subscriber can replay (the GTM `dataLayer` trick) — no lost events.
1454
+ * - **Subscribe + read only.** The window surface exposes `on`/`off`/`events` + read
1455
+ * getters; it has **no `emit`** (third-party code can't forge events) and **no
1456
+ * mutating actions** (`setVariable`/navigation) — the funnel can't be driven through
1457
+ * it. (v0's full-control Custom-JS API is deferred pending the bus-control-scope
1458
+ * decision; doc 08 §3 + §4.)
1459
+ */
1460
+
1461
+ /** Read accessors the bus exposes — wired to the funnel's store + context. */
1462
+ interface BusAccessors {
1463
+ getVariable: (key: string) => unknown;
1464
+ getVariables: () => Record<string, unknown>;
1465
+ getCurrentPageId: () => string | null;
1466
+ getCustomerId: () => string | null;
1467
+ getVisitorId: () => string | null;
279
1468
  }
280
- interface StripePaymentFormBaseProps {
281
- /** Product ID override (default: currently selected product) */
282
- productId?: string;
283
- /** "checkout" for full payment, "validate-only" to just collect card */
284
- mode?: 'checkout' | 'validate-only';
285
- /** Additional CSS class */
286
- className?: string;
287
- /** Stripe Appearance override */
288
- appearance?: Appearance;
289
- }
290
- interface StripePaymentFormElementsProps extends StripePaymentFormBaseProps {
291
- /** "elements" for PaymentElement, "embedded" for Stripe Embedded Checkout */
292
- variant?: 'elements';
293
- /** Page key to redirect to after successful embedded checkout */
294
- successPageKey?: string;
295
- /** Called on successful payment */
296
- onSuccess?: () => void;
297
- /** Called on payment error */
298
- onError?: (error: string) => void;
299
- /** Called when the Stripe form is ready to accept submissions */
300
- onReady?: () => void;
301
- /** PaymentElement layout option (default: 'tabs') */
302
- layout?: 'tabs' | 'accordion' | 'auto';
303
- automaticTax?: never;
304
- managedPayments?: never;
305
- allowPromotionCodes?: never;
306
- }
307
- interface StripePaymentFormEmbeddedProps extends StripePaymentFormBaseProps {
308
- /** "embedded" for Stripe Embedded Checkout */
309
- variant: 'embedded';
310
- /** Page key to redirect to after successful embedded checkout */
311
- successPageKey: string;
312
- /** Enable Stripe automatic tax calculation */
313
- automaticTax?: boolean;
314
- /** Enable Stripe managed payments — Stripe controls tax, payment methods, etc. (forces embedded mode) */
315
- managedPayments?: boolean;
316
- /** Allow promotion codes in checkout */
317
- allowPromotionCodes?: boolean;
318
- onSuccess?: never;
319
- onError?: never;
320
- onReady?: never;
321
- layout?: never;
322
- }
323
- type StripePaymentFormProps = StripePaymentFormElementsProps | StripePaymentFormEmbeddedProps;
324
- /**
325
- * Stripe payment component supporting PaymentElement and Embedded Checkout.
326
- *
327
- * In dev mode (`globalThis.__APPFUNNEL_DEV__`), renders a demo form that
328
- * simulates Stripe UI and emulates purchases without real Stripe calls.
329
- *
330
- * Use a ref to submit the form from an external button:
331
- * ```tsx
332
- * const paymentRef = useRef<StripePaymentHandle>(null)
333
- *
334
- * <StripePaymentForm ref={paymentRef} onSuccess={goToNextPage} />
335
- * <button onClick={() => paymentRef.current?.submit()}>Pay Now</button>
336
- * ```
337
- */
338
- declare const StripePaymentForm: react.ForwardRefExoticComponent<StripePaymentFormProps & react.RefAttributes<StripePaymentHandle>>;
339
-
340
- interface PaddleCheckoutProps {
341
- /** Product ID to checkout (default: currently selected product) */
342
- productId?: string;
343
- /** Display mode: overlay opens Paddle in a modal, inline renders embedded */
344
- mode?: 'overlay' | 'inline';
345
- /** Called on successful checkout */
346
- onSuccess?: () => void;
347
- /** Called on checkout error */
348
- onError?: (error: string) => void;
349
- /** Additional CSS class */
350
- className?: string;
1469
+ interface BusEvent {
1470
+ event: string;
1471
+ data: unknown;
1472
+ /** Epoch ms the event was emitted (for ordering / replay). */
1473
+ ts: number;
1474
+ }
1475
+ type Listener = (data: unknown) => void;
1476
+ /** The public `window.appfunnel` surface — subscribe + read, never emit or mutate. */
1477
+ interface AppFunnelBusApi extends BusAccessors {
1478
+ /** Subscribe to an event (or `'*'` for all). Returns an unsubscribe fn. */
1479
+ on(event: string, cb: Listener): () => void;
1480
+ off(event: string, cb: Listener): void;
1481
+ /** Bounded replay history — read past events, then `on()` for future ones. */
1482
+ readonly events: BusEvent[];
1483
+ }
1484
+ /**
1485
+ * The internal bus: the public {@link AppFunnelBusApi} plus `emit` (SDK-only) and
1486
+ * `now` injection. The SDK holds this; only {@link AppFunnelBusApi} reaches `window`.
1487
+ */
1488
+ interface FunnelBus extends AppFunnelBusApi {
1489
+ emit(event: string, data?: unknown): void;
351
1490
  }
1491
+ /** Create a bus over the given read accessors. `now` is injectable for tests. */
1492
+ declare function createBus(accessors: BusAccessors, now?: () => number): FunnelBus;
1493
+ /**
1494
+ * Wrap a {@link Tracker} so every emitted taxonomy event is mirrored to the bus
1495
+ * (the client sink). `identify()` also surfaces a `user.registered` to the bus, so
1496
+ * pixels see registration. The server sink (the inner tracker → CAPI) is untouched.
1497
+ */
1498
+ declare function withBus(tracker: Tracker, bus: FunnelBus): Tracker;
352
1499
  declare global {
353
1500
  interface Window {
354
- Paddle?: {
355
- Setup: (options: Record<string, unknown>) => void;
356
- Checkout: {
357
- open: (options: Record<string, unknown>) => void;
358
- };
359
- };
1501
+ appfunnel?: AppFunnelBusApi;
360
1502
  }
361
1503
  }
362
1504
  /**
363
- * Paddle checkout component.
364
- * Supports overlay (modal) and inline (embedded) modes.
1505
+ * Attach the bus's public surface to `window.appfunnel` (client-side only).
1506
+ * Returns a detach fn for unmount. No-op under SSR.
365
1507
  */
366
- declare function PaddleCheckout({ productId, mode, onSuccess, onError, className, }: PaddleCheckoutProps): react_jsx_runtime.JSX.Element | null;
1508
+ declare function attachBus(bus: FunnelBus): () => void;
367
1509
 
368
1510
  /**
369
- * Integration runtimesets up window.appfunnel event bus and initializes
370
- * third-party integrations (Meta Pixel, GTM, Clarity, etc.).
1511
+ * The **event catalog** the single source of truth for *which* events exist and
1512
+ * how each is routed (analytics vs. integrations vs. runtime-only). Pairs with the
1513
+ * typed payloads in {@link ./tracking}.`FunnelEventDataMap`.
371
1514
  *
372
- * The integration loaders from the admin codebase subscribe to events via
373
- * window.appfunnel.on(). The FunnelTracker emits events via _emitEvent().
374
- * This module bridges the two by setting up the pub/sub layer.
1515
+ * This replaces the duplication the monorepo flags (`shared/events/catalog.ts` is
1516
+ * "documentation, not imported" the taxonomy is re-declared in
1517
+ * `admin/lib/funnel/tracking.ts`, each `admin/lib/integrations/*` EVENT_MAP, and
1518
+ * `api/src/integrations/constants`). In v2 the **SDK owns the taxonomy** (it emits
1519
+ * it), so the bus, the pixel loaders, and the platform all import *this*.
1520
+ */
1521
+ type EventSource = 'frontend' | 'backend';
1522
+ interface EventMeta {
1523
+ /** Where the event originates. `backend` = the server emits it (e.g. a settled charge). */
1524
+ sources: EventSource[];
1525
+ /** Persisted to analytics. */
1526
+ tracking: boolean;
1527
+ /** Forwarded to ad/integration pixels (Meta/GTM/TikTok/…). */
1528
+ integrations: boolean;
1529
+ /** Runtime-only JS subscription — not persisted, not sent to integrations. */
1530
+ runtime?: boolean;
1531
+ }
1532
+ /**
1533
+ * Every event the system knows, with its routing metadata. Keys are the taxonomy
1534
+ * names (a superset of `FunnelEventDataMap`: it also lists the **backend-originated**
1535
+ * monetary events the platform pushes to the client bus for pixel firing).
375
1536
  */
1537
+ declare const EVENT_CATALOG: {
1538
+ readonly 'funnel.start': {
1539
+ readonly sources: ["frontend"];
1540
+ readonly tracking: false;
1541
+ readonly integrations: false;
1542
+ readonly runtime: true;
1543
+ };
1544
+ readonly 'experiment.exposure': {
1545
+ readonly sources: ["frontend"];
1546
+ readonly tracking: true;
1547
+ readonly integrations: false;
1548
+ };
1549
+ readonly 'page.view': {
1550
+ readonly sources: ["frontend"];
1551
+ readonly tracking: true;
1552
+ readonly integrations: true;
1553
+ };
1554
+ readonly 'page.exit': {
1555
+ readonly sources: ["frontend"];
1556
+ readonly tracking: true;
1557
+ readonly integrations: false;
1558
+ };
1559
+ readonly 'user.registered': {
1560
+ readonly sources: ["frontend"];
1561
+ readonly tracking: true;
1562
+ readonly integrations: true;
1563
+ };
1564
+ readonly 'checkout.start': {
1565
+ readonly sources: ["frontend"];
1566
+ readonly tracking: true;
1567
+ readonly integrations: true;
1568
+ };
1569
+ readonly 'checkout.payment_added': {
1570
+ readonly sources: ["frontend"];
1571
+ readonly tracking: true;
1572
+ readonly integrations: true;
1573
+ };
1574
+ readonly 'checkout.failed': {
1575
+ readonly sources: ["frontend"];
1576
+ readonly tracking: true;
1577
+ readonly integrations: false;
1578
+ };
1579
+ readonly 'purchase.complete': {
1580
+ readonly sources: ["backend"];
1581
+ readonly tracking: true;
1582
+ readonly integrations: true;
1583
+ };
1584
+ readonly 'customer.first_purchase': {
1585
+ readonly sources: ["backend"];
1586
+ readonly tracking: true;
1587
+ readonly integrations: true;
1588
+ };
1589
+ readonly 'subscription.created': {
1590
+ readonly sources: ["backend"];
1591
+ readonly tracking: true;
1592
+ readonly integrations: true;
1593
+ };
1594
+ readonly 'subscription.renewal': {
1595
+ readonly sources: ["backend"];
1596
+ readonly tracking: true;
1597
+ readonly integrations: true;
1598
+ };
1599
+ };
1600
+ /** A catalog event name. */
1601
+ type CatalogEvent = keyof typeof EVENT_CATALOG;
1602
+ /** True if the event is persisted to analytics. */
1603
+ declare function isTrackableEvent(name: string): boolean;
1604
+ /** True if the event is forwarded to ad/integration pixels. */
1605
+ declare function isIntegrationEvent(name: string): boolean;
1606
+ /** The events that feed integrations — the set a pixel loader subscribes to. */
1607
+ declare function integrationEvents(): CatalogEvent[];
376
1608
 
377
- /** Integration config map: integrationId -> config object */
378
- type IntegrationConfigs = Record<string, Record<string, unknown>>;
379
- type IntegrationLoader = (config: Record<string, unknown>) => void;
380
1609
  /**
381
- * Register a custom integration loader.
382
- * Called by the CLI build or user code to add integrations.
1610
+ * `<Script>` the **declared third-party embed** primitive (phase-2 §2.3d).
1611
+ *
1612
+ * The controlled escape hatch for runtime widgets/embeds that genuinely need
1613
+ * client code — Intercom, Trustpilot, a calendar embed, an operator pixel. Unlike
1614
+ * arbitrary `<script>` injection, it's **declared** (the funnel author writes
1615
+ * `<Script src=…/>`), **deduped** (injected at most once per id/src, even across
1616
+ * page navigation in the SPA — a chat widget must not reload on every page), and
1617
+ * **CSP-friendly** (accepts a `nonce`).
1618
+ *
1619
+ * CSP enforcement itself is the **renderer's** job: it scans the funnel's declared
1620
+ * `<Script src>`s (build-time) into the `script-src` allowlist and supplies the
1621
+ * `nonce`. This component just injects the tag at the right time.
1622
+ *
1623
+ * For *analytics* pixels, prefer subscribing to the `window.appfunnel` bus
1624
+ * ({@link ../tracking/bus}) — `<Script>` is for non-analytics third-party widgets
1625
+ * (and the pixel loader snippets, which then subscribe to the bus).
383
1626
  */
384
- declare function registerIntegration(id: string, loader: IntegrationLoader): void;
1627
+ interface ScriptProps {
1628
+ /** The third-party script URL. */
1629
+ src: string;
1630
+ /**
1631
+ * When to inject. `afterInteractive` (default) injects on mount (after hydration);
1632
+ * `lazyOnload` waits for idle/`load` so it never competes with the funnel's own JS.
1633
+ */
1634
+ strategy?: 'afterInteractive' | 'lazyOnload';
1635
+ /** Dedup key (defaults to `src`) — the script injects at most once per id. */
1636
+ id?: string;
1637
+ /** Defaults to `true`. */
1638
+ async?: boolean;
1639
+ defer?: boolean;
1640
+ /** CSP nonce — supplied by the renderer. */
1641
+ nonce?: string;
1642
+ /** Extra attributes (e.g. `data-*`). */
1643
+ attributes?: Record<string, string>;
1644
+ onLoad?: () => void;
1645
+ onError?: () => void;
1646
+ }
1647
+ /**
1648
+ * Injects a third-party script once. Renders nothing. Persists across navigation
1649
+ * (the tag is never removed on unmount — third-party widgets should stay loaded).
1650
+ */
1651
+ declare function Script({ src, strategy, id, async, defer, nonce, attributes, onLoad, onError, }: ScriptProps): null;
1652
+
1653
+ /**
1654
+ * The **manifest** — the declarative IR a funnel project compiles to (doc 06).
1655
+ * It's the machine-readable artifact the platform operates on without executing
1656
+ * the funnel: the dashboard draws the flow graph from it, analytics binds to its
1657
+ * **stable ids**, validation runs against it, and the renderer reads it to resolve
1658
+ * routing.
1659
+ *
1660
+ * `compileManifest` evaluates the spine (the funnel config + each page's co-located
1661
+ * `meta`) into that IR. It's pure — no I/O — so it unit-tests trivially. **It runs
1662
+ * server-side**, as the IR step of the publish build over the canonical files in our
1663
+ * storage (R2). The client/CLI never produces the authoritative manifest — R2 is the
1664
+ * source of truth, the server compiles. (A local dev preview may run it for itself,
1665
+ * but that output is never published.)
1666
+ *
1667
+ * Edge model mirrors the runtime ({@link ../flow/spine}.`nextPage`): a page's
1668
+ * `meta.next` routes become **branch** edges (with a serialized condition — a
1669
+ * declarative {@link Condition}, or an opaque marker for a code predicate), and the
1670
+ * implicit file-order fall-through becomes a **linear** edge (unless an
1671
+ * unconditional route already covers it, or the page is a `finish`).
1672
+ *
1673
+ * Experiments are **not** compiled here. A variant page is recognised structurally
1674
+ * by the `@` in its key (`welcome@b`) and tagged `variantOf` + collapsed out of the
1675
+ * linear flow; the experiment *wiring* (labels/weights/status) is operational data
1676
+ * the platform owns and overlays at render time, not part of the funnel build.
1677
+ *
1678
+ * Stable ids: the page **key** is the source anchor, so `id === key` here. The
1679
+ * durable analytics id is the semantic page name (a slot survives a winner being
1680
+ * promoted into it); transient `@variant` pages only ever key analytics by
1681
+ * `(experimentId, variant)` in immutable results — a platform concern.
1682
+ */
1683
+
1684
+ interface ManifestPage {
1685
+ id: string;
1686
+ key: string;
1687
+ type: PageType;
1688
+ slug: string;
1689
+ index: number;
1690
+ /**
1691
+ * Set on a variant page (`welcome@b`) to its **slot** (`welcome`) — a structural
1692
+ * marker that this node is an off-flow alternate, not a linear step. The
1693
+ * experiment *wiring* (label/weight/id) is operational data the platform owns,
1694
+ * not the manifest.
1695
+ */
1696
+ variantOf?: string;
1697
+ }
1698
+ type EdgeCondition = {
1699
+ kind: 'declarative';
1700
+ condition: Condition;
1701
+ }
1702
+ /** A code predicate — opaque body; `label` may be supplied later by AST parsing. */
1703
+ | {
1704
+ kind: 'predicate';
1705
+ label?: string;
1706
+ };
1707
+ interface ManifestEdge {
1708
+ from: string;
1709
+ to: string;
1710
+ kind: 'branch' | 'linear';
1711
+ /** Absent = unconditional (a fallback route, or the linear fall-through). */
1712
+ condition?: EdgeCondition;
1713
+ }
1714
+ interface ManifestValidation {
1715
+ ok: boolean;
1716
+ /** Page ids you can't proceed from (and that aren't a `finish`). */
1717
+ deadEnds: string[];
1718
+ /** Page ids not reachable from the start. */
1719
+ unreachable: string[];
1720
+ /** Routes whose target page key doesn't exist. */
1721
+ badTargets: {
1722
+ from: string;
1723
+ to: string;
1724
+ }[];
1725
+ /** True if the funnel never writes `user.email` (the mandatory identity step). */
1726
+ missingEmailCapture: boolean;
1727
+ }
1728
+ interface FunnelManifest {
1729
+ id: string;
1730
+ pages: ManifestPage[];
1731
+ flow: {
1732
+ start: string | null;
1733
+ nodes: string[];
1734
+ edges: ManifestEdge[];
1735
+ };
1736
+ products: string[];
1737
+ locales?: FunnelLocales;
1738
+ validation: ManifestValidation;
1739
+ }
1740
+ interface CompileInput {
1741
+ funnel: FunnelDefinition;
1742
+ /** Pages in file order (the default flow); `meta` holds type/slug/next. */
1743
+ pages: {
1744
+ key: string;
1745
+ meta?: PageMeta;
1746
+ }[];
1747
+ }
1748
+ /** Compile a funnel's spine into its declarative {@link FunnelManifest} IR. */
1749
+ declare function compileManifest(input: CompileInput): FunnelManifest;
385
1750
 
386
- export { AppFunnelConfig, type DateFormat, DeviceInfo, FunnelState, type IntegrationConfigs, type KeyboardState, LocaleState, NavigationState, PaddleCheckout, type PaddleCheckoutProps, PageData, PageDefinition, PaymentState, ProductsState, type SafeAreaInsets, StripePaymentForm, type StripePaymentFormProps, type StripePaymentHandle, TrackingState, TranslationState, UserState, VariableValue, defineConfig, definePage, registerIntegration, useData, useDateOfBirth, useDeviceInfo, useFunnel, useKeyboard, useLocale, useNavigation, usePageData, usePayment, useProducts, useQueryParam, useQueryParams, useResponse, useResponses, useSafeArea, useTracking, useTranslation, useUser, useUserProperty, useVariable, useVariables };
1751
+ export { type Acquisition, type AppFunnelBusApi, Back, type BackProps, type BrowserContext, type BusAccessors, type BusEvent, type CatalogEvent, Checkout, type CheckoutCallbacks, type CheckoutDriver, type CheckoutError, type CheckoutErrorCategory, type CheckoutHandle, type CheckoutIntent, type CheckoutProps, type CheckoutProvider, type CheckoutRequest, type CheckoutResult, type CheckoutStatus, type CheckoutSurface, Choice, type ChoiceOption, type ChoiceProps, type ClickIdContext, type CompileInput, type Condition, type ConditionOp, type DeviceContext, type Direction, EVENT_CATALOG, type EdgeCondition, type EventMeta, type EventSource, type ExperimentIssue, type ExperimentResolution, type ExperimentValidation, type ExperimentVariant, type FlowPage, type Formatters, type Funnel, type FunnelBus, type FunnelContext, type FunnelDefinition, type FunnelEventDataMap, type FunnelLocales, type FunnelManifest, FunnelProvider, type FunnelProviderProps, type FunnelSnapshot, FunnelToaster, FunnelView, type Gate, INLINE_SURFACES, type IdentityContext, type Interval, type KnownFunnelEvent, type LocaleContext, type ManifestEdge, type ManifestPage, type ManifestValidation, type MessageCatalog, type MockDriverOptions, Modal, type ModalHandler, type ModalHocProps, type ModalProps, type ModalState, type Money, Namespace, type NavigationState, Next, type NextProps, type OffSessionReliability, type OsContext, PROVIDER_PROFILES, type PageContext, type PageMeta, type PageModule, type PageType, type PluralCategory, type Predicate, type Product, type ProductInput, type ProviderProfile, type ResolvedProduct, type Route, type RuntimeExperiment, type RuntimePage, Script, type ScriptProps, type SessionContext, Sheet, type SheetProps, type SheetSide, type SurfaceCapability, type SystemContext, type TFunction, type Tracker, type Translation, Upsell, type UpsellKind, type UpsellProps, type UseCheckoutOptions, type UtmContext, type ValidationResult, VariableStore, assignVariant, attachBus, bucketingSeed, buildAcquisition, buildCatalog, checkoutError, compileManifest, createBus, createConsoleTracker, createFunnelStore, createMockDriver, defineFunnel, defineModal, definePage, entryGuard, evaluateCondition, evaluateGate, expectedPathLength, fnv1a, formatProduct, hashToUnit, hideModal, integrationEvents, isInlineSurface, isIntegrationEvent, isMerchantOfRecord, isOrchestrator, isRtl, isTrackableEvent, isVariantKey, newEventId, nextPage, outgoingKeys, pageMeta, parseSlotKey, pickByWeight, registerModal, removeModal, resolveExperiments, resolveLocale, resolveProduct, resolveRoute, showModal, surfacesFor, unregisterModal, useCheckout, useClickIds, useContextValue, useData, useDevice, useExperiment, useField, useFunnel, useLocale, useModal, useNavigation, usePage, useProduct, useProducts, useResponse, useSystem, useTracker, useTranslation, useUserAttribute, useUtm, validateCheckout, validateExperiments, validateUpsell, withBus };