@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.cjs +2008 -1605
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1644 -279
- package/dist/index.d.ts +1644 -279
- package/dist/index.js +1933 -1574
- package/dist/index.js.map +1 -1
- package/package.json +17 -34
- package/dist/chunk-7BSBNWJ6.cjs +0 -1484
- package/dist/chunk-7BSBNWJ6.cjs.map +0 -1
- package/dist/chunk-7D7VJBOV.cjs +0 -108
- package/dist/chunk-7D7VJBOV.cjs.map +0 -1
- package/dist/chunk-7KOSK32P.js +0 -1479
- package/dist/chunk-7KOSK32P.js.map +0 -1
- package/dist/chunk-YRHJBBAC.js +0 -104
- package/dist/chunk-YRHJBBAC.js.map +0 -1
- package/dist/elements/index.cjs +0 -12237
- package/dist/elements/index.cjs.map +0 -1
- package/dist/elements/index.d.cts +0 -624
- package/dist/elements/index.d.ts +0 -624
- package/dist/elements/index.js +0 -12201
- package/dist/elements/index.js.map +0 -1
- package/dist/internal-BBWDTB-l.d.cts +0 -525
- package/dist/internal-BBWDTB-l.d.ts +0 -525
- package/dist/internal.cjs +0 -12
- package/dist/internal.cjs.map +0 -1
- package/dist/internal.d.cts +0 -2
- package/dist/internal.d.ts +0 -2
- package/dist/internal.js +0 -3
- package/dist/internal.js.map +0 -1
package/dist/index.d.cts
CHANGED
|
@@ -1,386 +1,1751 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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 formatting — what the catalog stores. {@link useProduct} formats it.
|
|
85
414
|
*/
|
|
86
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
519
|
+
* Provider capabilities (researched 2026-06-23 against provider docs + Funnelfox).
|
|
114
520
|
*
|
|
115
|
-
*
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
921
|
+
declare function useTranslation(): Translation;
|
|
161
922
|
|
|
162
923
|
/**
|
|
163
|
-
*
|
|
924
|
+
* Toast — a thin convenience re-export of {@link https://sonner.emilkowal.ski | sonner}.
|
|
164
925
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
182
|
-
*
|
|
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
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
1076
|
+
declare function validateExperiments(experiments: RuntimeExperiment[], pages: {
|
|
1077
|
+
key: string;
|
|
1078
|
+
}[]): ExperimentValidation;
|
|
204
1079
|
|
|
205
1080
|
/**
|
|
206
|
-
*
|
|
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
|
-
*
|
|
209
|
-
*
|
|
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
|
-
*
|
|
212
|
-
*
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
230
|
-
* const { bottom } = useSafeArea()
|
|
231
|
-
* <div style={{ position: 'fixed', bottom: 0, paddingBottom: bottom }} />
|
|
232
|
-
* ```
|
|
1267
|
+
* Must live inside `<FunnelProvider>`.
|
|
233
1268
|
*/
|
|
234
|
-
declare function
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
246
|
-
*
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
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
|
-
*
|
|
253
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
265
|
-
*
|
|
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
|
-
*
|
|
272
|
-
*
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
364
|
-
*
|
|
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
|
|
1508
|
+
declare function attachBus(bus: FunnelBus): () => void;
|
|
367
1509
|
|
|
368
1510
|
/**
|
|
369
|
-
*
|
|
370
|
-
*
|
|
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
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
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
|
-
*
|
|
382
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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 };
|