@appfunnel-dev/sdk 0.16.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.js CHANGED
@@ -1,1642 +1,2024 @@
1
- import { useNavigation, useResponses } from './chunk-IA5WCBUS.js';
2
- export { useNavigation, useResponse, useResponses } from './chunk-IA5WCBUS.js';
3
- import { useFunnelContext } from './chunk-FDAIQSVU.js';
4
- export { FunnelProvider, registerIntegration } from './chunk-FDAIQSVU.js';
5
- import { forwardRef, useMemo, useRef, useEffect, useCallback, useImperativeHandle, useState, useSyncExternalStore } from 'react';
6
- import { loadStripe } from '@stripe/stripe-js';
7
- import { toast } from 'sonner';
1
+ import { createContext, createElement, useCallback, useContext, useMemo, lazy, useState, useRef, useEffect, Suspense, Fragment, isValidElement, cloneElement, useReducer, useSyncExternalStore } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Toaster } from 'sonner';
8
4
  export { toast } from 'sonner';
9
- import { useStripe, useElements, PaymentElement, EmbeddedCheckoutProvider, EmbeddedCheckout, Elements } from '@stripe/react-stripe-js';
10
- import { jsx, jsxs } from 'react/jsx-runtime';
11
5
 
12
- // src/config.ts
13
- function defineConfig(config) {
14
- return config;
15
- }
16
- function definePage(definition) {
17
- return definition;
18
- }
19
- function useVariable(id) {
20
- const { variableStore } = useFunnelContext();
21
- const subscribe = useCallback(
22
- (callback) => variableStore.subscribe(callback, { keys: [id] }),
23
- [variableStore, id]
24
- );
25
- const getSnapshot = useCallback(
26
- () => variableStore.get(id),
27
- [variableStore, id]
28
- );
29
- const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
30
- const setValue = useCallback(
31
- (v) => variableStore.set(id, v),
32
- [variableStore, id]
33
- );
34
- return [value, setValue];
35
- }
36
- function useVariables() {
37
- const { variableStore } = useFunnelContext();
38
- const subscribe = useCallback(
39
- (callback) => variableStore.subscribe(callback),
40
- [variableStore]
41
- );
42
- const getSnapshot = useCallback(
43
- () => variableStore.getState(),
44
- [variableStore]
45
- );
46
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
47
- }
6
+ // src/state/namespaces.ts
48
7
 
49
- // src/utils/date.ts
50
- function toISODate(input) {
51
- if (!input || !input.trim()) return "";
52
- const s = input.trim();
53
- if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
54
- if (/^\d{4}-\d{2}-\d{2}T/.test(s)) return s.slice(0, 10);
55
- const sepMatch = s.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})$/);
56
- if (sepMatch) {
57
- const a = parseInt(sepMatch[1], 10);
58
- const b = parseInt(sepMatch[2], 10);
59
- const year = sepMatch[3];
60
- let month;
61
- let day;
62
- if (a > 12 && b <= 12) {
63
- day = a;
64
- month = b;
65
- } else if (b > 12 && a <= 12) {
66
- month = a;
67
- day = b;
68
- } else {
69
- month = a;
70
- day = b;
8
+ // src/state/variableStore.ts
9
+ var VariableStore = class {
10
+ constructor(initial) {
11
+ this.listeners = /* @__PURE__ */ new Set();
12
+ /** Tracks which keys changed in the last mutation — used by scoped notify */
13
+ this.changedKeys = [];
14
+ this.state = { ...initial };
15
+ }
16
+ getState() {
17
+ return this.state;
18
+ }
19
+ get(key) {
20
+ return this.state[key];
21
+ }
22
+ set(key, value) {
23
+ if (this.state[key] === value) return;
24
+ this.state = { ...this.state, [key]: value };
25
+ this.changedKeys = [key];
26
+ this.notify();
27
+ }
28
+ setState(updater) {
29
+ const prev = this.state;
30
+ const next = updater(prev);
31
+ if (next === prev) return;
32
+ this.state = next;
33
+ this.changedKeys = [];
34
+ for (const key of Object.keys(next)) {
35
+ if (next[key] !== prev[key]) this.changedKeys.push(key);
71
36
  }
72
- if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
73
- return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
37
+ for (const key of Object.keys(prev)) {
38
+ if (!(key in next) && prev[key] !== void 0) this.changedKeys.push(key);
74
39
  }
40
+ this.notify();
75
41
  }
76
- const ymdSlash = s.match(/^(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})$/);
77
- if (ymdSlash) {
78
- const year = ymdSlash[1];
79
- const month = parseInt(ymdSlash[2], 10);
80
- const day = parseInt(ymdSlash[3], 10);
81
- if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
82
- return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
42
+ /** Batch set multiple variables at once */
43
+ setMany(updates) {
44
+ const changed = [];
45
+ const next = { ...this.state };
46
+ for (const [key, value] of Object.entries(updates)) {
47
+ if (next[key] !== value) {
48
+ next[key] = value;
49
+ changed.push(key);
50
+ }
83
51
  }
52
+ if (changed.length === 0) return;
53
+ this.state = next;
54
+ this.changedKeys = changed;
55
+ this.notify();
84
56
  }
85
- if (/^\d{8}$/.test(s)) {
86
- const a = parseInt(s.slice(0, 2), 10);
87
- const b = parseInt(s.slice(2, 4), 10);
88
- const year = s.slice(4, 8);
89
- let month;
90
- let day;
91
- if (a > 12 && b <= 12) {
92
- day = a;
93
- month = b;
94
- } else {
95
- month = a;
96
- day = b;
97
- }
98
- if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
99
- return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
57
+ /**
58
+ * Subscribe to store changes.
59
+ *
60
+ * @param listener - callback fired when relevant keys change
61
+ * @param scope - optional scope to limit notifications:
62
+ * - `{ keys: ['data.x', 'user.email'] }` — only fire when these exact keys change
63
+ * - `{ prefix: 'answers.' }` only fire when any key starting with this prefix changes
64
+ * - omit or `{}` — fire on every change (global listener)
65
+ */
66
+ subscribe(listener, scope) {
67
+ const entry = {
68
+ listener,
69
+ keys: scope?.keys || null,
70
+ prefix: scope?.prefix || null
71
+ };
72
+ this.listeners.add(entry);
73
+ return () => this.listeners.delete(entry);
74
+ }
75
+ notify() {
76
+ const changed = this.changedKeys;
77
+ for (const entry of this.listeners) {
78
+ if (!entry.keys && !entry.prefix) {
79
+ entry.listener();
80
+ continue;
81
+ }
82
+ if (entry.keys) {
83
+ if (changed.some((k) => entry.keys.includes(k))) {
84
+ entry.listener();
85
+ }
86
+ continue;
87
+ }
88
+ if (entry.prefix) {
89
+ if (changed.some((k) => k.startsWith(entry.prefix))) {
90
+ entry.listener();
91
+ }
92
+ }
100
93
  }
101
94
  }
102
- throw new Error(
103
- `[AppFunnel] Invalid date format: "${input}". Expected a date string like MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or MMDDYYYY.`
104
- );
105
- }
106
- function toISODateWithFormat(input, format) {
107
- if (!input || !input.trim()) return "";
108
- const s = input.trim();
109
- if (/^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}T/.test(s)) {
110
- return s.slice(0, 10);
95
+ };
96
+
97
+ // src/state/store.ts
98
+ var NAMESPACE_PREFIX = {
99
+ user: "user",
100
+ responses: "responses",
101
+ data: "data"
102
+ };
103
+ var BUILTIN_USER = {
104
+ "user.email": "",
105
+ "user.name": ""
106
+ };
107
+ function defaultFor(type) {
108
+ switch (type) {
109
+ case "string":
110
+ return "";
111
+ case "number":
112
+ return 0;
113
+ case "boolean":
114
+ return false;
115
+ case "stringArray":
116
+ return [];
117
+ default:
118
+ return "";
111
119
  }
112
- const digits = s.replace(/[^\d]/g, "");
113
- if (format === "YYYY-MM-DD") {
114
- const m = s.match(/^(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})$/);
115
- if (m) return `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
116
- if (digits.length === 8) {
117
- return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
118
- }
120
+ }
121
+ function createFunnelStore(config = {}, sessionValues) {
122
+ const initial = { ...BUILTIN_USER };
123
+ for (const [key, cfg] of Object.entries(config.responses ?? {})) {
124
+ initial[`responses.${key}`] = cfg.default ?? defaultFor(cfg.type);
119
125
  }
120
- if (format === "MM/DD/YYYY") {
121
- const m = s.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})$/);
122
- if (m) return `${m[3]}-${m[1].padStart(2, "0")}-${m[2].padStart(2, "0")}`;
123
- if (digits.length === 8) {
124
- return `${digits.slice(4, 8)}-${digits.slice(0, 2)}-${digits.slice(2, 4)}`;
125
- }
126
+ for (const [key, cfg] of Object.entries(config.data ?? {})) {
127
+ initial[`data.${key}`] = cfg.default ?? defaultFor(cfg.type);
126
128
  }
127
- if (format === "DD/MM/YYYY") {
128
- const m = s.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})$/);
129
- if (m) return `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
130
- if (digits.length === 8) {
131
- return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}`;
129
+ if (sessionValues) {
130
+ for (const [key, value] of Object.entries(sessionValues)) {
131
+ if (value !== void 0) initial[key] = value;
132
132
  }
133
133
  }
134
- throw new Error(
135
- `[AppFunnel] Invalid date format: "${input}". Expected format ${format} (e.g. ${{
136
- "MM/DD/YYYY": "03/15/1990 or 03151990",
137
- "DD/MM/YYYY": "15/03/1990 or 15031990",
138
- "YYYY-MM-DD": "1990-03-15 or 19900315"
139
- }[format]}).`
140
- );
134
+ return new VariableStore(initial);
141
135
  }
142
136
 
143
- // src/hooks/useUser.ts
144
- function useUser() {
145
- const { variableStore, tracker } = useFunnelContext();
146
- const subscribe = useCallback(
147
- (cb) => variableStore.subscribe(cb, { prefix: "user." }),
148
- [variableStore]
149
- );
150
- const getSnapshot = useCallback(
151
- () => variableStore.getState(),
152
- [variableStore]
153
- );
154
- const variables = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
155
- return useMemo(
156
- () => ({
157
- email: variables["user.email"] || "",
158
- name: variables["user.name"] || "",
159
- stripeCustomerId: variables["user.stripeCustomerId"] || "",
160
- paddleCustomerId: variables["user.paddleCustomerId"] || "",
161
- dateOfBirth: variables["user.dateOfBirth"] || "",
162
- marketingConsent: variables["user.marketingConsent"] === true,
163
- setEmail(email) {
164
- variableStore.set("user.email", email);
165
- },
166
- setName(name) {
167
- variableStore.set("user.name", name);
168
- },
169
- setDateOfBirth(dateOfBirth) {
170
- variableStore.set("user.dateOfBirth", toISODate(dateOfBirth));
137
+ // src/state/context.ts
138
+ var cached = null;
139
+ function extractVersion(ua, regex) {
140
+ return ua.match(regex)?.[1] ?? "0";
141
+ }
142
+ function detect() {
143
+ if (cached) return cached;
144
+ if (typeof window === "undefined") {
145
+ cached = {
146
+ device: {
147
+ type: "desktop",
148
+ isMobile: false,
149
+ isTablet: false,
150
+ screenWidth: 0,
151
+ screenHeight: 0,
152
+ viewportWidth: 0,
153
+ viewportHeight: 0,
154
+ colorDepth: 24,
155
+ pixelRatio: 1
171
156
  },
172
- setMarketingConsent(consent) {
173
- variableStore.set("user.marketingConsent", consent);
174
- if (consent) {
175
- tracker.track("marketing.consent_given");
176
- }
157
+ browser: {
158
+ name: "Unknown",
159
+ version: "0",
160
+ userAgent: "unknown",
161
+ language: "en",
162
+ cookieEnabled: true,
163
+ online: true
177
164
  },
178
- identify(email) {
179
- tracker.identify(email);
180
- }
181
- }),
182
- [variables, variableStore, tracker]
183
- );
165
+ os: { name: "Unknown", timezone: "UTC" },
166
+ locale: { locale: "en", language: "en", region: "", timeZone: "UTC" }
167
+ };
168
+ return cached;
169
+ }
170
+ const ua = navigator.userAgent;
171
+ const l = ua.toLowerCase();
172
+ let name = "Unknown";
173
+ let version = "0";
174
+ if (l.includes("firefox")) {
175
+ name = "Firefox";
176
+ version = extractVersion(ua, /Firefox\/(\d+[\d.]*)/);
177
+ } else if (l.includes("edg")) {
178
+ name = "Edge";
179
+ version = extractVersion(ua, /Edg\/(\d+[\d.]*)/);
180
+ } else if (l.includes("chrome") && !l.includes("edg")) {
181
+ name = "Chrome";
182
+ version = extractVersion(ua, /Chrome\/(\d+[\d.]*)/);
183
+ } else if (l.includes("safari") && !l.includes("chrome")) {
184
+ name = "Safari";
185
+ version = extractVersion(ua, /Version\/(\d+[\d.]*)/);
186
+ }
187
+ let osName = "Unknown";
188
+ if (l.includes("win")) osName = "Windows";
189
+ else if (l.includes("mac")) osName = "macOS";
190
+ else if (l.includes("linux") && !l.includes("android")) osName = "Linux";
191
+ else if (l.includes("android")) osName = "Android";
192
+ else if (/iphone|ipad|ipod/.test(l)) osName = "iOS";
193
+ let type = "desktop";
194
+ if (/mobile|android(?!.*tablet)|iphone|ipod/.test(l)) type = "mobile";
195
+ else if (/tablet|ipad/.test(l)) type = "tablet";
196
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
197
+ const fullLocale = navigator.language || "en";
198
+ const [language = "en", region = ""] = fullLocale.split("-");
199
+ cached = {
200
+ device: {
201
+ type,
202
+ isMobile: type === "mobile",
203
+ isTablet: type === "tablet",
204
+ screenWidth: window.screen?.width || 0,
205
+ screenHeight: window.screen?.height || 0,
206
+ viewportWidth: window.innerWidth || 0,
207
+ viewportHeight: window.innerHeight || 0,
208
+ colorDepth: window.screen?.colorDepth || 24,
209
+ pixelRatio: window.devicePixelRatio || 1
210
+ },
211
+ browser: {
212
+ name,
213
+ version,
214
+ userAgent: ua,
215
+ language: fullLocale,
216
+ cookieEnabled: navigator.cookieEnabled ?? true,
217
+ online: navigator.onLine ?? true
218
+ },
219
+ os: { name: osName, timezone: timeZone },
220
+ locale: { locale: fullLocale, language, region, timeZone }
221
+ };
222
+ return cached;
184
223
  }
185
- function useUserProperty(field) {
186
- const { variableStore } = useFunnelContext();
187
- const key = `user.${field}`;
188
- const subscribe = useCallback(
189
- (cb) => variableStore.subscribe(cb, { keys: [key] }),
190
- [variableStore, key]
191
- );
192
- const getSnapshot = useCallback(
193
- () => variableStore.get(key) || "",
194
- [variableStore, key]
195
- );
196
- const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
197
- const setValue = useCallback(
198
- (v) => variableStore.set(key, v),
199
- [variableStore, key]
200
- );
201
- return [value, setValue];
224
+ function collectUtm() {
225
+ if (typeof window === "undefined") return {};
226
+ const out = {};
227
+ const params = new URLSearchParams(window.location.search);
228
+ params.forEach((value, key) => {
229
+ if (key.startsWith("utm_")) out[key.slice(4)] = value;
230
+ });
231
+ return out;
202
232
  }
203
- function useDateOfBirth(format = "MM/DD/YYYY") {
204
- const { variableStore } = useFunnelContext();
205
- const key = "user.dateOfBirth";
206
- const subscribe = useCallback(
207
- (cb) => variableStore.subscribe(cb, { keys: [key] }),
208
- [variableStore]
209
- );
210
- const getSnapshot = useCallback(
211
- () => variableStore.get(key) || "",
212
- [variableStore]
213
- );
214
- const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
215
- const setValue = useCallback(
216
- (v) => variableStore.set(key, toISODateWithFormat(v, format)),
217
- [variableStore, format]
218
- );
219
- return [value, setValue];
233
+ var CLICK_ID_PARAMS = [
234
+ "fbclid",
235
+ "gclid",
236
+ "gbraid",
237
+ "wbraid",
238
+ "msclkid",
239
+ "ttclid",
240
+ "twclid",
241
+ "sccid",
242
+ "li_fat_id",
243
+ "epik",
244
+ "rdt_cid",
245
+ "dclid",
246
+ "irclickid"
247
+ ];
248
+ function collectClickIds() {
249
+ if (typeof window === "undefined") return {};
250
+ const out = {};
251
+ const params = new URLSearchParams(window.location.search);
252
+ for (const key of CLICK_ID_PARAMS) {
253
+ const v = params.get(key);
254
+ if (v) out[key] = v;
255
+ }
256
+ return out;
220
257
  }
221
- function useQueryParams() {
222
- const { variableStore } = useFunnelContext();
223
- const subscribe = useCallback(
224
- (cb) => variableStore.subscribe(cb, { prefix: "query." }),
225
- [variableStore]
226
- );
227
- const getSnapshot = useCallback(
228
- () => variableStore.getState(),
229
- [variableStore]
258
+ function readCookie(name) {
259
+ if (typeof document === "undefined") return void 0;
260
+ const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
261
+ return m ? decodeURIComponent(m[1]) : void 0;
262
+ }
263
+ function buildAcquisition(context) {
264
+ return {
265
+ utm: context.utm,
266
+ clickIds: context.clickIds,
267
+ cookies: { fbp: readCookie("_fbp"), fbc: readCookie("_fbc") },
268
+ referrer: context.session.referrer,
269
+ landingPath: typeof window !== "undefined" ? window.location.pathname : ""
270
+ };
271
+ }
272
+ function buildContext(opts = {}) {
273
+ const raw = detect();
274
+ const now = opts.now ?? 0;
275
+ const referrer = typeof document !== "undefined" ? document.referrer : "";
276
+ const base2 = {
277
+ device: raw.device,
278
+ browser: raw.browser,
279
+ os: raw.os,
280
+ locale: raw.locale,
281
+ utm: collectUtm(),
282
+ clickIds: collectClickIds(),
283
+ page: { key: "", slug: "", index: 0, total: 0, progressPercentage: 0, startedAt: now },
284
+ session: { id: opts.sessionId ?? null, startedAt: now, referrer },
285
+ identity: { customerId: opts.customerId ?? null, visitorId: opts.visitorId ?? null },
286
+ system: {
287
+ funnelId: opts.funnelId ?? "",
288
+ campaignId: opts.campaignId ?? "",
289
+ mode: opts.mode ?? "live",
290
+ now
291
+ }
292
+ };
293
+ if (!opts.overrides) return base2;
294
+ return {
295
+ ...base2,
296
+ ...opts.overrides,
297
+ device: { ...base2.device, ...opts.overrides.device },
298
+ browser: { ...base2.browser, ...opts.overrides.browser },
299
+ os: { ...base2.os, ...opts.overrides.os },
300
+ locale: { ...base2.locale, ...opts.overrides.locale },
301
+ utm: { ...base2.utm, ...opts.overrides.utm },
302
+ clickIds: { ...base2.clickIds, ...opts.overrides.clickIds },
303
+ page: { ...base2.page, ...opts.overrides.page },
304
+ session: { ...base2.session, ...opts.overrides.session },
305
+ identity: { ...base2.identity, ...opts.overrides.identity },
306
+ system: { ...base2.system, ...opts.overrides.system }
307
+ };
308
+ }
309
+ var _eventIdCounter = 0;
310
+ function newEventId() {
311
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
312
+ return `af_${Date.now().toString(36)}_${(_eventIdCounter++).toString(36)}`;
313
+ }
314
+ function createConsoleTracker(opts = {}) {
315
+ const log = (label, ...args) => {
316
+ if (!opts.silent) console.info(`[appfunnel] ${label}`, ...args);
317
+ };
318
+ return {
319
+ track: (event, data) => log(`track ${event}`, data ?? {}),
320
+ identify: (email, eventId) => log("identify", email, eventId),
321
+ setVariables: (vars) => log("vars", vars),
322
+ setAcquisition: (acq) => log("acquisition", acq)
323
+ };
324
+ }
325
+ var TrackerContext = createContext(null);
326
+ function TrackerProvider({
327
+ tracker,
328
+ children
329
+ }) {
330
+ return createElement(TrackerContext.Provider, { value: tracker }, children);
331
+ }
332
+ function useTrackerRef() {
333
+ return useContext(TrackerContext) ?? FALLBACK;
334
+ }
335
+ var FALLBACK = createConsoleTracker({ silent: true });
336
+ function useTracker() {
337
+ const tracker = useTrackerRef();
338
+ const track = useCallback(
339
+ (event, data, userData) => tracker.track(event, data, userData),
340
+ [tracker]
230
341
  );
231
- const variables = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
232
- return useMemo(() => {
233
- const params = {};
234
- for (const [key, value] of Object.entries(variables)) {
235
- if (key.startsWith("query.") && typeof value === "string") {
236
- params[key.slice(6)] = value;
342
+ return { track };
343
+ }
344
+
345
+ // src/tracking/bus.ts
346
+ var MAX_HISTORY = 250;
347
+ function createBus(accessors, now = () => Date.now()) {
348
+ const listeners = /* @__PURE__ */ new Map();
349
+ const events = [];
350
+ const on = (event, cb) => {
351
+ let set = listeners.get(event);
352
+ if (!set) listeners.set(event, set = /* @__PURE__ */ new Set());
353
+ set.add(cb);
354
+ return () => off(event, cb);
355
+ };
356
+ const off = (event, cb) => {
357
+ listeners.get(event)?.delete(cb);
358
+ };
359
+ const emit = (event, data) => {
360
+ events.push({ event, data, ts: now() });
361
+ if (events.length > MAX_HISTORY) events.shift();
362
+ for (const cb of listeners.get(event) ?? []) {
363
+ try {
364
+ cb(data);
365
+ } catch {
237
366
  }
238
367
  }
239
- return params;
240
- }, [variables]);
368
+ for (const cb of listeners.get("*") ?? []) {
369
+ try {
370
+ cb({ event, data });
371
+ } catch {
372
+ }
373
+ }
374
+ };
375
+ return { on, off, events, emit, ...accessors };
241
376
  }
242
- function useQueryParam(key) {
243
- const { variableStore } = useFunnelContext();
244
- const prefixedKey = `query.${key}`;
245
- const subscribe = useCallback(
246
- (cb) => variableStore.subscribe(cb, { keys: [prefixedKey] }),
247
- [variableStore, prefixedKey]
248
- );
249
- const getSnapshot = useCallback(
250
- () => variableStore.get(prefixedKey) || "",
251
- [variableStore, prefixedKey]
252
- );
253
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
377
+ function withBus(tracker, bus) {
378
+ return {
379
+ // Spread preserves the original call arity (don't append an undefined userData).
380
+ track: ((...args) => {
381
+ tracker.track(...args);
382
+ bus.emit(args[0], args[1]);
383
+ }),
384
+ identify: (email, eventId) => {
385
+ const id = eventId ?? newEventId();
386
+ tracker.identify(email, id);
387
+ bus.emit("user.registered", { email, eventId: id });
388
+ },
389
+ setVariables: tracker.setVariables ? (v) => tracker.setVariables(v) : void 0,
390
+ // Acquisition is persistence (session + first-touch Customer), not a bus event.
391
+ setAcquisition: tracker.setAcquisition ? (a) => tracker.setAcquisition(a) : void 0
392
+ };
254
393
  }
255
- function useData(key) {
256
- const { variableStore } = useFunnelContext();
257
- const prefixedKey = `data.${key}`;
258
- const subscribe = useCallback(
259
- (cb) => variableStore.subscribe(cb, { keys: [prefixedKey] }),
260
- [variableStore, prefixedKey]
261
- );
262
- const getSnapshot = useCallback(
263
- () => variableStore.get(prefixedKey),
264
- [variableStore, prefixedKey]
265
- );
266
- const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
267
- const setValue = useCallback(
268
- (v) => variableStore.set(prefixedKey, v),
269
- [variableStore, prefixedKey]
394
+ function attachBus(bus) {
395
+ if (typeof window === "undefined") return () => {
396
+ };
397
+ const { on, off, getVariable, getVariables, getCurrentPageId, getCustomerId, getVisitorId } = bus;
398
+ const api = {
399
+ on,
400
+ off,
401
+ getVariable,
402
+ getVariables,
403
+ getCurrentPageId,
404
+ getCustomerId,
405
+ getVisitorId,
406
+ get events() {
407
+ return bus.events;
408
+ }
409
+ };
410
+ window.appfunnel = api;
411
+ return () => {
412
+ if (window.appfunnel === api) delete window.appfunnel;
413
+ };
414
+ }
415
+ var RTL_LANGS = /* @__PURE__ */ new Set(["ar", "he", "fa", "ur", "ps", "sd", "dv", "yi"]);
416
+ function isRtl(locale) {
417
+ return RTL_LANGS.has(locale.split("-")[0]);
418
+ }
419
+ var base = (l) => l?.split("-")[0];
420
+ function resolveLocale(config, detected, override) {
421
+ const supported = config?.supported ?? (config?.default ? [config.default] : ["en"]);
422
+ const def = config?.default ?? supported[0] ?? "en";
423
+ const pick = (l) => {
424
+ if (!l) return void 0;
425
+ if (supported.includes(l)) return l;
426
+ return supported.find((s) => base(s) === base(l));
427
+ };
428
+ return pick(override) ?? pick(detected) ?? def;
429
+ }
430
+ function interpolate(str, params) {
431
+ if (!params) return str;
432
+ return str.replace(/\{\{(\w+)\}\}/g, (_, k) => k in params ? String(params[k]) : `{{${k}}}`);
433
+ }
434
+ function lookup(catalog, chain, key) {
435
+ for (const loc of chain) {
436
+ const v = catalog[loc]?.[key];
437
+ if (v !== void 0) return v;
438
+ }
439
+ return void 0;
440
+ }
441
+ function devWarn(message) {
442
+ const env = globalThis.process?.env?.NODE_ENV;
443
+ if (env === "production") return;
444
+ try {
445
+ console.warn(message);
446
+ } catch {
447
+ }
448
+ }
449
+ var LocaleContext = createContext(null);
450
+ function LocaleProvider({
451
+ config,
452
+ catalog = {},
453
+ detected,
454
+ override,
455
+ children
456
+ }) {
457
+ const [locale, setLocale] = useState(() => resolveLocale(config, detected, override));
458
+ const value = useMemo(
459
+ () => ({ locale, setLocale, config, catalog }),
460
+ [locale, config, catalog]
270
461
  );
271
- return [value, setValue];
462
+ return createElement(LocaleContext.Provider, { value }, children);
272
463
  }
273
- function useLocale() {
274
- return useMemo(() => {
275
- if (typeof navigator === "undefined") {
276
- return {
277
- locale: "en-US",
278
- language: "en",
279
- region: "US",
280
- languages: ["en-US"],
281
- timeZone: "UTC",
282
- is24Hour: false
283
- };
284
- }
285
- const locale = navigator.language || "en-US";
286
- const [language, region] = locale.split("-");
287
- const languages = [...navigator.languages || [locale]];
288
- const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
289
- const is24Hour = detect24Hour(locale);
290
- return {
291
- locale,
292
- language: language || "en",
293
- region: region?.toUpperCase() || "US",
294
- languages,
295
- timeZone,
296
- is24Hour
464
+ function useActiveLocale() {
465
+ return useContext(LocaleContext)?.locale ?? "en";
466
+ }
467
+ function useTranslation() {
468
+ const ctx = useContext(LocaleContext);
469
+ if (!ctx) throw new Error("useTranslation must be used inside <FunnelProvider>");
470
+ const { locale, setLocale, config, catalog } = ctx;
471
+ const chain = useMemo(() => {
472
+ const def = config?.default;
473
+ const fb = config?.fallback;
474
+ return Array.from(new Set([locale, fb, def].filter(Boolean)));
475
+ }, [locale, config]);
476
+ const t = useMemo(() => {
477
+ const fn = ((key, params) => {
478
+ const raw = lookup(catalog, chain, key);
479
+ if (raw === void 0) {
480
+ devWarn(`[appfunnel] missing translation: "${key}" (${locale})`);
481
+ return key;
482
+ }
483
+ return interpolate(raw, params);
484
+ });
485
+ fn.plural = (n, forms, params) => {
486
+ const rule = new Intl.PluralRules(locale).select(n);
487
+ const form = forms[rule] ?? forms.other ?? "";
488
+ return interpolate(form.replace(/#/g, String(n)), params);
297
489
  };
298
- }, []);
490
+ return fn;
491
+ }, [catalog, chain, locale]);
492
+ const fmt = useMemo(
493
+ () => ({
494
+ number: (n, opts) => new Intl.NumberFormat(locale, opts).format(n),
495
+ date: (d, opts) => new Intl.DateTimeFormat(locale, opts).format(d),
496
+ percent: (n, opts) => new Intl.NumberFormat(locale, { style: "percent", ...opts }).format(n)
497
+ }),
498
+ [locale]
499
+ );
500
+ return {
501
+ t,
502
+ fmt,
503
+ locale,
504
+ setLocale,
505
+ dir: isRtl(locale) ? "rtl" : "ltr",
506
+ locales: config?.supported ?? [locale]
507
+ };
508
+ }
509
+
510
+ // src/commerce/catalog.ts
511
+ var PERIOD_DAYS = {
512
+ day: 1,
513
+ week: 7,
514
+ month: 30,
515
+ year: 365,
516
+ one_time: 0
517
+ };
518
+ function periodDays(interval, count) {
519
+ return PERIOD_DAYS[interval] * count;
520
+ }
521
+ function intervalLabel(interval, count) {
522
+ if (interval === "one_time") return { period: "one-time", periodly: "one-time" };
523
+ if (interval === "month" && count === 3) return { period: "quarter", periodly: "quarterly" };
524
+ if (interval === "month" && count === 6) return { period: "6 months", periodly: "semiannually" };
525
+ if (interval === "week" && count === 2) return { period: "2 weeks", periodly: "biweekly" };
526
+ if (count === 1) {
527
+ const periodly = { day: "daily", week: "weekly", month: "monthly", year: "yearly" }[interval];
528
+ return { period: interval, periodly };
529
+ }
530
+ return { period: `${count} ${interval}s`, periodly: `every ${count} ${interval}s` };
531
+ }
532
+ function resolveProduct(input) {
533
+ const interval = input.interval ?? "one_time";
534
+ const count = input.intervalCount ?? 1;
535
+ const days = periodDays(interval, count);
536
+ const perDayMinor = days > 0 ? input.amount / days : input.amount;
537
+ const label = intervalLabel(interval, count);
538
+ return {
539
+ id: input.id,
540
+ name: input.name ?? input.id,
541
+ displayName: input.displayName ?? input.name ?? input.id,
542
+ provider: input.provider ?? "stripe",
543
+ currency: input.currency,
544
+ priceMinor: input.amount,
545
+ perDayMinor,
546
+ perWeekMinor: perDayMinor * 7,
547
+ perMonthMinor: perDayMinor * 30,
548
+ perYearMinor: perDayMinor * 365,
549
+ period: label.period,
550
+ periodly: label.periodly,
551
+ hasTrial: (input.trialDays ?? 0) > 0,
552
+ trialDays: input.trialDays ?? 0,
553
+ trialMinor: input.trialAmount ?? 0
554
+ };
555
+ }
556
+ function buildCatalog(inputs) {
557
+ return new Map(inputs.map((i) => [i.id, resolveProduct(i)]));
299
558
  }
300
- function detect24Hour(locale) {
559
+ function formatMoney(minor, currency, locale) {
560
+ const amount = minor / 100;
561
+ const code = currency.toUpperCase();
562
+ let formatted;
301
563
  try {
302
- const formatted = new Intl.DateTimeFormat(locale, { hour: "numeric" }).format(/* @__PURE__ */ new Date());
303
- return !formatted.match(/am|pm/i);
564
+ formatted = new Intl.NumberFormat(locale, { style: "currency", currency: code }).format(amount);
304
565
  } catch {
305
- return false;
566
+ formatted = `${code} ${amount.toFixed(2)}`;
306
567
  }
568
+ return { amount, minor, currency: code, formatted };
307
569
  }
308
- function useTranslation() {
309
- const { i18n } = useFunnelContext();
310
- const subscribe = useCallback(
311
- (cb) => i18n.subscribe(cb),
312
- [i18n]
570
+ function formatProduct(r, locale) {
571
+ const m = (minor) => formatMoney(minor, r.currency, locale);
572
+ return {
573
+ id: r.id,
574
+ name: r.name,
575
+ displayName: r.displayName,
576
+ provider: r.provider,
577
+ price: m(r.priceMinor),
578
+ period: r.period,
579
+ periodly: r.periodly,
580
+ perDay: m(r.perDayMinor),
581
+ perWeek: m(r.perWeekMinor),
582
+ perMonth: m(r.perMonthMinor),
583
+ perYear: m(r.perYearMinor),
584
+ hasTrial: r.hasTrial,
585
+ trialDays: r.trialDays,
586
+ trialPrice: r.hasTrial ? m(r.trialMinor) : null
587
+ };
588
+ }
589
+ var CatalogContext = createContext(null);
590
+ function CatalogProvider({
591
+ catalog,
592
+ children
593
+ }) {
594
+ return createElement(CatalogContext.Provider, { value: catalog }, children);
595
+ }
596
+ function useCatalog() {
597
+ return useContext(CatalogContext) ?? EMPTY;
598
+ }
599
+ var EMPTY = /* @__PURE__ */ new Map();
600
+ function useProduct(id) {
601
+ const catalog = useCatalog();
602
+ const locale = useActiveLocale();
603
+ const resolved = id ? catalog.get(id) : void 0;
604
+ return useMemo(() => resolved ? formatProduct(resolved, locale) : void 0, [resolved, locale]);
605
+ }
606
+ function useProducts() {
607
+ const catalog = useCatalog();
608
+ const locale = useActiveLocale();
609
+ return useMemo(
610
+ () => Array.from(catalog.values()).map((r) => formatProduct(r, locale)),
611
+ [catalog, locale]
313
612
  );
314
- const getSnapshot = useCallback(
315
- () => i18n.getLocale(),
316
- [i18n]
613
+ }
614
+
615
+ // src/flow/spine.ts
616
+ function readField(s, field) {
617
+ let cur = s;
618
+ for (const part of field.split(".")) {
619
+ if (cur == null) return void 0;
620
+ cur = cur[part];
621
+ }
622
+ return cur;
623
+ }
624
+ function evaluateCondition(cond, s) {
625
+ const val = readField(s, cond.field);
626
+ const { op, value } = cond;
627
+ switch (op) {
628
+ case "eq":
629
+ return val === value;
630
+ case "neq":
631
+ return val !== value;
632
+ case "in":
633
+ return Array.isArray(value) && value.includes(val);
634
+ case "gt":
635
+ return typeof val === "number" && typeof value === "number" && val > value;
636
+ case "gte":
637
+ return typeof val === "number" && typeof value === "number" && val >= value;
638
+ case "lt":
639
+ return typeof val === "number" && typeof value === "number" && val < value;
640
+ case "lte":
641
+ return typeof val === "number" && typeof value === "number" && val <= value;
642
+ case "contains":
643
+ if (typeof val === "string") return val.includes(String(value));
644
+ if (Array.isArray(val)) return val.includes(value);
645
+ return false;
646
+ case "exists":
647
+ return val !== void 0 && val !== null && val !== "";
648
+ case "empty":
649
+ return val === void 0 || val === null || val === "" || Array.isArray(val) && val.length === 0;
650
+ default:
651
+ return false;
652
+ }
653
+ }
654
+ function evaluateGate(gate, s) {
655
+ return typeof gate === "function" ? gate(s) : evaluateCondition(gate, s);
656
+ }
657
+ function resolveRoute(routes, s) {
658
+ if (!routes) return void 0;
659
+ for (const route of routes) {
660
+ if (!route.when || evaluateGate(route.when, s)) return route.to;
661
+ }
662
+ return void 0;
663
+ }
664
+ function pageMeta(meta) {
665
+ return meta;
666
+ }
667
+ var TYPE_GUARDS = {
668
+ paywall: { field: "user.email", op: "exists" },
669
+ upsell: { field: "user.email", op: "exists" }
670
+ };
671
+ function entryGuard(meta) {
672
+ return meta?.guard ?? (meta?.type ? TYPE_GUARDS[meta.type] : void 0);
673
+ }
674
+ function definePage(component) {
675
+ return component;
676
+ }
677
+ function nextPage(pages, currentKey, s) {
678
+ const idx = pages.findIndex((p) => p.key === currentKey);
679
+ if (idx === -1) return void 0;
680
+ const routed = resolveRoute(pages[idx].meta?.next, s);
681
+ if (routed) return routed;
682
+ return pages[idx + 1]?.key;
683
+ }
684
+ function outgoingKeys(pages, currentKey) {
685
+ const idx = pages.findIndex((p) => p.key === currentKey);
686
+ if (idx === -1) return [];
687
+ const out = /* @__PURE__ */ new Set();
688
+ for (const route of pages[idx].meta?.next ?? []) out.add(route.to);
689
+ const linear = pages[idx + 1]?.key;
690
+ if (linear) out.add(linear);
691
+ return [...out];
692
+ }
693
+ function expectedPathLength(pages, startKey, s) {
694
+ const seen = /* @__PURE__ */ new Set();
695
+ let key = startKey;
696
+ let count = 0;
697
+ while (key && !seen.has(key)) {
698
+ seen.add(key);
699
+ count++;
700
+ if (pages.find((p) => p.key === key)?.meta?.type === "finish") break;
701
+ key = nextPage(pages, key, s);
702
+ }
703
+ return count;
704
+ }
705
+ function defineFunnel(def) {
706
+ return def;
707
+ }
708
+
709
+ // src/flow/experiments.ts
710
+ function fnv1a(input) {
711
+ let h = 2166136261;
712
+ for (let i = 0; i < input.length; i++) {
713
+ h ^= input.charCodeAt(i);
714
+ h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
715
+ }
716
+ return h >>> 0;
717
+ }
718
+ function hashToUnit(seed) {
719
+ return fnv1a(seed) / 4294967296;
720
+ }
721
+ function pickByWeight(weights, u) {
722
+ const entries = Object.entries(weights).filter(([, w]) => w > 0);
723
+ if (entries.length === 0) return Object.keys(weights)[0] ?? "";
724
+ const total = entries.reduce((sum, [, w]) => sum + w, 0);
725
+ const target = u * total;
726
+ let acc = 0;
727
+ for (const [key, w] of entries) {
728
+ acc += w;
729
+ if (target < acc) return key;
730
+ }
731
+ return entries[entries.length - 1][0];
732
+ }
733
+ function assignVariant(experimentId, seed, weights) {
734
+ return pickByWeight(weights, hashToUnit(`${experimentId}:${seed}`));
735
+ }
736
+ function bucketingSeed(context) {
737
+ return context.identity.customerId ?? context.identity.visitorId ?? null;
738
+ }
739
+ function parseSlotKey(key) {
740
+ const at = key.indexOf("@");
741
+ return at === -1 ? { slot: key } : { slot: key.slice(0, at), variant: key.slice(at + 1) };
742
+ }
743
+ function isVariantKey(key) {
744
+ return parseSlotKey(key).variant !== void 0;
745
+ }
746
+ function weightsOf(exp) {
747
+ const out = {};
748
+ for (const [label, v] of Object.entries(exp.variants)) out[label] = v.weight;
749
+ return out;
750
+ }
751
+ function resolveExperiments(pages, experiments, seed) {
752
+ const assignments = {};
753
+ const render = {};
754
+ const experimentOf = {};
755
+ for (const exp of experiments) {
756
+ const status = exp.status ?? "running";
757
+ let label;
758
+ if (status === "stopped" && exp.winner) label = exp.winner;
759
+ else if (status === "running" && seed) label = assignVariant(exp.id, seed, weightsOf(exp));
760
+ else continue;
761
+ const arm = exp.variants[label];
762
+ if (!arm) continue;
763
+ assignments[exp.id] = label;
764
+ experimentOf[exp.slot] = exp.id;
765
+ if (arm.page !== exp.slot) render[exp.slot] = arm.page;
766
+ }
767
+ const slotOf = {};
768
+ const activeKeys = [];
769
+ for (const p of pages) {
770
+ const { slot, variant } = parseSlotKey(p.key);
771
+ if (variant !== void 0) slotOf[p.key] = slot;
772
+ else activeKeys.push(p.key);
773
+ }
774
+ return { assignments, activeKeys, render, slotOf, experimentOf };
775
+ }
776
+ function validateExperiments(experiments, pages) {
777
+ const errors = [];
778
+ const warnings = [];
779
+ const pageKeys = new Set(pages.map((p) => p.key));
780
+ const seenIds = /* @__PURE__ */ new Set();
781
+ const slotOwner = /* @__PURE__ */ new Map();
782
+ const renderedVariantPages = /* @__PURE__ */ new Set();
783
+ for (const exp of experiments) {
784
+ const err = (code, message) => errors.push({ experimentId: exp.id, code, message });
785
+ const warn = (code, message) => warnings.push({ experimentId: exp.id, code, message });
786
+ if (seenIds.has(exp.id)) err("duplicate_id", `Experiment id "${exp.id}" is used more than once.`);
787
+ seenIds.add(exp.id);
788
+ if (!pageKeys.has(exp.slot)) err("slot_missing", `Slot "${exp.slot}" is not a page in the funnel.`);
789
+ if (isVariantKey(exp.slot)) err("slot_is_variant", `Slot "${exp.slot}" is a variant page; a slot must be a real flow page.`);
790
+ if (slotOwner.has(exp.slot)) {
791
+ err("slot_conflict", `Slot "${exp.slot}" is already targeted by experiment "${slotOwner.get(exp.slot)}".`);
792
+ } else {
793
+ slotOwner.set(exp.slot, exp.id);
794
+ }
795
+ const arms = Object.entries(exp.variants);
796
+ if (arms.length < 2) warn("single_arm", `Experiment "${exp.id}" has fewer than two variants \u2014 nothing to test.`);
797
+ let hasControl = false;
798
+ let totalWeight = 0;
799
+ for (const [label, arm] of arms) {
800
+ if (!pageKeys.has(arm.page)) {
801
+ err("variant_page_missing", `Variant "${label}" references page "${arm.page}", which doesn't exist.`);
802
+ }
803
+ if (arm.page === exp.slot) hasControl = true;
804
+ else if (isVariantKey(arm.page)) {
805
+ renderedVariantPages.add(arm.page);
806
+ if (parseSlotKey(arm.page).slot !== exp.slot) {
807
+ err("variant_slot_mismatch", `Variant "${label}" page "${arm.page}" belongs to a different slot than "${exp.slot}".`);
808
+ }
809
+ }
810
+ if (arm.weight < 0) err("negative_weight", `Variant "${label}" has a negative weight (${arm.weight}).`);
811
+ totalWeight += arm.weight;
812
+ }
813
+ if (!hasControl) err("no_control", `No variant of "${exp.id}" renders the slot "${exp.slot}" itself (the control/baseline).`);
814
+ if (totalWeight <= 0) err("no_traffic", `Experiment "${exp.id}" has no positive weight \u2014 no traffic would enter it.`);
815
+ if (exp.status === "stopped" && exp.winner && !exp.variants[exp.winner]) {
816
+ err("bad_winner", `Stopped experiment "${exp.id}" names winner "${exp.winner}", which isn't one of its variants.`);
817
+ }
818
+ }
819
+ const running = new Set(experiments.filter((e) => (e.status ?? "running") === "running").flatMap(
820
+ (e) => Object.values(e.variants).map((v) => v.page)
821
+ ));
822
+ for (const p of pages) {
823
+ if (isVariantKey(p.key) && !running.has(p.key)) {
824
+ warnings.push({ experimentId: "*", code: "orphan_variant", message: `Variant page "${p.key}" is rendered by no running experiment.` });
825
+ }
826
+ }
827
+ return { ok: errors.length === 0, errors, warnings };
828
+ }
829
+
830
+ // src/flow/flow.tsx
831
+ var NavContext = createContext(null);
832
+ var ExperimentContext = createContext({});
833
+ function useExperiment(id) {
834
+ return useContext(ExperimentContext)[id];
835
+ }
836
+ function pageContextFor(currentKey, slug, index, total, startedAt) {
837
+ const denom = Math.max(total, index + 1);
838
+ return {
839
+ key: currentKey,
840
+ slug,
841
+ index,
842
+ total: denom,
843
+ progressPercentage: denom > 0 ? Math.min(100, Math.round((index + 1) / denom * 100)) : 0,
844
+ startedAt
845
+ };
846
+ }
847
+ function FunnelView({
848
+ pages,
849
+ initialKey,
850
+ layout,
851
+ fallback,
852
+ prefetch: prefetchMode = "auto",
853
+ onNavigate
854
+ }) {
855
+ const funnel = useFunnel();
856
+ const tracker = useTrackerRef();
857
+ const components = useMemo(() => {
858
+ const map = /* @__PURE__ */ new Map();
859
+ for (const p of pages) {
860
+ if (p.Component) map.set(p.key, p.Component);
861
+ else if (p.load) {
862
+ const load = p.load;
863
+ map.set(p.key, lazy(() => Promise.resolve(load()).then(
864
+ (m) => typeof m === "function" ? { default: m } : m
865
+ )));
866
+ }
867
+ }
868
+ return map;
869
+ }, [pages]);
870
+ const loaders = useMemo(
871
+ () => new Map(pages.filter((p) => p.load).map((p) => [p.key, p.load])),
872
+ [pages]
317
873
  );
318
- const locale = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
319
- const t = useCallback(
320
- (key, params) => i18n.t(key, params),
321
- [i18n, locale]
322
- // eslint-disable-line react-hooks/exhaustive-deps
874
+ const seed = bucketingSeed(funnel.context);
875
+ const experiments = useMemo(
876
+ () => resolveExperiments(pages, funnel.experiments, seed),
877
+ [pages, funnel.experiments, seed]
323
878
  );
324
- const setLocale = useCallback(
325
- (l) => i18n.setLocale(l),
326
- [i18n]
879
+ const pageByKey = useMemo(() => new Map(pages.map((p) => [p.key, p])), [pages]);
880
+ const flowPages = useMemo(
881
+ () => experiments.activeKeys.map((k) => pageByKey.get(k)).filter(Boolean),
882
+ [experiments, pageByKey]
327
883
  );
328
- const availableLocales = i18n.getAvailableLocales();
329
- return { t, locale, setLocale, availableLocales };
330
- }
331
- function useProducts() {
332
- const { products, variableStore, selectProduct: ctxSelect } = useFunnelContext();
333
- const subscribe = useCallback(
334
- (cb) => variableStore.subscribe(cb, { keys: ["products.selectedProductId"] }),
335
- [variableStore]
884
+ const flow = useMemo(
885
+ () => flowPages.map((p) => ({ key: p.key, meta: p.meta })),
886
+ [flowPages]
336
887
  );
337
- const getSnapshot = useCallback(
338
- () => variableStore.get("products.selectedProductId"),
339
- [variableStore]
888
+ const [history, setHistory] = useState(() => {
889
+ const start = flowPages[0]?.key;
890
+ if (!initialKey || initialKey === start) return [start].filter(Boolean);
891
+ const target = experiments.slotOf[initialKey] ?? initialKey;
892
+ const page2 = flowPages.find((p) => p.key === target);
893
+ const guard = entryGuard(page2?.meta);
894
+ const ok = page2 && (!guard || evaluateGate(guard, funnel.snapshot()));
895
+ return [ok ? target : start].filter(Boolean);
896
+ });
897
+ const currentKey = history[history.length - 1];
898
+ const index = history.length - 1;
899
+ const startKey = history[0];
900
+ const total = useMemo(
901
+ () => expectedPathLength(flow, startKey, funnel.snapshot()),
902
+ // currentKey in deps: re-trace as each step's answers settle the branch.
903
+ [flow, startKey, currentKey, funnel]
340
904
  );
341
- const selectedId = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
342
- const selected = products.find((p) => p.id === selectedId) || null;
343
- const select = useCallback((productId) => {
344
- ctxSelect(productId);
345
- }, [ctxSelect]);
346
- return { products, selected, select };
347
- }
348
- function useTracking() {
349
- const { tracker } = useFunnelContext();
350
- const track = useCallback(
351
- (eventName, data) => {
352
- tracker.track(eventName, data);
353
- },
354
- [tracker]
905
+ const slug = pageByKey.get(currentKey)?.meta?.slug ?? currentKey;
906
+ const page = useMemo(
907
+ () => pageContextFor(currentKey, slug, index, total, funnel.context.system.now),
908
+ [currentKey, slug, index, total, funnel.context.system.now]
355
909
  );
356
- return { track };
357
- }
358
- var API_BASE_URL = "https://api.appfunnel.net";
359
- var PAYMENT_KEYS = ["payment.loading", "payment.error"];
360
- function usePayment() {
361
- const { variableStore, products, campaignId, tracker, config } = useFunnelContext();
362
- const showToasts = !config.settings?.disableToasts;
363
- const subscribe = useCallback(
364
- (cb) => variableStore.subscribe(cb, { keys: PAYMENT_KEYS }),
365
- [variableStore]
910
+ funnel.context.page = page;
911
+ const started = useRef(false);
912
+ useEffect(() => {
913
+ if (started.current) return;
914
+ started.current = true;
915
+ tracker.track("funnel.start", {});
916
+ }, [tracker]);
917
+ useEffect(() => {
918
+ if (!currentKey) return;
919
+ tracker.track("page.view", { pageId: currentKey, pageKey: currentKey, isInitial: index === 0, eventId: newEventId() });
920
+ }, [tracker, currentKey, index]);
921
+ useEffect(() => {
922
+ if (currentKey && onNavigate) onNavigate(currentKey, slug);
923
+ }, [onNavigate, currentKey, slug]);
924
+ const exposed = useRef(/* @__PURE__ */ new Set());
925
+ useEffect(() => {
926
+ const expId = experiments.experimentOf[currentKey];
927
+ if (!expId || exposed.current.has(expId)) return;
928
+ exposed.current.add(expId);
929
+ tracker.track("experiment.exposure", { experimentId: expId, variant: experiments.assignments[expId] });
930
+ }, [tracker, currentKey, experiments]);
931
+ const timer = useRef(null);
932
+ const now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
933
+ const emitExit = useCallback(() => {
934
+ const t = timer.current;
935
+ if (!t) return;
936
+ const at = now();
937
+ const hidden = t.hidden + (t.hiddenSince != null ? at - t.hiddenSince : 0);
938
+ const durationMs = Math.round(at - t.enteredAt);
939
+ tracker.track("page.exit", { pageId: t.key, durationMs, activeMs: Math.max(0, Math.round(durationMs - hidden)) });
940
+ }, [tracker]);
941
+ useEffect(() => {
942
+ if (typeof document === "undefined") return;
943
+ const onVis = () => {
944
+ const t = timer.current;
945
+ if (!t) return;
946
+ if (document.visibilityState === "hidden") t.hiddenSince = now();
947
+ else if (t.hiddenSince != null) {
948
+ t.hidden += now() - t.hiddenSince;
949
+ t.hiddenSince = null;
950
+ }
951
+ };
952
+ document.addEventListener("visibilitychange", onVis);
953
+ return () => document.removeEventListener("visibilitychange", onVis);
954
+ }, []);
955
+ useEffect(() => {
956
+ if (!currentKey) return;
957
+ if (timer.current && timer.current.key !== currentKey) emitExit();
958
+ const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
959
+ timer.current = { key: currentKey, enteredAt: now(), hidden: 0, hiddenSince: hidden ? now() : null };
960
+ }, [currentKey, emitExit]);
961
+ useEffect(() => () => emitExit(), [emitExit]);
962
+ const next = useCallback(() => {
963
+ const target = nextPage(flow, currentKey, funnel.snapshot());
964
+ const resolved = target ? experiments.slotOf[target] ?? target : target;
965
+ if (resolved) setHistory((h) => [...h, resolved]);
966
+ }, [flow, currentKey, funnel, experiments]);
967
+ const back = useCallback(
968
+ () => setHistory((h) => h.length > 1 ? h.slice(0, -1) : h),
969
+ []
366
970
  );
367
- const getSnapshot = useCallback(
368
- () => variableStore.getState(),
369
- [variableStore]
971
+ const go = useCallback(
972
+ (key) => setHistory((h) => [...h, experiments.slotOf[key] ?? key]),
973
+ [experiments]
370
974
  );
371
- const variables = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
372
- const purchase = useCallback(
373
- async (productId, options) => {
374
- console.log("[Purchase] Starting purchase for product:", productId);
375
- if (globalThis.__APPFUNNEL_DEV__) {
376
- console.log(
377
- "[Purchase] Dev mode \u2014 simulating success (500ms delay)"
378
- );
379
- variableStore.set("payment.loading", true);
380
- await new Promise((r) => setTimeout(r, 500));
381
- variableStore.set("payment.error", "");
382
- console.log("[Purchase] Dev mode \u2014 success");
383
- options?.onSuccess?.();
384
- return true;
385
- }
386
- const customerId = variableStore.get(
387
- "user.stripeCustomerId"
388
- );
389
- console.log("[Purchase] Customer ID:", customerId || "(none)");
390
- if (!customerId) {
391
- const msg = "Please complete payment authorization first";
392
- console.error("[Purchase] Failed:", msg);
393
- variableStore.set("payment.error", msg);
394
- if (showToasts) toast.error(msg);
395
- options?.onError?.(msg);
396
- return false;
397
- }
398
- const product = products.find((p) => p.id === productId);
399
- console.log(
400
- "[Purchase] Product found:",
401
- product ? {
402
- id: product.id,
403
- name: product.name,
404
- stripePriceId: product.stripePriceId
405
- } : "(not found)"
406
- );
407
- if (!product?.stripePriceId) {
408
- const msg = "Product not found or missing Stripe price";
409
- console.error("[Purchase] Failed:", msg);
410
- variableStore.set("payment.error", msg);
411
- if (showToasts) toast.error(msg);
412
- options?.onError?.(msg);
413
- return false;
414
- }
415
- const trialPeriodDays = product.hasTrial ? product.trialDays : void 0;
416
- const trialChargePriceId = product.paidTrial && product.trialStorePriceId ? product.trialStorePriceId : void 0;
417
- console.log("[Purchase] Trial config:", {
418
- trialPeriodDays,
419
- trialChargePriceId,
420
- hasTrial: product.hasTrial,
421
- paidTrial: product.paidTrial
422
- });
423
- variableStore.set("payment.loading", true);
424
- variableStore.set("payment.error", "");
425
- const requestBody = {
426
- campaignId,
427
- sessionId: tracker.getSessionId(),
428
- stripePriceId: product.stripePriceId,
429
- trialPeriodDays,
430
- trialChargePriceId
431
- };
432
- console.log(
433
- "[Purchase] Sending request:",
434
- `${API_BASE_URL}/campaign/${campaignId}/stripe/purchase`,
435
- requestBody
436
- );
437
- try {
438
- const response = await fetch(
439
- `${API_BASE_URL}/campaign/${campaignId}/stripe/purchase`,
440
- {
441
- method: "POST",
442
- headers: { "Content-Type": "application/json" },
443
- body: JSON.stringify(requestBody)
444
- }
445
- );
446
- console.log("[Purchase] Response status:", response.status);
447
- let result = await response.json();
448
- console.log("[Purchase] Response body:", result);
449
- if (!result.success && result.requiresAction) {
450
- console.log(
451
- "[Purchase] 3DS authentication required, loading Stripe..."
452
- );
453
- const stripeInstance = await loadStripe(
454
- result.publishableKey
455
- );
456
- if (!stripeInstance) {
457
- const msg = "Failed to load payment processor";
458
- console.error("[Purchase] Failed:", msg);
459
- variableStore.set("payment.error", msg);
460
- if (showToasts) toast.error(msg);
461
- options?.onError?.(msg);
462
- return false;
463
- }
464
- console.log(
465
- "[Purchase] Confirming card payment with 3DS..."
466
- );
467
- const { error: confirmError, paymentIntent: confirmedPi } = await stripeInstance.confirmCardPayment(
468
- result.clientSecret,
469
- {
470
- payment_method: result.paymentMethodId
471
- }
472
- );
473
- if (confirmError || !confirmedPi || confirmedPi.status !== "succeeded") {
474
- const msg = confirmError?.message || "Payment authentication failed";
475
- console.error("[Purchase] 3DS failed:", msg);
476
- variableStore.set("payment.error", msg);
477
- if (showToasts) toast.error(msg);
478
- options?.onError?.(msg);
479
- return false;
480
- }
481
- console.log(
482
- "[Purchase] 3DS succeeded, retrying purchase with confirmed PI:",
483
- confirmedPi.id
484
- );
485
- const retryResponse = await fetch(
486
- `${API_BASE_URL}/campaign/${campaignId}/stripe/purchase`,
487
- {
488
- method: "POST",
489
- headers: { "Content-Type": "application/json" },
490
- body: JSON.stringify({
491
- campaignId,
492
- sessionId: tracker.getSessionId(),
493
- stripePriceId: product.stripePriceId,
494
- trialPeriodDays,
495
- trialChargePriceId,
496
- onSessionPiId: confirmedPi.id
497
- })
498
- }
499
- );
500
- result = await retryResponse.json();
501
- console.log("[Purchase] Retry response:", result);
502
- }
503
- if (result.success) {
504
- console.log("[Purchase] Success! Type:", result.type);
505
- variableStore.set("payment.error", "");
506
- if (result.type === "validate_only") {
507
- console.log(
508
- "[Purchase] Validate-only \u2014 setting card variables"
509
- );
510
- if (result.card) {
511
- variableStore.setMany({
512
- "card.last4": result.card.last4,
513
- "card.brand": result.card.brand,
514
- "card.expMonth": result.card.expMonth,
515
- "card.expYear": result.card.expYear,
516
- "card.funding": result.card.funding
517
- });
518
- }
519
- if (result.eventId) {
520
- console.log(
521
- "[Purchase] Tracking purchase.complete (validate_only), eventId:",
522
- result.eventId
523
- );
524
- tracker.track("purchase.complete", {
525
- eventId: result.eventId,
526
- amount: product.rawPrice ? product.rawPrice / 100 : 0,
527
- currency: product.currencyCode || "USD"
528
- });
529
- }
530
- } else if (result.type === "one_time") {
531
- console.log(
532
- "[Purchase] One-time charge \u2014 paymentIntentId:",
533
- result.paymentIntentId
534
- );
535
- variableStore.set(
536
- "stripe.paymentIntentId",
537
- result.paymentIntentId
538
- );
539
- variableStore.set("payment.status", result.status);
540
- if (result.eventId) {
541
- console.log(
542
- "[Purchase] Tracking purchase.complete (one_time), eventId:",
543
- result.eventId
544
- );
545
- tracker.track("purchase.complete", {
546
- eventId: result.eventId,
547
- amount: product.rawPrice ? product.rawPrice / 100 : 0,
548
- currency: product.currencyCode || "USD"
549
- });
550
- }
551
- } else {
552
- console.log(
553
- "[Purchase] Subscription \u2014 subscriptionId:",
554
- result.subscriptionId,
555
- "status:",
556
- result.status
557
- );
558
- variableStore.set(
559
- "stripe.subscriptionId",
560
- result.subscriptionId
561
- );
562
- variableStore.set("subscription.status", result.status);
563
- if (result.trialCharge) {
564
- console.log(
565
- "[Purchase] Tracking trial charge purchase.complete"
566
- );
567
- tracker.track("purchase.complete", {
568
- eventId: result.eventIds?.trialCharge,
569
- amount: result.trialCharge.amount / 100,
570
- currency: "USD"
571
- });
572
- }
573
- if (result.eventIds?.subscription) {
574
- console.log(
575
- "[Purchase] Tracking subscription.created, eventId:",
576
- result.eventIds.subscription
577
- );
578
- tracker.track("subscription.created", {
579
- eventId: result.eventIds.subscription,
580
- subscriptionId: result.subscriptionId,
581
- status: result.status
582
- });
583
- }
584
- if (result.eventIds?.firstPeriod) {
585
- console.log(
586
- "[Purchase] Tracking first-period purchase.complete, eventId:",
587
- result.eventIds.firstPeriod
588
- );
589
- tracker.track("purchase.complete", {
590
- eventId: result.eventIds.firstPeriod,
591
- amount: product.rawPrice ? product.rawPrice / 100 : 0,
592
- currency: product.currencyCode || "USD"
593
- });
594
- }
595
- }
596
- console.log("[Purchase] Calling onSuccess callback");
597
- options?.onSuccess?.();
598
- return true;
599
- } else {
600
- const msg = result.error || "Purchase failed";
601
- console.error("[Purchase] Failed:", msg);
602
- variableStore.set("payment.error", msg);
603
- if (showToasts) toast.error(msg);
604
- options?.onError?.(msg);
605
- return false;
606
- }
607
- } catch (err) {
608
- const msg = err instanceof Error ? err.message : "Purchase failed";
609
- console.error("[Purchase] Exception:", err);
610
- variableStore.set("payment.error", msg);
611
- if (showToasts) toast.error(msg);
612
- options?.onError?.(msg);
613
- return false;
614
- } finally {
615
- console.log("[Purchase] Done \u2014 setting loading to false");
616
- variableStore.set("payment.loading", false);
617
- }
975
+ const prefetched = useRef(/* @__PURE__ */ new Set());
976
+ const prefetch = useCallback(
977
+ (key) => {
978
+ const target = experiments.render[key] ?? key;
979
+ const load = loaders.get(target);
980
+ if (!load || prefetched.current.has(target)) return;
981
+ prefetched.current.add(target);
982
+ load().catch(() => prefetched.current.delete(target));
618
983
  },
619
- [variableStore, products, campaignId, tracker, showToasts]
984
+ [loaders, experiments]
985
+ );
986
+ const nextCandidates = useMemo(
987
+ () => [...new Set(outgoingKeys(flow, currentKey).map((k) => experiments.render[k] ?? k))],
988
+ [flow, currentKey, experiments]
989
+ );
990
+ useEffect(() => {
991
+ if (prefetchMode !== "auto") return;
992
+ for (const key of nextCandidates) prefetch(key);
993
+ }, [prefetchMode, nextCandidates, prefetch]);
994
+ const value = {
995
+ page,
996
+ next,
997
+ back,
998
+ go,
999
+ prefetch,
1000
+ nextCandidates,
1001
+ canGoBack: history.length > 1
1002
+ };
1003
+ const renderKey = experiments.render[currentKey] ?? currentKey;
1004
+ const Current = components.get(renderKey);
1005
+ const pageEl = createElement(Suspense, { fallback: fallback ?? null }, Current ? createElement(Current) : null);
1006
+ const content = layout ? createElement(layout, null, pageEl) : pageEl;
1007
+ return createElement(
1008
+ ExperimentContext.Provider,
1009
+ { value: experiments.assignments },
1010
+ createElement(NavContext.Provider, { value }, content)
620
1011
  );
1012
+ }
1013
+ function useNavigation() {
1014
+ const ctx = useContext(NavContext);
1015
+ if (!ctx) throw new Error("useNavigation must be used inside <FunnelView>");
1016
+ return ctx;
1017
+ }
1018
+ function usePage() {
1019
+ return useNavigation().page;
1020
+ }
1021
+ var symModalId = /* @__PURE__ */ Symbol("AppFunnelModalId");
1022
+ var initialState = {};
1023
+ var ModalStateContext = createContext(initialState);
1024
+ var ModalIdContext = createContext(null);
1025
+ var REGISTRY = {};
1026
+ var ALREADY_MOUNTED = {};
1027
+ var modalCallbacks = {};
1028
+ var hideCallbacks = {};
1029
+ var uid = 0;
1030
+ var getUid = () => `_af_modal_${uid++}`;
1031
+ var dispatch = () => {
1032
+ throw new Error("No modal dispatch \u2014 render inside <FunnelProvider> (which mounts the modal runtime).");
1033
+ };
1034
+ var modalReducer = (state = initialState, action) => {
1035
+ const { modalId, args, flags } = action.payload;
1036
+ switch (action.type) {
1037
+ case "show":
1038
+ return {
1039
+ ...state,
1040
+ [modalId]: {
1041
+ ...state[modalId],
1042
+ id: modalId,
1043
+ args,
1044
+ // Mount first, then flip visible on the next effect → enter transition.
1045
+ visible: !!ALREADY_MOUNTED[modalId],
1046
+ delayVisible: !ALREADY_MOUNTED[modalId]
1047
+ }
1048
+ };
1049
+ case "hide":
1050
+ return state[modalId] ? { ...state, [modalId]: { ...state[modalId], visible: false } } : state;
1051
+ case "remove": {
1052
+ const next = { ...state };
1053
+ delete next[modalId];
1054
+ return next;
1055
+ }
1056
+ case "set-flags":
1057
+ return { ...state, [modalId]: { ...state[modalId], ...flags } };
1058
+ default:
1059
+ return state;
1060
+ }
1061
+ };
1062
+ function idOf(modal) {
1063
+ if (typeof modal === "string") return modal;
1064
+ const m = modal;
1065
+ if (!m[symModalId]) m[symModalId] = getUid();
1066
+ return m[symModalId];
1067
+ }
1068
+ function registerModal(id, comp, props) {
1069
+ if (!REGISTRY[id]) REGISTRY[id] = { comp, props };
1070
+ else REGISTRY[id].props = props;
1071
+ }
1072
+ function unregisterModal(id) {
1073
+ delete REGISTRY[id];
1074
+ }
1075
+ function showModal(modal, args) {
1076
+ const id = idOf(modal);
1077
+ if (typeof modal !== "string" && !REGISTRY[id]) registerModal(id, modal);
1078
+ dispatch({ type: "show", payload: { modalId: id, args } });
1079
+ if (!modalCallbacks[id]) {
1080
+ let res;
1081
+ let rej;
1082
+ const promise = new Promise((resolve, reject) => {
1083
+ res = resolve;
1084
+ rej = reject;
1085
+ });
1086
+ modalCallbacks[id] = { resolve: res, reject: rej, promise };
1087
+ }
1088
+ return modalCallbacks[id].promise;
1089
+ }
1090
+ function hideModal(modal) {
1091
+ const id = idOf(modal);
1092
+ dispatch({ type: "hide", payload: { modalId: id } });
1093
+ delete modalCallbacks[id];
1094
+ if (!hideCallbacks[id]) {
1095
+ let res;
1096
+ let rej;
1097
+ const promise = new Promise((resolve, reject) => {
1098
+ res = resolve;
1099
+ rej = reject;
1100
+ });
1101
+ hideCallbacks[id] = { resolve: res, reject: rej, promise };
1102
+ }
1103
+ return hideCallbacks[id].promise;
1104
+ }
1105
+ function removeModal(modal) {
1106
+ const id = idOf(modal);
1107
+ dispatch({ type: "remove", payload: { modalId: id } });
1108
+ delete modalCallbacks[id];
1109
+ delete hideCallbacks[id];
1110
+ }
1111
+ function setFlags(id, flags) {
1112
+ dispatch({ type: "set-flags", payload: { modalId: id, flags } });
1113
+ }
1114
+ function useModal(modal, args) {
1115
+ const modals = useContext(ModalStateContext);
1116
+ const contextId = useContext(ModalIdContext);
1117
+ const isComponent = !!modal && typeof modal !== "string";
1118
+ const id = modal ? idOf(modal) : contextId;
1119
+ if (!id) throw new Error("useModal: no modal id (call inside a defineModal component, or pass an id/component).");
1120
+ useEffect(() => {
1121
+ if (isComponent && !REGISTRY[id]) registerModal(id, modal, args);
1122
+ }, [isComponent, id, modal, args]);
1123
+ const info = modals[id];
1124
+ const show = useCallback((a) => showModal(id, a), [id]);
1125
+ const hide = useCallback(() => hideModal(id), [id]);
1126
+ const remove = useCallback(() => removeModal(id), [id]);
1127
+ const resolve = useCallback((v) => {
1128
+ modalCallbacks[id]?.resolve(v);
1129
+ delete modalCallbacks[id];
1130
+ }, [id]);
1131
+ const reject = useCallback((v) => {
1132
+ modalCallbacks[id]?.reject(v);
1133
+ delete modalCallbacks[id];
1134
+ }, [id]);
1135
+ const resolveHide = useCallback((v) => {
1136
+ hideCallbacks[id]?.resolve(v);
1137
+ delete hideCallbacks[id];
1138
+ }, [id]);
621
1139
  return useMemo(
622
1140
  () => ({
623
- loading: !!variables["payment.loading"],
624
- error: variables["payment.error"] || null,
625
- purchase
1141
+ id,
1142
+ args: info?.args,
1143
+ visible: !!info?.visible,
1144
+ keepMounted: !!info?.keepMounted,
1145
+ show,
1146
+ hide,
1147
+ remove,
1148
+ resolve,
1149
+ reject,
1150
+ resolveHide
626
1151
  }),
627
- [variables, purchase]
1152
+ [id, info?.args, info?.visible, info?.keepMounted, show, hide, remove, resolve, reject, resolveHide]
628
1153
  );
629
1154
  }
630
- var DEVICE_KEYS = [
631
- "os.name",
632
- "os.timezone",
633
- "device.type",
634
- "device.isMobile",
635
- "device.isTablet",
636
- "device.screenWidth",
637
- "device.screenHeight",
638
- "device.viewportWidth",
639
- "device.viewportHeight",
640
- "device.colorDepth",
641
- "device.pixelRatio",
642
- "browser.name",
643
- "browser.version",
644
- "browser.userAgent",
645
- "browser.cookieEnabled",
646
- "browser.online",
647
- "browser.language"
648
- ];
649
- function useDeviceInfo() {
650
- const { variableStore } = useFunnelContext();
651
- const subscribe = useCallback(
652
- (cb) => variableStore.subscribe(cb, { keys: DEVICE_KEYS }),
653
- [variableStore]
1155
+ function defineModal(Comp) {
1156
+ return function Wrapped({ id, defaultVisible, keepMounted, ...props }) {
1157
+ const { args, show } = useModal(id);
1158
+ const modals = useContext(ModalStateContext);
1159
+ const shouldMount = !!modals[id];
1160
+ useEffect(() => {
1161
+ if (defaultVisible) show();
1162
+ ALREADY_MOUNTED[id] = true;
1163
+ return () => {
1164
+ delete ALREADY_MOUNTED[id];
1165
+ };
1166
+ }, [id, show, defaultVisible]);
1167
+ useEffect(() => {
1168
+ if (keepMounted) setFlags(id, { keepMounted: true });
1169
+ }, [id, keepMounted]);
1170
+ const delayVisible = modals[id]?.delayVisible;
1171
+ useEffect(() => {
1172
+ if (delayVisible) show(args);
1173
+ }, [delayVisible, args, show]);
1174
+ if (!shouldMount) return null;
1175
+ return createElement(
1176
+ ModalIdContext.Provider,
1177
+ { value: id },
1178
+ createElement(Comp, { ...props, ...args })
1179
+ );
1180
+ };
1181
+ }
1182
+ function ModalRoot() {
1183
+ const modals = useContext(ModalStateContext);
1184
+ const toRender = Object.keys(modals).filter((id) => REGISTRY[id]).map((id) => ({ id, ...REGISTRY[id] }));
1185
+ return createElement(
1186
+ "div",
1187
+ { "data-appfunnel-modal-root": "" },
1188
+ ...toRender.map((t) => createElement(t.comp, { key: t.id, id: t.id, ...t.props }))
654
1189
  );
655
- const getSnapshot = useCallback(
656
- () => variableStore.getState(),
657
- [variableStore]
1190
+ }
1191
+ function ModalRuntime({ children }) {
1192
+ const [state, localDispatch] = useReducer(modalReducer, initialState);
1193
+ dispatch = localDispatch;
1194
+ return createElement(
1195
+ ModalStateContext.Provider,
1196
+ { value: state },
1197
+ children,
1198
+ createElement(ModalRoot, null)
658
1199
  );
659
- const variables = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
660
- return useMemo(() => ({
661
- os: {
662
- name: variables["os.name"] || "",
663
- timezone: variables["os.timezone"] || ""
664
- },
665
- device: {
666
- type: variables["device.type"] || "desktop",
667
- isMobile: !!variables["device.isMobile"],
668
- isTablet: !!variables["device.isTablet"],
669
- screenWidth: variables["device.screenWidth"] || 0,
670
- screenHeight: variables["device.screenHeight"] || 0,
671
- viewportWidth: variables["device.viewportWidth"] || 0,
672
- viewportHeight: variables["device.viewportHeight"] || 0,
673
- colorDepth: variables["device.colorDepth"] || 24,
674
- pixelRatio: variables["device.pixelRatio"] || 1
675
- },
676
- browser: {
677
- name: variables["browser.name"] || "",
678
- version: variables["browser.version"] || "",
679
- userAgent: variables["browser.userAgent"] || "",
680
- cookieEnabled: variables["browser.cookieEnabled"] !== false,
681
- online: variables["browser.online"] !== false,
682
- language: variables["browser.language"] || "en"
683
- }
684
- }), [variables]);
685
1200
  }
686
- function useSafeArea() {
687
- const [insets, setInsets] = useState({ top: 0, right: 0, bottom: 0, left: 0 });
688
- useEffect(() => {
689
- if (typeof window === "undefined") return;
690
- const el = document.createElement("div");
691
- el.style.cssText = [
692
- "position:fixed",
693
- "top:env(safe-area-inset-top,0px)",
694
- "right:env(safe-area-inset-right,0px)",
695
- "bottom:env(safe-area-inset-bottom,0px)",
696
- "left:env(safe-area-inset-left,0px)",
697
- "pointer-events:none",
698
- "visibility:hidden",
699
- "z-index:-1"
700
- ].join(";");
701
- document.body.appendChild(el);
702
- function read() {
703
- const style = getComputedStyle(el);
704
- setInsets({
705
- top: parseFloat(style.top) || 0,
706
- right: parseFloat(style.right) || 0,
707
- bottom: parseFloat(style.bottom) || 0,
708
- left: parseFloat(style.left) || 0
709
- });
1201
+
1202
+ // src/modals/sheet.tsx
1203
+ function useModalControl() {
1204
+ const m = useModal();
1205
+ return {
1206
+ open: m.visible,
1207
+ onClose: () => {
1208
+ m.hide();
1209
+ m.resolveHide();
1210
+ if (!m.keepMounted) m.remove();
710
1211
  }
711
- read();
712
- const observer = new ResizeObserver(read);
713
- observer.observe(el);
714
- window.addEventListener("resize", read);
715
- return () => {
716
- observer.disconnect();
717
- window.removeEventListener("resize", read);
718
- el.remove();
719
- };
720
- }, []);
721
- return insets;
1212
+ };
722
1213
  }
723
- function useKeyboard() {
724
- const [state, setState] = useState({ isOpen: false, height: 0 });
725
- const timeoutRef = useRef();
1214
+ function OverlayShell({
1215
+ open,
1216
+ onClose,
1217
+ className,
1218
+ backdropClassName,
1219
+ dismissOnBackdrop = true,
1220
+ ariaLabel,
1221
+ container,
1222
+ panel,
1223
+ children
1224
+ }) {
726
1225
  useEffect(() => {
727
- if (typeof window === "undefined") return;
728
- if ("virtualKeyboard" in navigator) {
729
- const vk = navigator.virtualKeyboard;
730
- vk.overlaysContent = true;
731
- const handler = () => {
732
- const h = vk.boundingRect.height;
733
- setState((prev) => {
734
- if (prev.height === h && prev.isOpen === h > 0) return prev;
735
- return { isOpen: h > 0, height: h };
736
- });
737
- };
738
- vk.addEventListener("geometrychange", handler);
739
- handler();
740
- return () => vk.removeEventListener("geometrychange", handler);
741
- }
742
- const vv = window.visualViewport;
743
- if (!vv) return;
744
- let layoutHeight = window.innerHeight;
745
- function compute() {
746
- const kbHeight = Math.max(0, layoutHeight - vv.height);
747
- const h = kbHeight > 40 ? Math.round(kbHeight) : 0;
748
- setState((prev) => {
749
- if (prev.height === h && prev.isOpen === h > 0) return prev;
750
- return { isOpen: h > 0, height: h };
751
- });
752
- }
753
- function onViewportResize() {
754
- compute();
755
- clearTimeout(timeoutRef.current);
756
- timeoutRef.current = setTimeout(compute, 1e3);
757
- }
758
- function onWindowResize() {
759
- layoutHeight = window.innerHeight;
760
- compute();
761
- }
762
- vv.addEventListener("resize", onViewportResize);
763
- window.addEventListener("resize", onWindowResize);
764
- window.addEventListener("orientationchange", onWindowResize);
765
- compute();
1226
+ if (!open || typeof document === "undefined") return;
1227
+ const onKey = (e) => {
1228
+ if (e.key === "Escape") onClose();
1229
+ };
1230
+ const prev = document.body.style.overflow;
1231
+ document.body.style.overflow = "hidden";
1232
+ document.addEventListener("keydown", onKey);
766
1233
  return () => {
767
- clearTimeout(timeoutRef.current);
768
- vv.removeEventListener("resize", onViewportResize);
769
- window.removeEventListener("resize", onWindowResize);
770
- window.removeEventListener("orientationchange", onWindowResize);
1234
+ document.body.style.overflow = prev;
1235
+ document.removeEventListener("keydown", onKey);
771
1236
  };
772
- }, []);
773
- return state;
774
- }
775
- var PAGE_KEYS = [
776
- "page.currentId",
777
- "page.currentIndex",
778
- "page.current",
779
- "page.total",
780
- "page.progressPercentage",
781
- "page.startedAt"
782
- ];
783
- function usePageData() {
784
- const { variableStore } = useFunnelContext();
785
- const subscribe = useCallback(
786
- (cb) => variableStore.subscribe(cb, { keys: PAGE_KEYS }),
787
- [variableStore]
788
- );
789
- const getSnapshot = useCallback(
790
- () => variableStore.getState(),
791
- [variableStore]
1237
+ }, [open, onClose]);
1238
+ if (!open || typeof document === "undefined") return null;
1239
+ const overlay = createElement(
1240
+ "div",
1241
+ {
1242
+ role: "dialog",
1243
+ "aria-modal": true,
1244
+ "aria-label": ariaLabel,
1245
+ "data-appfunnel-overlay": "",
1246
+ style: { position: "fixed", inset: 0, zIndex: 1e3, display: "flex", ...container }
1247
+ },
1248
+ createElement("div", {
1249
+ "data-appfunnel-overlay-backdrop": "",
1250
+ className: backdropClassName,
1251
+ onClick: dismissOnBackdrop ? onClose : void 0,
1252
+ style: { position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }
1253
+ }),
1254
+ createElement(
1255
+ "div",
1256
+ { "data-appfunnel-overlay-panel": "", className, style: { position: "relative", background: "#fff", ...panel } },
1257
+ children
1258
+ )
792
1259
  );
793
- const variables = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
794
- return useMemo(() => ({
795
- currentId: variables["page.currentId"] || "",
796
- currentIndex: variables["page.currentIndex"] || 0,
797
- current: variables["page.current"] || 1,
798
- total: variables["page.total"] || 0,
799
- progressPercentage: variables["page.progressPercentage"] || 0,
800
- startedAt: variables["page.startedAt"] || 0
801
- }), [variables]);
1260
+ return createPortal(overlay, document.body);
1261
+ }
1262
+ var SHEET_LAYOUT = {
1263
+ bottom: { container: { justifyContent: "flex-end", alignItems: "stretch" }, panel: { borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 16, maxHeight: "90vh", overflow: "auto" } },
1264
+ top: { container: { justifyContent: "flex-start", alignItems: "stretch" }, panel: { borderBottomLeftRadius: 16, borderBottomRightRadius: 16, padding: 16, maxHeight: "90vh", overflow: "auto" } },
1265
+ left: { container: { justifyContent: "flex-start", alignItems: "stretch" }, panel: { height: "100%", width: "min(420px, 90vw)", padding: 16, overflow: "auto" } },
1266
+ right: { container: { justifyContent: "flex-end", alignItems: "stretch" }, panel: { height: "100%", width: "min(420px, 90vw)", padding: 16, overflow: "auto" } }
1267
+ };
1268
+ function Sheet(props) {
1269
+ const control = useModalControl();
1270
+ const layout = SHEET_LAYOUT[props.side ?? "bottom"];
1271
+ return createElement(OverlayShell, {
1272
+ ...control,
1273
+ className: props.className,
1274
+ backdropClassName: props.backdropClassName,
1275
+ dismissOnBackdrop: props.dismissOnBackdrop,
1276
+ ariaLabel: props.ariaLabel,
1277
+ container: layout.container,
1278
+ panel: layout.panel,
1279
+ children: props.children
1280
+ });
1281
+ }
1282
+ function Modal(props) {
1283
+ const control = useModalControl();
1284
+ return createElement(OverlayShell, {
1285
+ ...control,
1286
+ className: props.className,
1287
+ backdropClassName: props.backdropClassName,
1288
+ dismissOnBackdrop: props.dismissOnBackdrop,
1289
+ ariaLabel: props.ariaLabel,
1290
+ container: { justifyContent: "center", alignItems: "center" },
1291
+ panel: { borderRadius: 16, padding: 24, width: "min(480px, 92vw)", maxHeight: "90vh", overflow: "auto" },
1292
+ children: props.children
1293
+ });
802
1294
  }
803
1295
 
804
- // src/hooks/useFunnel.ts
805
- function useFunnel() {
806
- const { funnelId, campaignId, tracker } = useFunnelContext();
807
- return {
808
- funnelId,
809
- campaignId,
810
- sessionId: tracker.getSessionId(),
811
- variables: useVariables(),
812
- user: useUser(),
813
- responses: useResponses(),
814
- queryParams: useQueryParams(),
815
- navigation: useNavigation(),
816
- products: useProducts(),
817
- tracking: useTracking(),
818
- payment: usePayment()
819
- };
1296
+ // src/commerce/checkout.tsx
1297
+ var INLINE_SURFACES = /* @__PURE__ */ new Set([
1298
+ "express",
1299
+ "element",
1300
+ "embedded"
1301
+ ]);
1302
+ function isInlineSurface(surface) {
1303
+ return INLINE_SURFACES.has(surface);
820
1304
  }
821
- var FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
822
- var inputStyle = (focused) => ({
823
- width: "100%",
824
- padding: "12px",
825
- border: `1px solid ${focused ? "#666" : "#e0e0e0"}`,
826
- borderRadius: "6px",
827
- fontSize: "14px",
828
- fontFamily: FONT,
829
- outline: "none",
830
- backgroundColor: "#fff",
831
- boxSizing: "border-box",
832
- transition: "border-color 0.15s"
833
- });
834
- var labelStyle = {
835
- display: "block",
836
- fontSize: "14px",
837
- fontWeight: 500,
838
- color: "#333",
839
- marginBottom: "6px",
840
- fontFamily: FONT
1305
+ var ON = { purchase: true, wallets: true, offSession: "reliable" };
1306
+ var MANAGED = { purchase: true, wallets: true, offSession: "conditional" };
1307
+ var ALL_UPSELLS = ["subscription", "one-time", "upgrade"];
1308
+ var PROVIDER_PROFILES = {
1309
+ // PSP; you hold the PaymentMethod and fire your own off-session PaymentIntent on
1310
+ // the Elements surfaces (reliable). **Managed** Checkout (`embedded`/`redirect`)
1311
+ // upsells too, but as a direct charge against Stripe's vaulted card → conditional.
1312
+ // No provider "popup" surface.
1313
+ stripe: {
1314
+ isMoR: false,
1315
+ offSessionUpsells: ALL_UPSELLS,
1316
+ tokenModel: "merchant",
1317
+ orchestrator: false,
1318
+ surfaces: { express: ON, element: ON, sheet: ON, embedded: MANAGED, redirect: MANAGED }
1319
+ },
1320
+ // Merchant-of-Record; card entry is always Paddle's frame → no own element/sheet/
1321
+ // express. Off-session via subscription one-time charge / collection_mode, but
1322
+ // **no 2nd concurrent subscription** (upgrade + one-time only), provider-initiated.
1323
+ paddle: {
1324
+ isMoR: true,
1325
+ offSessionUpsells: ["one-time", "upgrade"],
1326
+ tokenModel: "provider",
1327
+ orchestrator: false,
1328
+ surfaces: { embedded: ON, popup: ON, redirect: ON }
1329
+ },
1330
+ // Merchant-of-Record; iframe-only (no raw card field). Off-session works
1331
+ // (provider-initiated, async via webhooks). `sheet` = our chrome hosting their embed.
1332
+ whop: {
1333
+ isMoR: true,
1334
+ offSessionUpsells: ALL_UPSELLS,
1335
+ tokenModel: "provider",
1336
+ orchestrator: false,
1337
+ surfaces: { embedded: ON, redirect: ON, sheet: ON }
1338
+ },
1339
+ // Pure orchestrator (PSP-side); Headless renders express/element/sheet/embedded.
1340
+ // No hosted redirect page, no popup checkout. Off-session is PSP-conditional.
1341
+ primer: {
1342
+ isMoR: false,
1343
+ offSessionUpsells: ALL_UPSELLS,
1344
+ tokenModel: "merchant",
1345
+ orchestrator: true,
1346
+ surfaces: { express: ON, element: ON, sheet: ON, embedded: ON }
1347
+ },
1348
+ // PSP **and** orchestrator (own acquiring + connectors routing across Stripe/Adyen/
1349
+ // PayPal). One Payment Form (element/sheet/embedded) + hosted page + wallet button.
1350
+ // Strong native off-session (Recurring API). No popup.
1351
+ solidgate: {
1352
+ isMoR: false,
1353
+ offSessionUpsells: ALL_UPSELLS,
1354
+ tokenModel: "merchant",
1355
+ orchestrator: true,
1356
+ surfaces: { express: ON, element: ON, sheet: ON, embedded: ON, redirect: ON }
1357
+ }
841
1358
  };
842
- function CardBrandBadges() {
843
- return /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "4px", alignItems: "center" }, children: [
844
- /* @__PURE__ */ jsx("span", { style: { fontSize: "10px", fontWeight: 700, color: "#1a1f71", background: "#fff", border: "1px solid #e0e0e0", borderRadius: "3px", padding: "2px 4px", fontFamily: FONT }, children: "VISA" }),
845
- /* @__PURE__ */ jsx("span", { style: { fontSize: "10px", fontWeight: 700, color: "#eb001b", background: "#fff", border: "1px solid #e0e0e0", borderRadius: "3px", padding: "2px 4px", fontFamily: FONT }, children: "MC" }),
846
- /* @__PURE__ */ jsx("span", { style: { fontSize: "10px", fontWeight: 700, color: "#ff6000", background: "#fff", border: "1px solid #e0e0e0", borderRadius: "3px", padding: "2px 4px", fontFamily: FONT }, children: "DISC" })
847
- ] });
848
- }
849
- function CvcIcon() {
850
- return /* @__PURE__ */ jsxs("svg", { width: "20", height: "16", viewBox: "0 0 20 16", fill: "none", style: { opacity: 0.4 }, children: [
851
- /* @__PURE__ */ jsx("rect", { x: "0.5", y: "0.5", width: "19", height: "15", rx: "2", stroke: "#888" }),
852
- /* @__PURE__ */ jsx("rect", { x: "0", y: "4", width: "20", height: "3", fill: "#888" }),
853
- /* @__PURE__ */ jsx("rect", { x: "3", y: "10", width: "8", height: "2", rx: "1", fill: "#ccc" }),
854
- /* @__PURE__ */ jsx("text", { x: "14", y: "12", fontSize: "6", fill: "#888", fontFamily: FONT, children: "123" })
855
- ] });
856
- }
857
- function DemoElementsForm({ onReady }) {
858
- const [cardNumber, setCardNumber] = useState("");
859
- const [expiry, setExpiry] = useState("");
860
- const [cvc, setCvc] = useState("");
861
- const [country, setCountry] = useState("Denmark");
862
- const [focusedField, setFocusedField] = useState(null);
863
- const readyFired = useRef(false);
864
- useEffect(() => {
865
- if (!readyFired.current) {
866
- readyFired.current = true;
867
- onReady?.();
868
- }
869
- }, [onReady]);
870
- const formatCardNumber = (val) => {
871
- const digits = val.replace(/\D/g, "").slice(0, 16);
872
- return digits.replace(/(.{4})/g, "$1 ").trim();
873
- };
874
- const formatExpiry = (val) => {
875
- const digits = val.replace(/\D/g, "").slice(0, 4);
876
- if (digits.length > 2) return digits.slice(0, 2) + " / " + digits.slice(2);
877
- return digits;
878
- };
879
- return /* @__PURE__ */ jsxs("div", { style: { fontFamily: FONT }, children: [
880
- /* @__PURE__ */ jsxs("div", { style: { marginBottom: "14px" }, children: [
881
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Card number" }),
882
- /* @__PURE__ */ jsxs("div", { style: { position: "relative" }, children: [
883
- /* @__PURE__ */ jsx(
884
- "input",
885
- {
886
- type: "text",
887
- value: cardNumber,
888
- onChange: (e) => setCardNumber(formatCardNumber(e.target.value)),
889
- onFocus: () => setFocusedField("card"),
890
- onBlur: () => setFocusedField(null),
891
- placeholder: "1234 1234 1234 1234",
892
- style: inputStyle(focusedField === "card")
893
- }
894
- ),
895
- /* @__PURE__ */ jsx("div", { style: { position: "absolute", right: "12px", top: "50%", transform: "translateY(-50%)" }, children: /* @__PURE__ */ jsx(CardBrandBadges, {}) })
896
- ] })
897
- ] }),
898
- /* @__PURE__ */ jsxs("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px", marginBottom: "14px" }, children: [
899
- /* @__PURE__ */ jsxs("div", { children: [
900
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Expiry date" }),
901
- /* @__PURE__ */ jsx(
902
- "input",
903
- {
904
- type: "text",
905
- value: expiry,
906
- onChange: (e) => setExpiry(formatExpiry(e.target.value)),
907
- onFocus: () => setFocusedField("expiry"),
908
- onBlur: () => setFocusedField(null),
909
- placeholder: "MM / YY",
910
- style: inputStyle(focusedField === "expiry")
911
- }
912
- )
913
- ] }),
914
- /* @__PURE__ */ jsxs("div", { children: [
915
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Security code" }),
916
- /* @__PURE__ */ jsxs("div", { style: { position: "relative" }, children: [
917
- /* @__PURE__ */ jsx(
918
- "input",
919
- {
920
- type: "text",
921
- value: cvc,
922
- onChange: (e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, 4)),
923
- onFocus: () => setFocusedField("cvc"),
924
- onBlur: () => setFocusedField(null),
925
- placeholder: "CVC",
926
- style: inputStyle(focusedField === "cvc")
927
- }
928
- ),
929
- /* @__PURE__ */ jsx("div", { style: { position: "absolute", right: "12px", top: "50%", transform: "translateY(-50%)" }, children: /* @__PURE__ */ jsx(CvcIcon, {}) })
930
- ] })
931
- ] })
932
- ] }),
933
- /* @__PURE__ */ jsxs("div", { style: { marginBottom: "14px" }, children: [
934
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Country" }),
935
- /* @__PURE__ */ jsxs(
936
- "select",
937
- {
938
- value: country,
939
- onChange: (e) => setCountry(e.target.value),
940
- onFocus: () => setFocusedField("country"),
941
- onBlur: () => setFocusedField(null),
942
- style: {
943
- ...inputStyle(focusedField === "country"),
944
- appearance: "none",
945
- backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23666' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E")`,
946
- backgroundRepeat: "no-repeat",
947
- backgroundPosition: "right 12px center",
948
- paddingRight: "32px"
949
- },
950
- children: [
951
- /* @__PURE__ */ jsx("option", { children: "Australia" }),
952
- /* @__PURE__ */ jsx("option", { children: "Austria" }),
953
- /* @__PURE__ */ jsx("option", { children: "Belgium" }),
954
- /* @__PURE__ */ jsx("option", { children: "Brazil" }),
955
- /* @__PURE__ */ jsx("option", { children: "Canada" }),
956
- /* @__PURE__ */ jsx("option", { children: "Denmark" }),
957
- /* @__PURE__ */ jsx("option", { children: "Finland" }),
958
- /* @__PURE__ */ jsx("option", { children: "France" }),
959
- /* @__PURE__ */ jsx("option", { children: "Germany" }),
960
- /* @__PURE__ */ jsx("option", { children: "Ireland" }),
961
- /* @__PURE__ */ jsx("option", { children: "Italy" }),
962
- /* @__PURE__ */ jsx("option", { children: "Japan" }),
963
- /* @__PURE__ */ jsx("option", { children: "Netherlands" }),
964
- /* @__PURE__ */ jsx("option", { children: "New Zealand" }),
965
- /* @__PURE__ */ jsx("option", { children: "Norway" }),
966
- /* @__PURE__ */ jsx("option", { children: "Poland" }),
967
- /* @__PURE__ */ jsx("option", { children: "Portugal" }),
968
- /* @__PURE__ */ jsx("option", { children: "Spain" }),
969
- /* @__PURE__ */ jsx("option", { children: "Sweden" }),
970
- /* @__PURE__ */ jsx("option", { children: "Switzerland" }),
971
- /* @__PURE__ */ jsx("option", { children: "United Kingdom" }),
972
- /* @__PURE__ */ jsx("option", { children: "United States" })
973
- ]
974
- }
975
- )
976
- ] }),
977
- /* @__PURE__ */ jsx("p", { style: { fontSize: "12px", color: "#6b7280", lineHeight: 1.5, margin: 0, fontFamily: FONT }, children: "By providing your card information, you allow the merchant to charge your card for future payments in accordance with their terms." })
978
- ] });
979
- }
980
- function DemoEmbeddedForm({ product, onReady }) {
981
- const readyFired = useRef(false);
982
- const [email, setEmail] = useState("");
983
- const [cardNumber, setCardNumber] = useState("");
984
- const [expiry, setExpiry] = useState("");
985
- const [cvc, setCvc] = useState("");
986
- const [name, setName] = useState("");
987
- const [country, setCountry] = useState("Denmark");
988
- const [focusedField, setFocusedField] = useState(null);
989
- useEffect(() => {
990
- if (!readyFired.current) {
991
- readyFired.current = true;
992
- onReady?.();
993
- }
994
- }, [onReady]);
995
- const formatCardNumber = (val) => {
996
- const digits = val.replace(/\D/g, "").slice(0, 16);
997
- return digits.replace(/(.{4})/g, "$1 ").trim();
998
- };
999
- const formatExpiry = (val) => {
1000
- const digits = val.replace(/\D/g, "").slice(0, 4);
1001
- if (digits.length > 2) return digits.slice(0, 2) + " / " + digits.slice(2);
1002
- return digits;
1359
+ function surfacesFor(provider) {
1360
+ return Object.keys(PROVIDER_PROFILES[provider]?.surfaces ?? {});
1361
+ }
1362
+ function isMerchantOfRecord(provider) {
1363
+ return PROVIDER_PROFILES[provider]?.isMoR ?? false;
1364
+ }
1365
+ function isOrchestrator(provider) {
1366
+ return PROVIDER_PROFILES[provider]?.orchestrator ?? false;
1367
+ }
1368
+ function validateCheckout(provider, surface) {
1369
+ const cap = PROVIDER_PROFILES[provider]?.surfaces[surface];
1370
+ if (!cap) return { ok: false, reason: `${provider} has no '${surface}' surface` };
1371
+ return cap.purchase ? { ok: true } : { ok: false, reason: `'${surface}' can't take a purchase on ${provider}` };
1372
+ }
1373
+ function validateUpsell(provider, paywallSurface, kind = "subscription") {
1374
+ const profile = PROVIDER_PROFILES[provider];
1375
+ if (!profile) return { ok: false, reason: `unknown provider '${provider}'` };
1376
+ if (!profile.offSessionUpsells.includes(kind)) {
1377
+ const can = profile.offSessionUpsells.length ? ` (it supports: ${profile.offSessionUpsells.join(", ")})` : "";
1378
+ return { ok: false, reason: `${provider} can't charge a '${kind}' upsell off-session${can}` };
1379
+ }
1380
+ const cap = profile.surfaces[paywallSurface];
1381
+ if (!cap) return { ok: false, reason: `${provider} has no '${paywallSurface}' surface to upsell after` };
1382
+ if (cap.offSession === "none") {
1383
+ return { ok: false, reason: `a purchase on '${paywallSurface}' retains no chargeable method for an upsell` };
1384
+ }
1385
+ if (cap.offSession === "conditional") {
1386
+ return { ok: true, note: `off-session upsell after ${provider} '${paywallSurface}' (managed checkout) may decline \u2014 prefer element/sheet/express` };
1387
+ }
1388
+ return { ok: true };
1389
+ }
1390
+ function checkoutError(category, message, extra = {}) {
1391
+ const retryable = extra.retryable ?? (category === "authentication_required" || category === "requires_payment_method" || category === "processing_error");
1392
+ return { category, message, declineCode: extra.declineCode, retryable };
1393
+ }
1394
+ function createMockDriver(provider = "stripe", options = {}) {
1395
+ const success = options.result ?? { ok: true, amountMinor: 0, currency: "USD" };
1396
+ const offError = typeof options.failOffSession === "object" ? options.failOffSession : checkoutError("authentication_required", "off-session charge declined (mock)");
1397
+ return {
1398
+ provider,
1399
+ start: (req) => {
1400
+ if (options.failOffSession && !req.surface) return Promise.resolve({ ok: false, error: offError });
1401
+ return Promise.resolve(success);
1402
+ },
1403
+ renderInline: (req, cb) => createElement(
1404
+ "button",
1405
+ {
1406
+ type: "button",
1407
+ "data-mock-surface": req.surface,
1408
+ onClick: () => success.ok ? cb.onSuccess(success) : cb.onError(success.error ?? checkoutError("processing_error", "failed (mock)"))
1409
+ },
1410
+ `Pay (mock ${req.surface})`
1411
+ )
1003
1412
  };
1004
- const embeddedInputStyle = (focused) => ({
1005
- width: "100%",
1006
- padding: "10px 12px",
1007
- border: "none",
1008
- borderBottom: `1px solid ${focused ? "#666" : "#e0e0e0"}`,
1009
- fontSize: "14px",
1010
- fontFamily: FONT,
1011
- outline: "none",
1012
- backgroundColor: "#fff",
1013
- boxSizing: "border-box"
1014
- });
1015
- const displayPrice = product?.hasTrial ? product.trialPrice : product?.price;
1016
- return /* @__PURE__ */ jsxs("div", { style: { fontFamily: FONT, border: "1px solid #e0e0e0", borderRadius: "12px", overflow: "hidden", background: "#fff" }, children: [
1017
- product && /* @__PURE__ */ jsxs("div", { style: { padding: "24px 20px", borderBottom: "1px solid #e0e0e0" }, children: [
1018
- /* @__PURE__ */ jsxs("div", { style: { fontSize: "14px", color: "#666", marginBottom: "4px" }, children: [
1019
- "Pay ",
1020
- product.displayName || product.name
1021
- ] }),
1022
- /* @__PURE__ */ jsxs("div", { style: { fontSize: "28px", fontWeight: 700, color: "#333", marginBottom: "4px" }, children: [
1023
- product.currencyCode?.toUpperCase(),
1024
- displayPrice
1025
- ] }),
1026
- product.hasTrial && /* @__PURE__ */ jsxs("div", { style: { fontSize: "13px", color: "#666" }, children: [
1027
- "Then ",
1028
- /* @__PURE__ */ jsxs("span", { style: { textDecoration: "underline" }, children: [
1029
- product.currencyCode?.toUpperCase(),
1030
- product.price
1031
- ] }),
1032
- " every ",
1033
- product.period
1034
- ] }),
1035
- /* @__PURE__ */ jsx(
1036
- "button",
1037
- {
1038
- type: "button",
1039
- style: {
1040
- marginTop: "12px",
1041
- padding: "6px 14px",
1042
- borderRadius: "6px",
1043
- border: "1px solid #e0e0e0",
1044
- backgroundColor: "#fff",
1045
- color: "#333",
1046
- fontSize: "13px",
1047
- cursor: "default",
1048
- fontFamily: FONT
1049
- },
1050
- children: "View details \u25BE"
1051
- }
1052
- )
1053
- ] }),
1054
- /* @__PURE__ */ jsxs("div", { style: { padding: "20px" }, children: [
1055
- /* @__PURE__ */ jsxs("div", { style: { marginBottom: "16px" }, children: [
1056
- /* @__PURE__ */ jsx("label", { style: { ...labelStyle, fontSize: "13px", color: "#666" }, children: "Email" }),
1057
- /* @__PURE__ */ jsx(
1058
- "input",
1059
- {
1060
- type: "email",
1061
- value: email,
1062
- onChange: (e) => setEmail(e.target.value),
1063
- onFocus: () => setFocusedField("email"),
1064
- onBlur: () => setFocusedField(null),
1065
- placeholder: "you@example.com",
1066
- style: embeddedInputStyle(focusedField === "email")
1067
- }
1068
- )
1069
- ] }),
1070
- /* @__PURE__ */ jsxs("div", { style: { marginBottom: "16px" }, children: [
1071
- /* @__PURE__ */ jsx("label", { style: { ...labelStyle, fontSize: "13px", color: "#666" }, children: "Card information" }),
1072
- /* @__PURE__ */ jsxs("div", { style: { border: "1px solid #e0e0e0", borderRadius: "6px", overflow: "hidden" }, children: [
1073
- /* @__PURE__ */ jsxs("div", { style: { position: "relative" }, children: [
1074
- /* @__PURE__ */ jsx(
1075
- "input",
1076
- {
1077
- type: "text",
1078
- value: cardNumber,
1079
- onChange: (e) => setCardNumber(formatCardNumber(e.target.value)),
1080
- onFocus: () => setFocusedField("card"),
1081
- onBlur: () => setFocusedField(null),
1082
- placeholder: "1234 1234 1234 1234",
1083
- style: { ...embeddedInputStyle(focusedField === "card"), padding: "12px" }
1084
- }
1085
- ),
1086
- /* @__PURE__ */ jsx("div", { style: { position: "absolute", right: "12px", top: "50%", transform: "translateY(-50%)" }, children: /* @__PURE__ */ jsx(CardBrandBadges, {}) })
1087
- ] }),
1088
- /* @__PURE__ */ jsxs("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr" }, children: [
1089
- /* @__PURE__ */ jsx(
1090
- "input",
1091
- {
1092
- type: "text",
1093
- value: expiry,
1094
- onChange: (e) => setExpiry(formatExpiry(e.target.value)),
1095
- onFocus: () => setFocusedField("expiry"),
1096
- onBlur: () => setFocusedField(null),
1097
- placeholder: "MM / YY",
1098
- style: { ...embeddedInputStyle(focusedField === "expiry"), borderRight: "1px solid #e0e0e0", padding: "12px" }
1099
- }
1100
- ),
1101
- /* @__PURE__ */ jsxs("div", { style: { position: "relative" }, children: [
1102
- /* @__PURE__ */ jsx(
1103
- "input",
1104
- {
1105
- type: "text",
1106
- value: cvc,
1107
- onChange: (e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, 4)),
1108
- onFocus: () => setFocusedField("cvc"),
1109
- onBlur: () => setFocusedField(null),
1110
- placeholder: "CVC",
1111
- style: { ...embeddedInputStyle(focusedField === "cvc"), padding: "12px" }
1112
- }
1113
- ),
1114
- /* @__PURE__ */ jsx("div", { style: { position: "absolute", right: "12px", top: "50%", transform: "translateY(-50%)" }, children: /* @__PURE__ */ jsx(CvcIcon, {}) })
1115
- ] })
1116
- ] })
1117
- ] })
1118
- ] }),
1119
- /* @__PURE__ */ jsxs("div", { style: { marginBottom: "16px" }, children: [
1120
- /* @__PURE__ */ jsx("label", { style: { ...labelStyle, fontSize: "13px", color: "#666" }, children: "Cardholder name" }),
1121
- /* @__PURE__ */ jsx(
1122
- "input",
1123
- {
1124
- type: "text",
1125
- value: name,
1126
- onChange: (e) => setName(e.target.value),
1127
- onFocus: () => setFocusedField("name"),
1128
- onBlur: () => setFocusedField(null),
1129
- placeholder: "Full name on card",
1130
- style: inputStyle(focusedField === "name")
1131
- }
1132
- )
1133
- ] }),
1134
- /* @__PURE__ */ jsxs("div", { style: { marginBottom: "20px" }, children: [
1135
- /* @__PURE__ */ jsx("label", { style: { ...labelStyle, fontSize: "13px", color: "#666" }, children: "Country or region" }),
1136
- /* @__PURE__ */ jsxs(
1137
- "select",
1138
- {
1139
- value: country,
1140
- onChange: (e) => setCountry(e.target.value),
1141
- onFocus: () => setFocusedField("country"),
1142
- onBlur: () => setFocusedField(null),
1143
- style: {
1144
- ...inputStyle(focusedField === "country"),
1145
- appearance: "none",
1146
- backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23666' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E")`,
1147
- backgroundRepeat: "no-repeat",
1148
- backgroundPosition: "right 12px center",
1149
- paddingRight: "32px"
1150
- },
1151
- children: [
1152
- /* @__PURE__ */ jsx("option", { children: "Australia" }),
1153
- /* @__PURE__ */ jsx("option", { children: "Austria" }),
1154
- /* @__PURE__ */ jsx("option", { children: "Belgium" }),
1155
- /* @__PURE__ */ jsx("option", { children: "Brazil" }),
1156
- /* @__PURE__ */ jsx("option", { children: "Canada" }),
1157
- /* @__PURE__ */ jsx("option", { children: "Denmark" }),
1158
- /* @__PURE__ */ jsx("option", { children: "Finland" }),
1159
- /* @__PURE__ */ jsx("option", { children: "France" }),
1160
- /* @__PURE__ */ jsx("option", { children: "Germany" }),
1161
- /* @__PURE__ */ jsx("option", { children: "Ireland" }),
1162
- /* @__PURE__ */ jsx("option", { children: "Italy" }),
1163
- /* @__PURE__ */ jsx("option", { children: "Japan" }),
1164
- /* @__PURE__ */ jsx("option", { children: "Netherlands" }),
1165
- /* @__PURE__ */ jsx("option", { children: "New Zealand" }),
1166
- /* @__PURE__ */ jsx("option", { children: "Norway" }),
1167
- /* @__PURE__ */ jsx("option", { children: "Poland" }),
1168
- /* @__PURE__ */ jsx("option", { children: "Portugal" }),
1169
- /* @__PURE__ */ jsx("option", { children: "Spain" }),
1170
- /* @__PURE__ */ jsx("option", { children: "Sweden" }),
1171
- /* @__PURE__ */ jsx("option", { children: "Switzerland" }),
1172
- /* @__PURE__ */ jsx("option", { children: "United Kingdom" }),
1173
- /* @__PURE__ */ jsx("option", { children: "United States" })
1174
- ]
1175
- }
1176
- )
1177
- ] }),
1178
- /* @__PURE__ */ jsx(
1179
- "button",
1180
- {
1181
- type: "button",
1182
- disabled: true,
1183
- style: {
1184
- width: "100%",
1185
- padding: "14px",
1186
- borderRadius: "6px",
1187
- border: "none",
1188
- backgroundColor: "#0570de",
1189
- color: "#fff",
1190
- fontSize: "16px",
1191
- fontWeight: 600,
1192
- cursor: "default",
1193
- fontFamily: FONT,
1194
- opacity: 0.7
1195
- },
1196
- children: "Subscribe"
1197
- }
1198
- ),
1199
- /* @__PURE__ */ jsxs("div", { style: { textAlign: "center", marginTop: "12px", fontSize: "12px", color: "#aaa" }, children: [
1200
- "Powered by ",
1201
- /* @__PURE__ */ jsx("span", { style: { fontWeight: 700, letterSpacing: "-0.3px" }, children: "stripe" })
1202
- ] })
1203
- ] })
1204
- ] });
1205
- }
1206
- var DemoStripePaymentForm = forwardRef(
1207
- function DemoStripePaymentForm2({
1208
- productId,
1209
- mode = "checkout",
1210
- variant = "elements",
1211
- onSuccess,
1212
- onError,
1213
- onReady,
1214
- className
1215
- }, ref) {
1216
- const { variableStore, tracker, products } = useFunnelContext();
1217
- const validateOnly = mode === "validate-only";
1218
- const product = useMemo(() => {
1219
- if (productId) return products.find((p) => p.id === productId) || null;
1220
- const selectedId = variableStore.get("products.selectedProductId");
1221
- return products.find((p) => p.id === selectedId) || null;
1222
- }, [productId, products, variableStore]);
1223
- const hasFiredStart = useRef(false);
1224
- useEffect(() => {
1225
- if (hasFiredStart.current) return;
1226
- hasFiredStart.current = true;
1227
- variableStore.set("payment.loading", false);
1228
- tracker.track("checkout.start", {
1229
- productId: product?.id,
1230
- priceId: product?.stripePriceId || product?.storePriceId,
1231
- productName: product?.displayName
1413
+ }
1414
+ var DriverContext = createContext(null);
1415
+ function CheckoutDriverProvider({
1416
+ driver,
1417
+ children
1418
+ }) {
1419
+ return createElement(DriverContext.Provider, { value: driver }, children);
1420
+ }
1421
+ function useDriver() {
1422
+ return useContext(DriverContext) ?? FALLBACK_DRIVER;
1423
+ }
1424
+ var FALLBACK_DRIVER = createMockDriver();
1425
+ function useCheckout(opts = {}) {
1426
+ const driver = useDriver();
1427
+ const tracker = useTrackerRef();
1428
+ const nav = useNavigation();
1429
+ const [status, setStatus] = useState("idle");
1430
+ const [error, setError] = useState(null);
1431
+ const intent = opts.intent ?? "purchase";
1432
+ const kind = opts.kind;
1433
+ const advance = opts.advanceOnSuccess ?? true;
1434
+ const succeed = useCallback(
1435
+ (result) => {
1436
+ setStatus("success");
1437
+ tracker.track("purchase.complete", {
1438
+ amount: result.amountMinor != null ? result.amountMinor / 100 : void 0,
1439
+ currency: result.currency,
1440
+ eventId: result.eventId
1441
+ // server-minted (browser↔CAPI dedup); undefined if none
1232
1442
  });
1233
- }, [product, tracker, variableStore]);
1234
- const handleSubmit = useCallback(async () => {
1235
- variableStore.set("payment.loading", true);
1236
- await new Promise((r) => setTimeout(r, 800));
1237
- try {
1238
- tracker.track("checkout.payment_added");
1239
- if (validateOnly) {
1240
- variableStore.setMany({
1241
- "card.last4": "4242",
1242
- "card.brand": "visa",
1243
- "card.expMonth": 12,
1244
- "card.expYear": 2030,
1245
- "card.funding": "credit",
1246
- "payment.error": ""
1247
- });
1248
- onSuccess?.();
1249
- return;
1250
- }
1251
- variableStore.set("payment.error", "");
1252
- tracker.track("purchase.complete", {
1253
- amount: product?.rawPrice ? product.rawPrice / 100 : 0,
1254
- currency: product?.currencyCode || "USD"
1255
- });
1256
- onSuccess?.();
1257
- } catch (err) {
1258
- const msg = err instanceof Error ? err.message : "An error occurred";
1259
- variableStore.set("payment.error", msg);
1260
- onError?.(msg);
1261
- } finally {
1262
- variableStore.set("payment.loading", false);
1263
- }
1264
- }, [validateOnly, variableStore, tracker, product, onSuccess, onError]);
1265
- useImperativeHandle(ref, () => ({ submit: handleSubmit }), [handleSubmit]);
1266
- return /* @__PURE__ */ jsx("div", { className, children: variant === "embedded" ? /* @__PURE__ */ jsx(DemoEmbeddedForm, { product, onReady }) : /* @__PURE__ */ jsx(DemoElementsForm, { onReady }) });
1267
- }
1268
- );
1269
- var API_BASE_URL2 = "https://api.appfunnel.net";
1270
- var isDevMode = () => typeof globalThis !== "undefined" && !!globalThis.__APPFUNNEL_DEV__;
1271
- var InnerPaymentForm = forwardRef(
1272
- function InnerPaymentForm2({ paymentMode, validateOnly, onSuccess, onError, onReady, layout }, ref) {
1273
- const stripe = useStripe();
1274
- const elements = useElements();
1275
- const [error, setError] = useState(null);
1276
- const { variableStore, campaignId, tracker, products, config } = useFunnelContext();
1277
- const showToasts = !config.settings?.disableToasts;
1278
- const readyFired = useRef(false);
1279
- useEffect(() => {
1280
- if (stripe && elements && !readyFired.current) {
1281
- readyFired.current = true;
1282
- onReady?.();
1283
- }
1284
- }, [stripe, elements, onReady]);
1285
- const handleSubmit = useCallback(async () => {
1286
- if (!stripe || !elements) {
1287
- const msg = "Stripe not loaded";
1288
- setError(msg);
1289
- onError?.(msg);
1290
- return;
1291
- }
1443
+ opts.onSuccess?.(result);
1444
+ if (advance) nav.next();
1445
+ },
1446
+ [tracker, nav, advance, opts]
1447
+ );
1448
+ const fail = useCallback(
1449
+ (err, product) => {
1450
+ setStatus(err.category === "authentication_required" ? "requires_action" : "error");
1451
+ setError(err);
1452
+ tracker.track("checkout.failed", {
1453
+ category: err.category,
1454
+ declineCode: err.declineCode,
1455
+ productId: product,
1456
+ intent
1457
+ });
1458
+ opts.onError?.(err);
1459
+ },
1460
+ [tracker, intent, opts]
1461
+ );
1462
+ const begin = useCallback(
1463
+ (product, surface) => {
1464
+ setStatus("loading");
1292
1465
  setError(null);
1293
- variableStore.set("payment.loading", true);
1466
+ tracker.track("checkout.start", { productId: product, surface });
1467
+ },
1468
+ [tracker]
1469
+ );
1470
+ const paymentAdded = useCallback(() => tracker.track("checkout.payment_added", {}), [tracker]);
1471
+ const open = useCallback(
1472
+ async (product, surface) => {
1473
+ begin(product, surface);
1294
1474
  try {
1295
- const confirmFn = paymentMode === "setup" ? stripe.confirmSetup : stripe.confirmPayment;
1296
- const confirmResult = await confirmFn({
1297
- elements,
1298
- redirect: "if_required",
1299
- confirmParams: { return_url: window.location.href }
1300
- });
1301
- if (confirmResult.error) {
1302
- const msg = confirmResult.error.message || "Payment failed";
1303
- setError(msg);
1304
- variableStore.set("payment.error", msg);
1305
- if (showToasts) toast.error(msg);
1306
- onError?.(msg);
1307
- return;
1308
- }
1309
- tracker.track("checkout.payment_added");
1310
- if (validateOnly) {
1311
- const piId = "paymentIntent" in confirmResult ? confirmResult.paymentIntent : void 0;
1312
- if (!piId?.id) {
1313
- const msg = "PaymentIntent not found after confirmation";
1314
- setError(msg);
1315
- variableStore.set("payment.error", msg);
1316
- if (showToasts) toast.error(msg);
1317
- onError?.(msg);
1318
- return;
1319
- }
1320
- const response2 = await fetch(
1321
- `${API_BASE_URL2}/campaign/${campaignId}/stripe/validate-card`,
1322
- {
1323
- method: "POST",
1324
- headers: { "Content-Type": "application/json" },
1325
- body: JSON.stringify({
1326
- campaignId,
1327
- sessionId: tracker.getSessionId(),
1328
- paymentIntentId: piId.id
1329
- })
1330
- }
1331
- );
1332
- const result2 = await response2.json();
1333
- if (!result2.success) {
1334
- const msg = result2.error || "Card validation failed";
1335
- setError(msg);
1336
- variableStore.set("payment.error", msg);
1337
- if (showToasts) toast.error(msg);
1338
- onError?.(msg);
1339
- return;
1340
- }
1341
- variableStore.setMany({
1342
- "card.last4": result2.card.last4,
1343
- "card.brand": result2.card.brand,
1344
- "card.expMonth": result2.card.expMonth,
1345
- "card.expYear": result2.card.expYear,
1346
- "card.funding": result2.card.funding,
1347
- "payment.error": ""
1348
- });
1349
- onSuccess?.();
1350
- return;
1351
- }
1352
- const paymentIntentId = "paymentIntent" in confirmResult ? confirmResult.paymentIntent?.id : void 0;
1353
- const selectedId = variableStore.get("products.selectedProductId");
1354
- const product = products.find((p) => p.id === selectedId);
1355
- if (!product?.stripePriceId) {
1356
- const msg = "No product selected or missing Stripe price";
1357
- setError(msg);
1358
- variableStore.set("payment.error", msg);
1359
- if (showToasts) toast.error(msg);
1360
- onError?.(msg);
1361
- return;
1362
- }
1363
- const response = await fetch(
1364
- `${API_BASE_URL2}/campaign/${campaignId}/stripe/purchase`,
1365
- {
1366
- method: "POST",
1367
- headers: { "Content-Type": "application/json" },
1368
- body: JSON.stringify({
1369
- campaignId,
1370
- sessionId: tracker.getSessionId(),
1371
- stripePriceId: product.stripePriceId,
1372
- trialPeriodDays: product.hasTrial ? product.trialDays : void 0,
1373
- onSessionPiId: paymentMode === "payment" ? paymentIntentId : void 0
1374
- })
1375
- }
1376
- );
1377
- const result = await response.json();
1378
- if (result.success) {
1379
- variableStore.set("payment.error", "");
1380
- if (result.eventId || result.eventIds?.firstPeriod) {
1381
- tracker.track("purchase.complete", {
1382
- amount: product.rawPrice ? product.rawPrice / 100 : 0,
1383
- currency: product.currencyCode || "USD"
1384
- });
1385
- }
1386
- onSuccess?.();
1387
- } else {
1388
- const msg = result.error || "Failed to process payment";
1389
- setError(msg);
1390
- variableStore.set("payment.error", msg);
1391
- if (showToasts) toast.error(msg);
1392
- onError?.(msg);
1475
+ const result = await driver.start({ product, intent, surface, kind });
1476
+ if (result.ok) succeed(result);
1477
+ else fail(result.error ?? checkoutError("unknown", "Checkout failed"), product);
1478
+ return result;
1479
+ } catch (e) {
1480
+ const err = checkoutError("processing_error", e instanceof Error ? e.message : "Checkout failed");
1481
+ fail(err, product);
1482
+ return { ok: false, error: err };
1483
+ }
1484
+ },
1485
+ [driver, intent, kind, begin, succeed, fail]
1486
+ );
1487
+ const callbacksFor = useCallback(
1488
+ (product, surface) => ({
1489
+ onStart: () => begin(product, surface),
1490
+ onSuccess: (result) => succeed(result),
1491
+ onError: (err) => fail(err, product),
1492
+ onPaymentAdded: () => paymentAdded()
1493
+ }),
1494
+ [begin, succeed, fail, paymentAdded]
1495
+ );
1496
+ const reset = useCallback(() => {
1497
+ setStatus("idle");
1498
+ setError(null);
1499
+ }, []);
1500
+ return { open, status, error, isLoading: status === "loading", reset, callbacksFor };
1501
+ }
1502
+ var CheckoutSheet = defineModal(({ product, surface, intent, onSuccess, onError }) => {
1503
+ const driver = useDriver();
1504
+ const modal = useModal();
1505
+ if (!driver.renderInline) return null;
1506
+ return createElement(Sheet, {
1507
+ children: driver.renderInline(
1508
+ { product, intent, surface },
1509
+ {
1510
+ onSuccess: (r) => {
1511
+ modal.hide();
1512
+ onSuccess(r);
1513
+ },
1514
+ onError: (e) => {
1515
+ modal.hide();
1516
+ onError(e);
1393
1517
  }
1394
- } catch (err) {
1395
- const msg = err instanceof Error ? err.message : "An error occurred";
1396
- setError(msg);
1397
- variableStore.set("payment.error", msg);
1398
- if (showToasts) toast.error(msg);
1399
- onError?.(msg);
1400
- } finally {
1401
- variableStore.set("payment.loading", false);
1402
1518
  }
1403
- }, [stripe, elements, paymentMode, validateOnly, variableStore, campaignId, tracker, products, onSuccess, onError]);
1404
- useImperativeHandle(ref, () => ({ submit: handleSubmit }), [handleSubmit]);
1405
- return /* @__PURE__ */ jsxs("div", { children: [
1406
- /* @__PURE__ */ jsx(PaymentElement, { options: { layout: layout || "tabs" } }),
1407
- error && /* @__PURE__ */ jsx("div", { style: { color: "#ef4444", fontSize: "14px", marginTop: "12px" }, children: error })
1408
- ] });
1519
+ )
1520
+ });
1521
+ });
1522
+ function trigger(onClick, { asChild, children, className, disabled }) {
1523
+ if (asChild && isValidElement(children)) {
1524
+ const child = children;
1525
+ return cloneElement(child, { onClick });
1526
+ }
1527
+ return createElement("button", { type: "button", className, disabled, onClick }, children ?? "Continue");
1528
+ }
1529
+ function Checkout({
1530
+ product,
1531
+ surface,
1532
+ asChild,
1533
+ children,
1534
+ className,
1535
+ ...options
1536
+ }) {
1537
+ const driver = useDriver();
1538
+ const checkout = useCheckout(options);
1539
+ const intent = options.intent ?? "purchase";
1540
+ if (isInlineSurface(surface)) {
1541
+ return driver.renderInline ? createElement(
1542
+ "div",
1543
+ { key: product, className },
1544
+ driver.renderInline({ product, intent, surface }, checkout.callbacksFor(product, surface))
1545
+ ) : null;
1546
+ }
1547
+ if (surface === "sheet") {
1548
+ const cb = checkout.callbacksFor(product, surface);
1549
+ const open = () => {
1550
+ cb.onStart();
1551
+ showModal(CheckoutSheet, { product, surface, intent, onSuccess: cb.onSuccess, onError: cb.onError });
1552
+ };
1553
+ return trigger(open, { asChild, children, className, disabled: checkout.isLoading });
1409
1554
  }
1410
- );
1411
- var RealStripePaymentForm = forwardRef(
1412
- function RealStripePaymentForm2({
1413
- productId,
1414
- mode = "checkout",
1415
- variant = "elements",
1416
- successPageKey,
1417
- automaticTax = false,
1418
- managedPayments = false,
1419
- allowPromotionCodes = false,
1420
- onSuccess,
1421
- onError,
1422
- onReady,
1555
+ return trigger(() => checkout.open(product, surface), {
1556
+ asChild,
1557
+ children,
1423
1558
  className,
1424
- appearance,
1425
- layout
1426
- }, ref) {
1427
- const { campaignId, tracker, variableStore, products, router, config } = useFunnelContext();
1428
- const showToasts = !config.settings?.disableToasts;
1429
- const [email] = useVariable("user.email");
1430
- const validateOnly = mode === "validate-only";
1431
- const product = useMemo(() => {
1432
- if (productId) return products.find((p) => p.id === productId) || null;
1433
- const selectedId = variableStore.get("products.selectedProductId");
1434
- return products.find((p) => p.id === selectedId) || null;
1435
- }, [productId, products, variableStore]);
1436
- const paymentMode = useMemo(() => {
1437
- if (validateOnly) return "payment";
1438
- if (product?.hasTrial && !product.paidTrial) return "setup";
1439
- return "payment";
1440
- }, [product, validateOnly]);
1441
- const [stripePromise, setStripePromise] = useState(null);
1442
- const [clientSecret, setClientSecret] = useState(null);
1443
- const [error, setError] = useState(null);
1444
- const [isLoading, setIsLoading] = useState(false);
1445
- const hasInitialized = useRef(false);
1446
- useEffect(() => {
1447
- if (!email || !campaignId || hasInitialized.current) return;
1448
- if (!product?.storePriceId) return;
1449
- hasInitialized.current = true;
1450
- setIsLoading(true);
1451
- variableStore.set("payment.loading", true);
1452
- const createIntent = async () => {
1453
- try {
1454
- if (variant === "embedded") {
1455
- const origin = typeof window !== "undefined" ? window.location.origin : "";
1456
- let returnUrl = origin + "/";
1457
- if (successPageKey) {
1458
- const pageUrl = router.getPageUrl(successPageKey);
1459
- returnUrl = `${origin}${pageUrl}?checkout=success&session_id={CHECKOUT_SESSION_ID}`;
1460
- }
1461
- const apiCheckoutMode = managedPayments ? "embedded" : variant;
1462
- const body = {
1463
- campaignId,
1464
- sessionId: tracker.getSessionId(),
1465
- customerEmail: email,
1466
- priceId: product.storePriceId,
1467
- returnUrl,
1468
- checkoutMode: apiCheckoutMode,
1469
- automaticTax,
1470
- managedPayments,
1471
- allowPromotionCodes
1472
- };
1473
- if (product.hasTrial && product.trialDays > 0) {
1474
- body.trialPeriodDays = product.trialDays;
1475
- if (product.paidTrial && product.trialStorePriceId) {
1476
- body.paidTrialPriceId = product.trialStorePriceId;
1477
- }
1478
- }
1479
- const response = await fetch(
1480
- `${API_BASE_URL2}/campaign/${campaignId}/stripe/checkout-session`,
1481
- {
1482
- method: "POST",
1483
- headers: { "Content-Type": "application/json" },
1484
- body: JSON.stringify(body)
1485
- }
1486
- );
1487
- const result = await response.json();
1488
- if (!result.success) throw new Error(result.error || "Failed to create checkout session");
1489
- setStripePromise(loadStripe(result.publishableKey));
1490
- setClientSecret(result.clientSecret);
1491
- } else {
1492
- const endpoint = paymentMode === "setup" ? `/campaign/${campaignId}/stripe/setup-intent` : `/campaign/${campaignId}/stripe/payment-intent`;
1493
- const body = {
1494
- campaignId,
1495
- sessionId: tracker.getSessionId(),
1496
- customerEmail: email,
1497
- priceId: product.storePriceId
1498
- };
1499
- if (validateOnly) body.validateOnly = true;
1500
- const response = await fetch(`${API_BASE_URL2}${endpoint}`, {
1501
- method: "POST",
1502
- headers: { "Content-Type": "application/json" },
1503
- body: JSON.stringify(body)
1504
- });
1505
- const result = await response.json();
1506
- if (!result.success) throw new Error(result.error || "Failed to create intent");
1507
- setStripePromise(loadStripe(result.publishableKey));
1508
- setClientSecret(result.clientSecret);
1509
- variableStore.set("user.stripeCustomerId", result.customerId);
1510
- }
1511
- tracker.track("checkout.start", {
1512
- productId: product.id,
1513
- priceId: product.stripePriceId || product.storePriceId,
1514
- productName: product.displayName
1515
- });
1516
- } catch (err) {
1517
- const msg = err instanceof Error ? err.message : "Failed to initialize payment";
1518
- setError(msg);
1519
- variableStore.set("payment.error", msg);
1520
- if (showToasts) toast.error(msg);
1521
- } finally {
1522
- setIsLoading(false);
1523
- variableStore.set("payment.loading", false);
1524
- }
1525
- };
1526
- createIntent();
1527
- }, [email, campaignId, product, paymentMode, validateOnly, variant, successPageKey, automaticTax, managedPayments, allowPromotionCodes, router, tracker, variableStore]);
1528
- if (isLoading) {
1529
- return /* @__PURE__ */ jsx("div", { className, style: { padding: "20px", textAlign: "center", color: "#6b7280" }, children: "Loading payment form..." });
1530
- }
1531
- if (!email) {
1532
- return /* @__PURE__ */ jsx("div", { className, style: { padding: "20px", textAlign: "center", color: "#ef4444", fontSize: "14px" }, children: "Email is required to initialize payment" });
1559
+ disabled: checkout.isLoading
1560
+ });
1561
+ }
1562
+ function Upsell({ product, fallback, asChild, children, className, ...options }) {
1563
+ const checkout = useCheckout({ ...options, intent: "upsell" });
1564
+ const start = async () => {
1565
+ const result = await checkout.open(product);
1566
+ if (result.ok || !fallback) return;
1567
+ const cb = checkout.callbacksFor(product, fallback);
1568
+ if (fallback === "sheet") {
1569
+ cb.onStart();
1570
+ showModal(CheckoutSheet, { product, surface: "sheet", intent: "upsell", onSuccess: cb.onSuccess, onError: cb.onError });
1571
+ } else {
1572
+ await checkout.open(product, fallback);
1533
1573
  }
1534
- if (error) {
1535
- return /* @__PURE__ */ jsx("div", { className, style: { padding: "20px", textAlign: "center", color: "#ef4444", fontSize: "14px" }, children: error });
1574
+ };
1575
+ return trigger(start, {
1576
+ asChild,
1577
+ children: children ?? "Add to my plan",
1578
+ className,
1579
+ disabled: checkout.isLoading
1580
+ });
1581
+ }
1582
+ function FunnelToaster(props = {}) {
1583
+ return createElement(Toaster, { position: "top-center", ...props });
1584
+ }
1585
+ function renderToaster(toaster) {
1586
+ if (toaster === false) return null;
1587
+ return createElement(Toaster, { position: "top-center", ...toaster === true ? {} : toaster });
1588
+ }
1589
+
1590
+ // src/state/namespaces.ts
1591
+ var Namespace = class {
1592
+ constructor(store, name, onWrite) {
1593
+ this.store = store;
1594
+ this.name = name;
1595
+ this.onWrite = onWrite;
1596
+ this.dot = `${NAMESPACE_PREFIX[name]}.`;
1597
+ }
1598
+ /** Fully-qualified store key for a namespace-relative key. */
1599
+ storeKey(key) {
1600
+ return this.dot + key;
1601
+ }
1602
+ /** The store-key prefix this namespace reads/writes (e.g. `user.`). */
1603
+ get prefix() {
1604
+ return this.dot;
1605
+ }
1606
+ /** Read the whole namespace as a flat object (prefix stripped). */
1607
+ getAll() {
1608
+ const out = {};
1609
+ const all = this.store.getState();
1610
+ for (const key in all) {
1611
+ if (key.startsWith(this.dot)) out[key.slice(this.dot.length)] = all[key];
1536
1612
  }
1537
- if (!stripePromise || !clientSecret) {
1538
- return /* @__PURE__ */ jsx("div", { className, style: { padding: "20px", textAlign: "center", color: "#6b7280" }, children: "Initializing payment..." });
1613
+ return out;
1614
+ }
1615
+ get(key) {
1616
+ return key === void 0 ? this.getAll() : this.store.get(this.storeKey(key));
1617
+ }
1618
+ /** Write one or more keys from a plain object (batched + side-effected). */
1619
+ set(values) {
1620
+ const updates = {};
1621
+ for (const key in values) {
1622
+ updates[this.storeKey(key)] = values[key];
1539
1623
  }
1540
- if (variant === "embedded") {
1541
- return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(
1542
- EmbeddedCheckoutProvider,
1543
- {
1544
- stripe: stripePromise,
1545
- options: { clientSecret },
1546
- children: /* @__PURE__ */ jsx(EmbeddedCheckout, {})
1547
- }
1548
- ) });
1624
+ this.store.setMany(updates);
1625
+ this.onWrite(this.name, values);
1626
+ }
1627
+ };
1628
+ function createFunnel(store, context, tracker, experiments = []) {
1629
+ const onWrite = (ns, values) => {
1630
+ if (ns === "user" && typeof values.email === "string" && values.email) {
1631
+ tracker.identify(values.email);
1549
1632
  }
1550
- const defaultAppearance = {
1551
- theme: "stripe",
1552
- variables: { colorPrimary: "#3b82f6", borderRadius: "8px" }
1633
+ tracker.setVariables?.(store.getState());
1634
+ };
1635
+ const user = new Namespace(store, "user", onWrite);
1636
+ const responses = new Namespace(store, "responses", onWrite);
1637
+ const data = new Namespace(store, "data", onWrite);
1638
+ return {
1639
+ user,
1640
+ responses,
1641
+ data,
1642
+ context,
1643
+ experiments,
1644
+ track: (event, eventData, userData) => tracker.track(event, eventData, userData),
1645
+ snapshot: () => ({
1646
+ user: user.getAll(),
1647
+ responses: responses.getAll(),
1648
+ data: data.getAll(),
1649
+ context
1650
+ })
1651
+ };
1652
+ }
1653
+ var FunnelCtx = createContext(null);
1654
+ function FunnelProvider({
1655
+ config,
1656
+ sessionValues,
1657
+ tracker,
1658
+ context: contextOpts,
1659
+ products,
1660
+ experiments,
1661
+ checkout,
1662
+ messages,
1663
+ locale: localeOverride,
1664
+ toaster = true,
1665
+ store,
1666
+ children
1667
+ }) {
1668
+ const resolvedTracker = tracker ?? FALLBACK_TRACKER;
1669
+ const [value] = useState(() => {
1670
+ const s = store ?? createFunnelStore(
1671
+ { responses: config?.responses, data: config?.data },
1672
+ sessionValues
1673
+ );
1674
+ const ctx = buildContext({
1675
+ funnelId: config?.id,
1676
+ ...contextOpts
1677
+ });
1678
+ const bus = createBus({
1679
+ getVariable: (key) => s.get(key),
1680
+ getVariables: () => s.getState(),
1681
+ getCurrentPageId: () => ctx.page.key || null,
1682
+ getCustomerId: () => ctx.identity.customerId,
1683
+ getVisitorId: () => ctx.identity.visitorId
1684
+ });
1685
+ const busTracker = withBus(resolvedTracker, bus);
1686
+ return {
1687
+ funnel: createFunnel(s, ctx, busTracker, experiments ?? []),
1688
+ store: s,
1689
+ context: ctx,
1690
+ catalog: buildCatalog(products ?? []),
1691
+ driver: checkout ?? createMockDriver(),
1692
+ tracker: busTracker,
1693
+ bus
1553
1694
  };
1554
- return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(Elements, { stripe: stripePromise, options: { clientSecret, appearance: appearance || defaultAppearance }, children: /* @__PURE__ */ jsx(
1555
- InnerPaymentForm,
1556
- {
1557
- ref,
1558
- paymentMode,
1559
- validateOnly,
1560
- onSuccess,
1561
- onError,
1562
- onReady,
1563
- layout
1564
- }
1565
- ) }) });
1695
+ });
1696
+ useEffect(() => attachBus(value.bus), [value.bus]);
1697
+ useEffect(() => {
1698
+ value.tracker.setAcquisition?.(buildAcquisition(value.context));
1699
+ }, [value]);
1700
+ const tree = createElement(TrackerProvider, {
1701
+ tracker: value.tracker,
1702
+ children: createElement(CheckoutDriverProvider, {
1703
+ driver: value.driver,
1704
+ children: createElement(CatalogProvider, {
1705
+ catalog: value.catalog,
1706
+ children: createElement(FunnelCtx.Provider, {
1707
+ value,
1708
+ children: createElement(LocaleProvider, {
1709
+ config: config?.locales,
1710
+ catalog: messages,
1711
+ detected: value.context.locale.locale,
1712
+ override: localeOverride,
1713
+ children: createElement(ModalRuntime, { children })
1714
+ })
1715
+ })
1716
+ })
1717
+ })
1718
+ });
1719
+ return createElement(Fragment, null, tree, renderToaster(toaster));
1720
+ }
1721
+ var FALLBACK_TRACKER = createConsoleTracker({ silent: true });
1722
+ function useFunnelCtx() {
1723
+ const ctx = useContext(FunnelCtx);
1724
+ if (!ctx) {
1725
+ throw new Error("useFunnel/useResponse/\u2026 must be used inside <FunnelProvider>");
1566
1726
  }
1567
- );
1568
- var StripePaymentForm = forwardRef(
1569
- function StripePaymentForm2(props, ref) {
1570
- if (isDevMode()) {
1571
- return /* @__PURE__ */ jsx(DemoStripePaymentForm, { ref, ...props });
1572
- }
1573
- return /* @__PURE__ */ jsx(RealStripePaymentForm, { ref, ...props });
1727
+ return ctx;
1728
+ }
1729
+ function useFunnel() {
1730
+ return useFunnelCtx().funnel;
1731
+ }
1732
+ function useStoreKey(store, key) {
1733
+ const subscribe = useCallback(
1734
+ (cb) => store.subscribe(cb, { keys: [key] }),
1735
+ [store, key]
1736
+ );
1737
+ const getSnapshot = useCallback(() => store.get(key), [store, key]);
1738
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1739
+ }
1740
+ function makeFieldHook(ns) {
1741
+ return function useNamespaceField(key) {
1742
+ const { funnel, store } = useFunnelCtx();
1743
+ const value = useStoreKey(store, `${NAMESPACE_PREFIX[ns]}.${key}`);
1744
+ const set = useCallback(
1745
+ (v) => funnel[ns].set({ [key]: v }),
1746
+ [funnel, key]
1747
+ );
1748
+ return [value, set];
1749
+ };
1750
+ }
1751
+ var useResponse = makeFieldHook("responses");
1752
+ var useUserAttribute = makeFieldHook("user");
1753
+ var useData = makeFieldHook("data");
1754
+ function useField(path) {
1755
+ const dot = path.indexOf(".");
1756
+ const ns = path.slice(0, dot);
1757
+ const key = path.slice(dot + 1);
1758
+ const { funnel, store } = useFunnelCtx();
1759
+ if (!(ns in NAMESPACE_PREFIX)) {
1760
+ throw new Error(`useField: "${path}" is not a writable namespace (user/responses/data)`);
1574
1761
  }
1575
- );
1576
- function PaddleCheckout({
1577
- productId,
1578
- mode = "overlay",
1579
- onSuccess,
1580
- onError,
1581
- className
1762
+ const value = useStoreKey(store, `${NAMESPACE_PREFIX[ns]}.${key}`);
1763
+ const set = useCallback(
1764
+ (v) => funnel[ns].set({ [key]: v }),
1765
+ [funnel, ns, key]
1766
+ );
1767
+ return { value, set };
1768
+ }
1769
+ function useContextObject() {
1770
+ return useFunnelCtx().context;
1771
+ }
1772
+ function useDevice() {
1773
+ return useContextObject().device;
1774
+ }
1775
+ function useLocale() {
1776
+ return useContextObject().locale;
1777
+ }
1778
+ function useUtm() {
1779
+ return useContextObject().utm;
1780
+ }
1781
+ function useClickIds() {
1782
+ return useContextObject().clickIds;
1783
+ }
1784
+ function useSystem() {
1785
+ return useContextObject().system;
1786
+ }
1787
+ function useContextValue(path) {
1788
+ const ctx = useContextObject();
1789
+ return useMemo(
1790
+ () => path.split(".").reduce((acc, k) => acc?.[k], ctx),
1791
+ [ctx, path]
1792
+ );
1793
+ }
1794
+ function Choice({
1795
+ bind,
1796
+ options,
1797
+ className,
1798
+ selectedClassName,
1799
+ advanceOnSelect
1800
+ }) {
1801
+ const { value, set } = useField(bind);
1802
+ const nav = useNavigation();
1803
+ return options.map((opt) => {
1804
+ const o = typeof opt === "string" ? { value: opt } : opt;
1805
+ const selected = value === o.value;
1806
+ return createElement(
1807
+ "button",
1808
+ {
1809
+ key: o.value,
1810
+ type: "button",
1811
+ "aria-pressed": selected,
1812
+ "data-selected": selected || void 0,
1813
+ className: selected && selectedClassName ? `${className ?? ""} ${selectedClassName}`.trim() : className,
1814
+ onClick: () => {
1815
+ set(o.value);
1816
+ if (advanceOnSelect) nav.next();
1817
+ }
1818
+ },
1819
+ o.label ?? o.value
1820
+ );
1821
+ });
1822
+ }
1823
+ function Next({ children, onClick, ...rest }) {
1824
+ const { next } = useNavigation();
1825
+ return createElement(
1826
+ "button",
1827
+ {
1828
+ type: "button",
1829
+ ...rest,
1830
+ onClick: (e) => {
1831
+ onClick?.(e);
1832
+ if (!e.defaultPrevented) next();
1833
+ }
1834
+ },
1835
+ children ?? "Continue"
1836
+ );
1837
+ }
1838
+ function Back({ children, onClick, ...rest }) {
1839
+ const { back, canGoBack } = useNavigation();
1840
+ if (!canGoBack) return null;
1841
+ return createElement(
1842
+ "button",
1843
+ {
1844
+ type: "button",
1845
+ ...rest,
1846
+ onClick: (e) => {
1847
+ onClick?.(e);
1848
+ if (!e.defaultPrevented) back();
1849
+ }
1850
+ },
1851
+ children ?? "Back"
1852
+ );
1853
+ }
1854
+
1855
+ // src/tracking/catalog.ts
1856
+ var EVENT_CATALOG = {
1857
+ // ── Runtime / navigation (JS subscriptions; not persisted) ──
1858
+ "funnel.start": { sources: ["frontend"], tracking: false, integrations: false, runtime: true },
1859
+ "experiment.exposure": { sources: ["frontend"], tracking: true, integrations: false },
1860
+ // ── Frontend, tracked; some also go to pixels ──
1861
+ "page.view": { sources: ["frontend"], tracking: true, integrations: true },
1862
+ "page.exit": { sources: ["frontend"], tracking: true, integrations: false },
1863
+ "user.registered": { sources: ["frontend"], tracking: true, integrations: true },
1864
+ "checkout.start": { sources: ["frontend"], tracking: true, integrations: true },
1865
+ "checkout.payment_added": { sources: ["frontend"], tracking: true, integrations: true },
1866
+ "checkout.failed": { sources: ["frontend"], tracking: true, integrations: false },
1867
+ // ── Backend-originated money events (server mints the eventId; client fires the pixel) ──
1868
+ "purchase.complete": { sources: ["backend"], tracking: true, integrations: true },
1869
+ "customer.first_purchase": { sources: ["backend"], tracking: true, integrations: true },
1870
+ "subscription.created": { sources: ["backend"], tracking: true, integrations: true },
1871
+ "subscription.renewal": { sources: ["backend"], tracking: true, integrations: true }
1872
+ };
1873
+ function isTrackableEvent(name) {
1874
+ return EVENT_CATALOG[name]?.tracking ?? false;
1875
+ }
1876
+ function isIntegrationEvent(name) {
1877
+ return EVENT_CATALOG[name]?.integrations ?? false;
1878
+ }
1879
+ function integrationEvents() {
1880
+ return Object.keys(EVENT_CATALOG).filter((e) => EVENT_CATALOG[e].integrations);
1881
+ }
1882
+ var injected = /* @__PURE__ */ new Set();
1883
+ function Script({
1884
+ src,
1885
+ strategy = "afterInteractive",
1886
+ id,
1887
+ async = true,
1888
+ defer,
1889
+ nonce,
1890
+ attributes,
1891
+ onLoad,
1892
+ onError
1582
1893
  }) {
1583
- const { variableStore, tracker, products } = useFunnelContext();
1584
- const containerRef = useRef(null);
1585
- const initializedRef = useRef(false);
1586
- const product = productId ? products.find((p) => p.id === productId) : products.find((p) => p.id === variableStore.get("products.selectedProductId"));
1587
- const handleCheckoutComplete = useCallback(() => {
1588
- tracker.track("purchase.complete", {
1589
- amount: product?.rawPrice ? product.rawPrice / 100 : 0,
1590
- currency: product?.currencyCode || "USD"
1591
- });
1592
- onSuccess?.();
1593
- }, [tracker, product, onSuccess]);
1594
1894
  useEffect(() => {
1595
- if (initializedRef.current || !product?.paddlePriceId) return;
1596
- initializedRef.current = true;
1597
- if (!window.Paddle) {
1598
- onError?.("Paddle.js not loaded. Include the Paddle script in your HTML.");
1895
+ if (typeof document === "undefined") return;
1896
+ const key = id ?? src;
1897
+ if (injected.has(key)) {
1898
+ onLoad?.();
1599
1899
  return;
1600
1900
  }
1601
- const email = variableStore.get("user.email") || "";
1602
- if (mode === "overlay") {
1603
- window.Paddle.Checkout.open({
1604
- items: [{ priceId: product.paddlePriceId, quantity: 1 }],
1605
- customer: email ? { email } : void 0,
1606
- successCallback: handleCheckoutComplete,
1607
- closeCallback: () => {
1608
- }
1901
+ const inject = () => {
1902
+ if (injected.has(key)) return;
1903
+ injected.add(key);
1904
+ const el = document.createElement("script");
1905
+ el.src = src;
1906
+ if (id) el.id = id;
1907
+ if (async) el.async = true;
1908
+ if (defer) el.defer = true;
1909
+ if (nonce) el.nonce = nonce;
1910
+ if (attributes) for (const [k, v] of Object.entries(attributes)) el.setAttribute(k, v);
1911
+ el.addEventListener("load", () => onLoad?.());
1912
+ el.addEventListener("error", () => {
1913
+ injected.delete(key);
1914
+ onError?.();
1609
1915
  });
1610
- } else {
1611
- if (containerRef.current) {
1612
- window.Paddle.Checkout.open({
1613
- items: [{ priceId: product.paddlePriceId, quantity: 1 }],
1614
- customer: email ? { email } : void 0,
1615
- settings: {
1616
- displayMode: "inline",
1617
- frameTarget: containerRef.current.id || "paddle-checkout",
1618
- frameInitialHeight: 450,
1619
- frameStyle: "width: 100%; min-width: 312px; background-color: transparent; border: none;"
1620
- },
1621
- successCallback: handleCheckoutComplete
1622
- });
1916
+ document.head.appendChild(el);
1917
+ };
1918
+ if (strategy === "lazyOnload") {
1919
+ const ric = window.requestIdleCallback;
1920
+ if (ric) {
1921
+ ric(inject);
1922
+ } else {
1923
+ const t = setTimeout(inject, 0);
1924
+ return () => clearTimeout(t);
1623
1925
  }
1926
+ return;
1624
1927
  }
1625
- tracker.track("checkout.start", {
1626
- productId: product.id,
1627
- priceId: product.paddlePriceId,
1628
- productName: product.displayName
1629
- });
1630
- }, [product, mode, variableStore, tracker, handleCheckoutComplete, onError]);
1631
- if (!product) {
1632
- return /* @__PURE__ */ jsx("div", { className, style: { padding: "20px", textAlign: "center", color: "#ef4444", fontSize: "14px" }, children: "No product selected" });
1633
- }
1634
- if (mode === "inline") {
1635
- return /* @__PURE__ */ jsx("div", { ref: containerRef, id: "paddle-checkout", className });
1636
- }
1928
+ inject();
1929
+ }, [src, id]);
1637
1930
  return null;
1638
1931
  }
1639
1932
 
1640
- export { PaddleCheckout, StripePaymentForm, defineConfig, definePage, useData, useDateOfBirth, useDeviceInfo, useFunnel, useKeyboard, useLocale, usePageData, usePayment, useProducts, useQueryParam, useQueryParams, useSafeArea, useTracking, useTranslation, useUser, useUserProperty, useVariable, useVariables };
1933
+ // src/manifest/manifest.ts
1934
+ function isVariantNode(page) {
1935
+ return isVariantKey(page.key);
1936
+ }
1937
+ function serializeGate(when) {
1938
+ if (!when) return void 0;
1939
+ if (typeof when === "function") return { kind: "predicate" };
1940
+ return { kind: "declarative", condition: when };
1941
+ }
1942
+ function compileManifest(input) {
1943
+ const { funnel, pages } = input;
1944
+ const keys = new Set(pages.map((p) => p.key));
1945
+ const manifestPages = pages.map((p, index) => ({
1946
+ id: p.key,
1947
+ key: p.key,
1948
+ type: p.meta?.type ?? "default",
1949
+ slug: p.meta?.slug ?? p.key,
1950
+ index,
1951
+ variantOf: isVariantKey(p.key) ? parseSlotKey(p.key).slot : void 0
1952
+ }));
1953
+ const edges = [];
1954
+ const badTargets = [];
1955
+ const nextAnchor = (from) => {
1956
+ for (let j = from + 1; j < pages.length; j++) {
1957
+ if (!isVariantNode(pages[j])) return pages[j];
1958
+ }
1959
+ return void 0;
1960
+ };
1961
+ pages.forEach((p, i) => {
1962
+ if (isVariantNode(p)) return;
1963
+ const routes = p.meta?.next ?? [];
1964
+ let hasUnconditional = false;
1965
+ for (const route of routes) {
1966
+ if (!keys.has(route.to)) {
1967
+ badTargets.push({ from: p.key, to: route.to });
1968
+ continue;
1969
+ }
1970
+ edges.push({ from: p.key, to: route.to, kind: "branch", condition: serializeGate(route.when) });
1971
+ if (!route.when) hasUnconditional = true;
1972
+ }
1973
+ const next = nextAnchor(i);
1974
+ if (!hasUnconditional && next && p.meta?.type !== "finish") {
1975
+ edges.push({ from: p.key, to: next.key, kind: "linear" });
1976
+ }
1977
+ });
1978
+ const startPage = pages.find((p) => !isVariantNode(p));
1979
+ const start = startPage?.key ?? null;
1980
+ const variantKeys = new Set(pages.filter(isVariantNode).map((p) => p.key));
1981
+ const validation = validate(manifestPages, edges, badTargets, start, variantKeys);
1982
+ return {
1983
+ id: funnel.id,
1984
+ pages: manifestPages,
1985
+ flow: { start, nodes: manifestPages.map((p) => p.id), edges },
1986
+ products: funnel.products ?? [],
1987
+ locales: funnel.locales,
1988
+ validation
1989
+ };
1990
+ }
1991
+ function validate(pages, edges, badTargets, start, variantKeys) {
1992
+ const outgoing = /* @__PURE__ */ new Map();
1993
+ for (const e of edges) outgoing.set(e.from, (outgoing.get(e.from) ?? 0) + 1);
1994
+ const deadEnds = pages.filter((p) => p.type !== "finish" && !variantKeys.has(p.id) && !(outgoing.get(p.id) > 0)).map((p) => p.id);
1995
+ const reachable = /* @__PURE__ */ new Set();
1996
+ if (start) {
1997
+ const adj = /* @__PURE__ */ new Map();
1998
+ for (const e of edges) {
1999
+ const list = adj.get(e.from);
2000
+ if (list) list.push(e.to);
2001
+ else adj.set(e.from, [e.to]);
2002
+ }
2003
+ const stack = [start];
2004
+ while (stack.length) {
2005
+ const id = stack.pop();
2006
+ if (reachable.has(id)) continue;
2007
+ reachable.add(id);
2008
+ for (const to of adj.get(id) ?? []) stack.push(to);
2009
+ }
2010
+ }
2011
+ const unreachable = pages.filter((p) => !variantKeys.has(p.id) && !reachable.has(p.id)).map((p) => p.id);
2012
+ const missingEmailCapture = !pages.some((p) => p.type === "email-capture");
2013
+ return {
2014
+ ok: deadEnds.length === 0 && unreachable.length === 0 && badTargets.length === 0 && !missingEmailCapture,
2015
+ deadEnds,
2016
+ unreachable,
2017
+ badTargets,
2018
+ missingEmailCapture
2019
+ };
2020
+ }
2021
+
2022
+ export { Back, Checkout, Choice, EVENT_CATALOG, FunnelProvider, FunnelToaster, FunnelView, INLINE_SURFACES, Modal, Next, PROVIDER_PROFILES, Script, Sheet, Upsell, 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 };
1641
2023
  //# sourceMappingURL=index.js.map
1642
2024
  //# sourceMappingURL=index.js.map