@appfunnel-dev/sdk 2.0.0-canary.3 → 2.0.0-canary.5

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.
Files changed (53) hide show
  1. package/dist/{chunk-AKO6XKXP.js → chunk-AXXG3ODP.js} +10 -3
  2. package/dist/chunk-AXXG3ODP.js.map +1 -0
  3. package/dist/{chunk-M6U3FNRW.js → chunk-CFFMZYPE.js} +3 -3
  4. package/dist/{chunk-M6U3FNRW.js.map → chunk-CFFMZYPE.js.map} +1 -1
  5. package/dist/chunk-EMMSS5I5.cjs +37 -0
  6. package/dist/chunk-EMMSS5I5.cjs.map +1 -0
  7. package/dist/chunk-G3PMV62Z.js +33 -0
  8. package/dist/chunk-G3PMV62Z.js.map +1 -0
  9. package/dist/{chunk-JSRKA375.cjs → chunk-G44BUG6G.cjs} +10 -3
  10. package/dist/chunk-G44BUG6G.cjs.map +1 -0
  11. package/dist/{chunk-LJYLGLFS.cjs → chunk-QMAZGLGV.cjs} +2 -35
  12. package/dist/chunk-QMAZGLGV.cjs.map +1 -0
  13. package/dist/{chunk-YY375F2B.js → chunk-TNLRQPVY.js} +3 -33
  14. package/dist/chunk-TNLRQPVY.js.map +1 -0
  15. package/dist/{chunk-CY4VBSMX.cjs → chunk-VAOR77NW.cjs} +7 -7
  16. package/dist/{chunk-CY4VBSMX.cjs.map → chunk-VAOR77NW.cjs.map} +1 -1
  17. package/dist/chunk-WYUDL4FI.cjs +8 -0
  18. package/dist/chunk-WYUDL4FI.cjs.map +1 -0
  19. package/dist/chunk-ZZJG4EYL.js +6 -0
  20. package/dist/chunk-ZZJG4EYL.js.map +1 -0
  21. package/dist/driver-paddle.cjs +23 -22
  22. package/dist/driver-paddle.cjs.map +1 -1
  23. package/dist/driver-paddle.js +3 -2
  24. package/dist/driver-paddle.js.map +1 -1
  25. package/dist/driver-stripe.cjs +37 -36
  26. package/dist/driver-stripe.cjs.map +1 -1
  27. package/dist/driver-stripe.js +3 -2
  28. package/dist/driver-stripe.js.map +1 -1
  29. package/dist/index.cjs +541 -91
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.cts +151 -75
  32. package/dist/index.d.ts +151 -75
  33. package/dist/index.js +484 -34
  34. package/dist/index.js.map +1 -1
  35. package/dist/{manifest-Cr2y1op6.d.cts → manifest-Dl7faaKe.d.cts} +34 -0
  36. package/dist/{manifest-Cr2y1op6.d.ts → manifest-Dl7faaKe.d.ts} +34 -0
  37. package/dist/manifest-entry.cjs +45 -30
  38. package/dist/manifest-entry.cjs.map +1 -1
  39. package/dist/manifest-entry.d.cts +1 -1
  40. package/dist/manifest-entry.d.ts +1 -1
  41. package/dist/manifest-entry.js +10 -3
  42. package/dist/manifest-entry.js.map +1 -1
  43. package/dist/protocol.cjs +13 -0
  44. package/dist/protocol.cjs.map +1 -0
  45. package/dist/protocol.d.cts +182 -0
  46. package/dist/protocol.d.ts +182 -0
  47. package/dist/protocol.js +4 -0
  48. package/dist/protocol.js.map +1 -0
  49. package/package.json +14 -2
  50. package/dist/chunk-AKO6XKXP.js.map +0 -1
  51. package/dist/chunk-JSRKA375.cjs.map +0 -1
  52. package/dist/chunk-LJYLGLFS.cjs.map +0 -1
  53. package/dist/chunk-YY375F2B.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
- import { isRtl, formatProduct, bucketingSeed, resolveExperiments, entryGuard, evaluateGate, expectedPathLength, nextPage, outgoingKeys, buildCatalog, resolveLocale } from './chunk-AKO6XKXP.js';
2
- export { assignVariant, bucketingSeed, buildCatalog, compileManifest, currencyExponent, defineFunnel, definePage, entryGuard, evaluateCondition, evaluateGate, expectedPathLength, fnv1a, formatMoney, formatProduct, hashToUnit, isRtl, isVariantKey, nextPage, outgoingKeys, pageMeta, parseSlotKey, pickByWeight, resolveExperiments, resolveLocale, resolveProduct, resolveRoute, validateExperiments } from './chunk-AKO6XKXP.js';
3
- import { checkoutError, isInlineSurface, PROVIDER_PROFILES } from './chunk-YY375F2B.js';
4
- export { INLINE_SURFACES, PROVIDER_PROFILES, checkoutError, isInlineSurface, isMerchantOfRecord, isOrchestrator, surfacesFor, validateCheckout, validateUpsell } from './chunk-YY375F2B.js';
1
+ import { isRtl, formatProduct, bucketingSeed, resolveExperiments, entryGuard, evaluateGate, expectedPathLength, nextPage, outgoingKeys, buildCatalog, resolveLocale } from './chunk-AXXG3ODP.js';
2
+ export { assignVariant, bucketingSeed, buildCatalog, compileManifest, currencyExponent, defineFunnel, definePage, entryGuard, evaluateCondition, evaluateGate, expectedPathLength, fnv1a, formatMoney, formatProduct, hashToUnit, isRtl, isVariantKey, nextPage, outgoingKeys, pageMeta, parseSlotKey, pickByWeight, resolveExperiments, resolveLocale, resolveProduct, resolveRoute, validateExperiments } from './chunk-AXXG3ODP.js';
3
+ import { AF_PROTOCOL } from './chunk-ZZJG4EYL.js';
4
+ import { checkoutError, isInlineSurface, PROVIDER_PROFILES } from './chunk-TNLRQPVY.js';
5
+ export { INLINE_SURFACES, PROVIDER_PROFILES, checkoutError, isInlineSurface, isMerchantOfRecord, isOrchestrator, surfacesFor, validateCheckout, validateUpsell } from './chunk-TNLRQPVY.js';
6
+ import './chunk-G3PMV62Z.js';
5
7
  import React, { createContext, createElement, useCallback, useContext, useMemo, lazy, useRef, useState, useEffect, useTransition, Suspense, Fragment, isValidElement, cloneElement, useReducer, useSyncExternalStore } from 'react';
6
8
  import ReactDOM, { createPortal } from 'react-dom';
7
9
  import { jsx } from 'react/jsx-runtime';
@@ -380,7 +382,27 @@ function createBus(accessors, now2 = () => Date.now()) {
380
382
  }
381
383
  }
382
384
  };
383
- return { on, off, events, emit, ...accessors };
385
+ let nav = null;
386
+ let warnedNoNav = false;
387
+ const currentNav = () => {
388
+ if (!nav && !warnedNoNav) {
389
+ warnedNoNav = true;
390
+ console.warn("[appfunnel] window.appfunnel navigation called before the funnel flow mounted \u2014 ignored");
391
+ }
392
+ return nav;
393
+ };
394
+ const registerNavigation = (n) => {
395
+ nav = n;
396
+ warnedNoNav = false;
397
+ return () => {
398
+ if (nav === n) nav = null;
399
+ };
400
+ };
401
+ const goTo = (pageKey) => currentNav()?.goTo(pageKey) ?? false;
402
+ const goNext = () => currentNav()?.goNext();
403
+ const goPrevious = () => currentNav()?.goPrevious();
404
+ const navigation = () => nav?.snapshot() ?? null;
405
+ return { on, off, events, emit, registerNavigation, goTo, goNext, goPrevious, navigation, ...accessors };
384
406
  }
385
407
  function withBus(tracker, bus) {
386
408
  return {
@@ -402,7 +424,19 @@ function withBus(tracker, bus) {
402
424
  function attachBus(bus) {
403
425
  if (typeof window === "undefined") return () => {
404
426
  };
405
- const { on, off, getVariable, getVariables, getCurrentPageId, getCustomerId, getVisitorId } = bus;
427
+ const {
428
+ on,
429
+ off,
430
+ getVariable,
431
+ getVariables,
432
+ getCurrentPageId,
433
+ getCustomerId,
434
+ getVisitorId,
435
+ goTo,
436
+ goNext,
437
+ goPrevious,
438
+ navigation
439
+ } = bus;
406
440
  const api = {
407
441
  on,
408
442
  off,
@@ -411,6 +445,11 @@ function attachBus(bus) {
411
445
  getCurrentPageId,
412
446
  getCustomerId,
413
447
  getVisitorId,
448
+ // Flow control (bound closures over the bus's live nav registration).
449
+ goTo,
450
+ goNext,
451
+ goPrevious,
452
+ navigation,
414
453
  get events() {
415
454
  return bus.events;
416
455
  }
@@ -552,6 +591,102 @@ function useProducts() {
552
591
  [catalog, locale]
553
592
  );
554
593
  }
594
+
595
+ // src/flow/screenshot.ts
596
+ var MIME = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
597
+ var ScreenshotError = class extends Error {
598
+ constructor(code, message) {
599
+ super(message);
600
+ this.code = code;
601
+ }
602
+ };
603
+ function resolveTarget(t) {
604
+ if (!t || t.kind === "page") return document.getElementById("root") ?? document.body;
605
+ if (t.kind === "viewport") return document.body;
606
+ return document.querySelector(t.selector);
607
+ }
608
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
609
+ function hideAll(selectors) {
610
+ const touched = [];
611
+ for (const sel of selectors) {
612
+ document.querySelectorAll(sel).forEach((el) => {
613
+ touched.push([el, el.style.visibility]);
614
+ el.style.visibility = "hidden";
615
+ });
616
+ }
617
+ return () => touched.forEach(([el, v]) => el.style.visibility = v);
618
+ }
619
+ async function scrollThrough(el) {
620
+ const prev = el.scrollTop;
621
+ const step = el.clientHeight || window.innerHeight;
622
+ for (let y = 0; y < el.scrollHeight; y += step) {
623
+ el.scrollTop = y;
624
+ await delay(16);
625
+ }
626
+ el.scrollTop = prev;
627
+ await delay(16);
628
+ }
629
+ function blobToDataURL(blob) {
630
+ return new Promise((resolve, reject) => {
631
+ const fr = new FileReader();
632
+ fr.onload = () => resolve(String(fr.result));
633
+ fr.onerror = () => reject(fr.error);
634
+ fr.readAsDataURL(blob);
635
+ });
636
+ }
637
+ async function captureScreenshot(params = {}) {
638
+ const el = resolveTarget(params.target);
639
+ if (!el) throw new ScreenshotError("bad_params", "screenshot target not found");
640
+ const restore = params.hideSelectors?.length ? hideAll(params.hideSelectors) : null;
641
+ const scale = Math.min(params.scale ?? 2, params.maxScale ?? 3);
642
+ const format = params.format ?? "png";
643
+ const timeout = params.timeout ?? 1e4;
644
+ try {
645
+ if (params.waitForFonts !== false && document.fonts?.ready) {
646
+ await Promise.race([document.fonts.ready, delay(timeout)]);
647
+ }
648
+ if (params.triggerLazyLoad) await scrollThrough(el);
649
+ if (params.waitForImages !== false) {
650
+ await Promise.all(
651
+ Array.from(el.querySelectorAll("img")).map(
652
+ (img) => typeof img.decode === "function" ? img.decode().catch(() => {
653
+ }) : Promise.resolve()
654
+ )
655
+ );
656
+ }
657
+ const { domToBlob } = await import('modern-screenshot');
658
+ let blob;
659
+ try {
660
+ blob = await domToBlob(el, {
661
+ type: MIME[format],
662
+ quality: params.quality ?? 0.92,
663
+ scale,
664
+ width: params.width,
665
+ height: params.height,
666
+ // `null` = transparent (png/webp); only fall back to white when the caller omitted it. `??`
667
+ // would wrongly collapse an explicit `null` to white.
668
+ backgroundColor: params.backgroundColor === void 0 ? "#ffffff" : params.backgroundColor,
669
+ timeout
670
+ });
671
+ } catch (e) {
672
+ throw new ScreenshotError("failed", `capture failed (an image/font may be served without CORS '*'): ${String(e?.message || e)}`);
673
+ }
674
+ const result = {
675
+ mime: MIME[format],
676
+ width: Math.round((params.width ?? el.clientWidth) * scale),
677
+ height: Math.round((params.height ?? el.scrollHeight) * scale),
678
+ scale,
679
+ bytesApprox: blob.size
680
+ };
681
+ if ((params.encoding ?? "blob") === "dataURL") result.dataURL = await blobToDataURL(blob);
682
+ else result.blob = blob;
683
+ return result;
684
+ } finally {
685
+ restore?.();
686
+ }
687
+ }
688
+
689
+ // src/flow/flow.tsx
555
690
  var NavContext = createContext(null);
556
691
  var ExperimentContext = createContext({});
557
692
  function useExperiment(id) {
@@ -604,6 +739,7 @@ function usePageExitTimer(tracker, currentKey) {
604
739
  function FunnelView({
605
740
  pages,
606
741
  initialKey,
742
+ trustInitialKey = false,
607
743
  layout,
608
744
  fallback,
609
745
  prefetch: prefetchMode = "auto",
@@ -651,6 +787,7 @@ function FunnelView({
651
787
  if (!initialKey || initialKey === start) return start ? [start] : [];
652
788
  const target = toSlot(initialKey);
653
789
  const page2 = flowPages.find((p) => p.key === target);
790
+ if (trustInitialKey) return page2 ? [target] : start ? [start] : [];
654
791
  const guard = entryGuard(page2?.meta);
655
792
  const ok = page2 && (!guard || evaluateGate(guard, funnel.snapshot()));
656
793
  const first = ok ? target : start;
@@ -736,6 +873,44 @@ function FunnelView({
736
873
  canGoBack: history.length > 1,
737
874
  isNavigating
738
875
  };
876
+ const bus = useBusInternal();
877
+ const navRef = useRef(value);
878
+ navRef.current = value;
879
+ useEffect(() => {
880
+ if (!bus) return;
881
+ const control = {
882
+ goTo: (key) => {
883
+ if (!components.has(toRenderKey(key))) return false;
884
+ navRef.current.go(key);
885
+ return true;
886
+ },
887
+ goNext: () => navRef.current.next(),
888
+ goPrevious: () => navRef.current.back(),
889
+ snapshot: () => {
890
+ const v = navRef.current;
891
+ return {
892
+ key: v.page.key,
893
+ index: v.page.index,
894
+ total: v.page.total,
895
+ progressPercentage: v.page.progressPercentage,
896
+ canGoBack: v.canGoBack,
897
+ nextCandidates: v.nextCandidates
898
+ };
899
+ }
900
+ };
901
+ return bus.registerNavigation(control);
902
+ }, [bus]);
903
+ useEffect(() => {
904
+ const v = navRef.current;
905
+ bus?.emit("navigation", {
906
+ key: v.page.key,
907
+ index: v.page.index,
908
+ total: v.page.total,
909
+ progressPercentage: v.page.progressPercentage,
910
+ canGoBack: v.canGoBack,
911
+ nextCandidates: v.nextCandidates
912
+ });
913
+ }, [bus, currentKey]);
739
914
  const renderKey = toRenderKey(currentKey);
740
915
  const Current = components.get(renderKey);
741
916
  const pageEl = createElement(Suspense, { fallback: fallback ?? null }, Current ? createElement(Current) : null);
@@ -751,6 +926,81 @@ function useNavigation() {
751
926
  if (!ctx) throw new Error("useNavigation must be used inside <FunnelView>");
752
927
  return ctx;
753
928
  }
929
+ var EMBED_CAPABILITIES = {
930
+ methods: ["nav.snapshot", "nav.goTo", "nav.goNext", "nav.goPrevious", "screenshot"],
931
+ events: ["navigation"]
932
+ };
933
+ function EmbedBridge() {
934
+ const bus = useBusInternal();
935
+ useEffect(() => {
936
+ if (!bus || typeof window === "undefined" || window.parent === window) return;
937
+ const parent = window.parent;
938
+ let disposed = false;
939
+ const send = (msg) => {
940
+ try {
941
+ parent.postMessage({ af: AF_PROTOCOL, source: "af-preview", ...msg }, "*");
942
+ } catch {
943
+ }
944
+ };
945
+ const hello = () => send({ kind: "hello", role: "funnel", protocol: AF_PROTOCOL, capabilities: EMBED_CAPABILITIES });
946
+ const sendSnapshot = () => {
947
+ const s = bus.navigation();
948
+ if (s) send({ kind: "event", event: "navigation", data: s });
949
+ };
950
+ async function invoke(method, params) {
951
+ switch (method) {
952
+ case "nav.snapshot":
953
+ return bus.navigation();
954
+ case "nav.goTo": {
955
+ const key = params?.key;
956
+ if (typeof key !== "string" || !bus.goTo(key)) throw { code: "bad_params", message: `unknown page key: ${String(key)}` };
957
+ return void 0;
958
+ }
959
+ case "nav.goNext":
960
+ bus.goNext();
961
+ return void 0;
962
+ case "nav.goPrevious":
963
+ bus.goPrevious();
964
+ return void 0;
965
+ case "screenshot":
966
+ return await captureScreenshot(params ?? {});
967
+ default:
968
+ throw { code: "unknown_method", message: `unknown method: ${method}` };
969
+ }
970
+ }
971
+ async function onMsg(e) {
972
+ if (e.source !== parent) return;
973
+ const d = e.data;
974
+ if (!d || typeof d !== "object" || d.af !== AF_PROTOCOL || d.source !== "af-host") return;
975
+ if (d.kind === "hello") {
976
+ hello();
977
+ sendSnapshot();
978
+ return;
979
+ }
980
+ if (d.kind !== "request" || typeof d.id !== "string") return;
981
+ try {
982
+ const result = await invoke(d.method, d.params);
983
+ if (!disposed) send({ kind: "response", id: d.id, ok: true, result });
984
+ } catch (err) {
985
+ const e2 = err;
986
+ const error = e2 && typeof e2.code === "string" ? { code: e2.code, message: String(e2.message ?? e2.code) } : { code: "failed", message: String(e2?.message ?? err) };
987
+ if (!disposed) send({ kind: "response", id: d.id, ok: false, error });
988
+ }
989
+ }
990
+ window.addEventListener("message", onMsg);
991
+ const offNav = bus.on("navigation", (data) => {
992
+ if (!disposed) send({ kind: "event", event: "navigation", data });
993
+ });
994
+ hello();
995
+ sendSnapshot();
996
+ return () => {
997
+ disposed = true;
998
+ offNav();
999
+ window.removeEventListener("message", onMsg);
1000
+ };
1001
+ }, [bus]);
1002
+ return null;
1003
+ }
754
1004
  function usePage() {
755
1005
  return useNavigation().page;
756
1006
  }
@@ -1120,12 +1370,25 @@ var FALLBACK_DRIVER = createMockDriver();
1120
1370
  function useCheckout(opts = {}) {
1121
1371
  const driver = useDriver();
1122
1372
  const tracker = useTrackerRef();
1373
+ const catalog = useCatalog();
1123
1374
  const nav = useNavigation();
1124
1375
  const [status, setStatus] = useState("idle");
1125
1376
  const [error, setError] = useState(null);
1126
1377
  const intent = opts.intent ?? "purchase";
1127
1378
  const kind = opts.kind;
1128
1379
  const advance = opts.advanceOnSuccess ?? true;
1380
+ const productFacts = useCallback(
1381
+ (product) => {
1382
+ const resolved = product ? catalog.get(product) : void 0;
1383
+ return {
1384
+ productId: resolved?.catalogKey ?? product,
1385
+ amount: resolved ? resolved.priceMinor / 100 : void 0,
1386
+ currency: resolved?.currency,
1387
+ isSubscription: resolved ? resolved.period !== "one-time" : kind === "subscription" || kind === "upgrade"
1388
+ };
1389
+ },
1390
+ [catalog, kind]
1391
+ );
1129
1392
  const recovering = useRef(false);
1130
1393
  const failRef = useRef(() => {
1131
1394
  });
@@ -1133,18 +1396,24 @@ function useCheckout(opts = {}) {
1133
1396
  async () => ({ ok: false })
1134
1397
  );
1135
1398
  const succeed = useCallback(
1136
- (result) => {
1399
+ (result, product) => {
1137
1400
  setStatus("success");
1401
+ const { productId, isSubscription } = productFacts(product);
1402
+ const amount = result.amountMinor != null ? result.amountMinor / 100 : void 0;
1138
1403
  tracker.track("purchase.complete", {
1139
- amount: result.amountMinor != null ? result.amountMinor / 100 : void 0,
1404
+ amount,
1140
1405
  currency: result.currency,
1406
+ productId,
1141
1407
  eventId: result.eventId
1142
1408
  // server-minted (browser↔CAPI dedup); undefined if none
1143
1409
  });
1410
+ if (isSubscription) {
1411
+ tracker.track("subscription.created", { amount, currency: result.currency, productId, eventId: result.eventId });
1412
+ }
1144
1413
  opts.onSuccess?.(result);
1145
1414
  if (advance) nav.next();
1146
1415
  },
1147
- [tracker, nav, advance, opts]
1416
+ [tracker, nav, advance, opts, productFacts]
1148
1417
  );
1149
1418
  const succeedRef = useRef(succeed);
1150
1419
  succeedRef.current = succeed;
@@ -1152,11 +1421,18 @@ function useCheckout(opts = {}) {
1152
1421
  (product, surface) => {
1153
1422
  setStatus("loading");
1154
1423
  setError(null);
1155
- tracker.track("checkout.start", { productId: product, surface });
1424
+ const { productId, amount, currency } = productFacts(product);
1425
+ tracker.track("checkout.start", { productId, amount, currency, surface, eventId: newEventId() });
1156
1426
  },
1157
- [tracker]
1427
+ [tracker, productFacts]
1428
+ );
1429
+ const paymentAdded = useCallback(
1430
+ (product) => {
1431
+ const { productId, amount, currency } = productFacts(product);
1432
+ tracker.track("checkout.payment_added", { productId, amount, currency, eventId: newEventId() });
1433
+ },
1434
+ [tracker, productFacts]
1158
1435
  );
1159
- const paymentAdded = useCallback(() => tracker.track("checkout.payment_added", {}), [tracker]);
1160
1436
  const hostInSheet = (surface) => surface === "sheet" || isInlineSurface(surface) && !!PROVIDER_PROFILES[driver.provider]?.surfaces.sheet;
1161
1437
  const startRecovery = (product, surface) => {
1162
1438
  recovering.current = true;
@@ -1167,9 +1443,9 @@ function useCheckout(opts = {}) {
1167
1443
  surface: "sheet",
1168
1444
  intent,
1169
1445
  kind,
1170
- onSuccess: (r) => succeedRef.current(r),
1446
+ onSuccess: (r) => succeedRef.current(r, product),
1171
1447
  onError: (e) => failRef.current(e, product),
1172
- onPaymentAdded: () => paymentAdded()
1448
+ onPaymentAdded: () => paymentAdded(product)
1173
1449
  });
1174
1450
  } else {
1175
1451
  void attemptRef.current(product, surface);
@@ -1223,7 +1499,7 @@ function useCheckout(opts = {}) {
1223
1499
  begin(product, surface);
1224
1500
  try {
1225
1501
  const result = await driver.start({ product, intent, surface, kind });
1226
- if (result.ok) succeedRef.current(result);
1502
+ if (result.ok) succeedRef.current(result, product);
1227
1503
  else failRef.current(result.error ?? checkoutError("unknown", "Checkout failed"), product);
1228
1504
  return result;
1229
1505
  } catch (e) {
@@ -1242,9 +1518,9 @@ function useCheckout(opts = {}) {
1242
1518
  recovering.current = false;
1243
1519
  begin(product, surface);
1244
1520
  },
1245
- onSuccess: (result) => succeedRef.current(result),
1521
+ onSuccess: (result) => succeedRef.current(result, product),
1246
1522
  onError: (err) => failRef.current(err, product),
1247
- onPaymentAdded: () => paymentAdded()
1523
+ onPaymentAdded: () => paymentAdded(product)
1248
1524
  }),
1249
1525
  [begin, paymentAdded]
1250
1526
  );
@@ -2542,6 +2818,7 @@ var FunnelCtx = createContext(null);
2542
2818
  function FunnelProvider({
2543
2819
  config,
2544
2820
  sessionValues,
2821
+ deferSessionValues = false,
2545
2822
  tracker,
2546
2823
  context: contextOpts,
2547
2824
  products,
@@ -2557,7 +2834,12 @@ function FunnelProvider({
2557
2834
  const [value] = useState(() => {
2558
2835
  const s = store ?? createFunnelStore(
2559
2836
  { responses: config?.responses, data: config?.data },
2560
- sessionValues
2837
+ // FLASH-FREE HYDRATION: with deferSessionValues the initial render is seeded with
2838
+ // DEFAULTS ONLY, so the client's first render is byte-identical to the build-time
2839
+ // DEFAULT-state prerender it hydrates against (clean match). The visitor's saved
2840
+ // answers arrive as a post-hydration setMany below. Without the flag, sessionValues
2841
+ // seed the store at construction (SSR + non-prerendered paths).
2842
+ deferSessionValues ? void 0 : sessionValues
2561
2843
  );
2562
2844
  const ctx = buildContext({
2563
2845
  funnelId: config?.id,
@@ -2589,6 +2871,12 @@ function FunnelProvider({
2589
2871
  };
2590
2872
  });
2591
2873
  useEffect(() => attachBus(value.bus), [value.bus]);
2874
+ useEffect(() => {
2875
+ if (!deferSessionValues || !sessionValues) return;
2876
+ const updates = {};
2877
+ for (const [key, v] of Object.entries(sessionValues)) if (v !== void 0) updates[key] = v;
2878
+ if (Object.keys(updates).length > 0) value.store.setMany(updates);
2879
+ }, []);
2592
2880
  useEffect(() => {
2593
2881
  value.tracker.setAcquisition?.(buildAcquisition(value.context));
2594
2882
  }, [value]);
@@ -2624,6 +2912,9 @@ function useFunnelCtx() {
2624
2912
  function useFunnel() {
2625
2913
  return useFunnelCtx().funnel;
2626
2914
  }
2915
+ function useBusInternal() {
2916
+ return useContext(FunnelCtx)?.bus ?? null;
2917
+ }
2627
2918
  function useStoreKey(store, key) {
2628
2919
  const subscribe = useCallback(
2629
2920
  (cb) => store.subscribe(cb, { keys: [key] }),
@@ -2687,6 +2978,152 @@ function useContextValue(path) {
2687
2978
  );
2688
2979
  }
2689
2980
 
2981
+ // src/tracking/snapshotPersistence.ts
2982
+ function createSnapshotPersistence(opts) {
2983
+ const baseDelay = opts.baseDelayMs ?? 1e3;
2984
+ const maxDelay = opts.maxDelayMs ?? 3e4;
2985
+ const setT = opts.timers?.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
2986
+ const clearT = opts.timers?.clearTimeout ?? ((h) => clearTimeout(h));
2987
+ const rand = opts.random ?? Math.random;
2988
+ let pending = null;
2989
+ let seq = 0;
2990
+ let pendingSeq = 0;
2991
+ let inFlight = false;
2992
+ let attempt = 0;
2993
+ let retryTimer = null;
2994
+ let disposed = false;
2995
+ let fatalSeq = -1;
2996
+ const clearRetry = () => {
2997
+ if (retryTimer !== null) {
2998
+ clearT(retryTimer);
2999
+ retryTimer = null;
3000
+ }
3001
+ };
3002
+ const buildBody = (snapshot, s) => JSON.stringify({ sessionId: opts.getSessionId(), userData: snapshot, seq: s });
3003
+ const trySend = () => {
3004
+ if (disposed || inFlight || pending === null) return;
3005
+ if (retryTimer !== null) return;
3006
+ const sessionId = opts.getSessionId();
3007
+ if (!sessionId) return;
3008
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
3009
+ if (pendingSeq === fatalSeq) return;
3010
+ const snapshot = pending;
3011
+ const sentSeq = pendingSeq;
3012
+ const body = buildBody(snapshot, sentSeq);
3013
+ inFlight = true;
3014
+ void fetch(opts.url, {
3015
+ method: "POST",
3016
+ headers: { "content-type": "application/json" },
3017
+ body,
3018
+ keepalive: true
3019
+ }).then((res) => {
3020
+ inFlight = false;
3021
+ if (disposed) return;
3022
+ if (res.ok) {
3023
+ attempt = 0;
3024
+ if (pendingSeq === sentSeq) pending = null;
3025
+ else trySend();
3026
+ return;
3027
+ }
3028
+ if (res.status >= 400 && res.status < 500) {
3029
+ if (process.env.NODE_ENV !== "production") {
3030
+ console.warn(
3031
+ `[appfunnel] snapshot persist rejected ${res.status} (dropping snapshot; check session/payload)`
3032
+ );
3033
+ }
3034
+ fatalSeq = sentSeq;
3035
+ if (pendingSeq === sentSeq) pending = null;
3036
+ else trySend();
3037
+ return;
3038
+ }
3039
+ scheduleRetry();
3040
+ }).catch(() => {
3041
+ inFlight = false;
3042
+ if (disposed) return;
3043
+ scheduleRetry();
3044
+ });
3045
+ };
3046
+ const scheduleRetry = () => {
3047
+ if (disposed || pending === null) return;
3048
+ const exp = Math.min(maxDelay, baseDelay * 2 ** attempt);
3049
+ const delay2 = Math.round(exp * (0.5 + rand() * 0.5));
3050
+ attempt++;
3051
+ clearRetry();
3052
+ retryTimer = setT(() => {
3053
+ retryTimer = null;
3054
+ trySend();
3055
+ }, delay2);
3056
+ };
3057
+ const push = (snapshot) => {
3058
+ if (disposed) return;
3059
+ pending = snapshot;
3060
+ pendingSeq = ++seq;
3061
+ if (fatalSeq !== -1 && pendingSeq !== fatalSeq) fatalSeq = -1;
3062
+ if (retryTimer === null) trySend();
3063
+ };
3064
+ const flushBeacon = () => {
3065
+ if (disposed || pending === null) return;
3066
+ const sessionId = opts.getSessionId();
3067
+ if (!sessionId) return;
3068
+ const body = buildBody(pending, pendingSeq);
3069
+ let delivered = false;
3070
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
3071
+ try {
3072
+ delivered = navigator.sendBeacon(
3073
+ opts.url,
3074
+ new Blob([body], { type: "application/json" })
3075
+ );
3076
+ } catch {
3077
+ delivered = false;
3078
+ }
3079
+ }
3080
+ if (!delivered) {
3081
+ try {
3082
+ void fetch(opts.url, {
3083
+ method: "POST",
3084
+ headers: { "content-type": "application/json" },
3085
+ body,
3086
+ keepalive: true
3087
+ }).catch(() => {
3088
+ });
3089
+ delivered = true;
3090
+ } catch {
3091
+ delivered = false;
3092
+ }
3093
+ }
3094
+ if (delivered) pending = null;
3095
+ };
3096
+ const onHidden = () => {
3097
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
3098
+ flushBeacon();
3099
+ }
3100
+ };
3101
+ const onPageHide = () => flushBeacon();
3102
+ const onOnline = () => trySend();
3103
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
3104
+ try {
3105
+ document.addEventListener("visibilitychange", onHidden);
3106
+ window.addEventListener("pagehide", onPageHide);
3107
+ window.addEventListener("online", onOnline);
3108
+ } catch {
3109
+ }
3110
+ }
3111
+ const dispose = () => {
3112
+ disposed = true;
3113
+ clearRetry();
3114
+ pending = null;
3115
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
3116
+ try {
3117
+ document.removeEventListener("visibilitychange", onHidden);
3118
+ window.removeEventListener("pagehide", onPageHide);
3119
+ window.removeEventListener("online", onOnline);
3120
+ } catch {
3121
+ }
3122
+ }
3123
+ };
3124
+ return { push, flushBeacon, dispose };
3125
+ }
3126
+
2690
3127
  // src/tracking/platformTracker.ts
2691
3128
  function validClickIds(acq) {
2692
3129
  return Object.entries(acq.clickIds ?? {}).filter(
@@ -2792,32 +3229,35 @@ function createPlatformTracker(cfg) {
2792
3229
  }
2793
3230
  };
2794
3231
  const VARS_DEBOUNCE_MS = 800;
3232
+ const dataUrl = `${base}/campaign/${encodeURIComponent(cfg.campaignId)}/event/session/data`;
2795
3233
  let pendingVars = null;
2796
3234
  let varsTimer = null;
3235
+ const persistence = createSnapshotPersistence({
3236
+ url: dataUrl,
3237
+ getSessionId: () => sessionId
3238
+ });
2797
3239
  const flushVars = () => {
2798
3240
  if (varsTimer) {
2799
3241
  clearTimeout(varsTimer);
2800
3242
  varsTimer = null;
2801
3243
  }
2802
3244
  if (!pendingVars) return;
2803
- const doPost = () => {
2804
- if (!pendingVars || !sessionId) return;
2805
- const userData = pendingVars;
2806
- pendingVars = null;
2807
- void fetch(`${base}/campaign/${encodeURIComponent(cfg.campaignId)}/event/session/data`, {
2808
- method: "POST",
2809
- headers: { "content-type": "application/json" },
2810
- body: JSON.stringify({ sessionId, userData }),
2811
- keepalive: true
2812
- }).catch((e) => {
2813
- if (process.env.NODE_ENV !== "production") console.warn("[appfunnel] variable persist failed:", e);
2814
- });
2815
- };
2816
- if (sessionId) doPost();
2817
- else chain = chain.then(doPost);
3245
+ const snapshot = pendingVars;
3246
+ pendingVars = null;
3247
+ persistence.push(snapshot);
3248
+ if (!sessionId) chain = chain.then(() => {
3249
+ persistence.push(snapshot);
3250
+ });
3251
+ };
3252
+ const drainOnExit = () => {
3253
+ if (pendingVars) {
3254
+ flushVars();
3255
+ persistence.flushBeacon();
3256
+ }
2818
3257
  };
2819
3258
  try {
2820
- window.addEventListener("pagehide", flushVars);
3259
+ window.addEventListener("visibilitychange", drainOnExit);
3260
+ window.addEventListener("pagehide", drainOnExit);
2821
3261
  } catch {
2822
3262
  }
2823
3263
  if (cfg.experiment?.id && cfg.experiment.variant) {
@@ -2860,6 +3300,7 @@ function createFunnelTree({
2860
3300
  }) {
2861
3301
  return function tree(opts) {
2862
3302
  const mode = opts.mode ?? "live";
3303
+ const embedEnabled = opts.embed ?? false;
2863
3304
  const tracker = opts.tracking ? createPlatformTracker({
2864
3305
  ...opts.tracking,
2865
3306
  visitorId: opts.visitorId,
@@ -2881,6 +3322,7 @@ function createFunnelTree({
2881
3322
  Fragment,
2882
3323
  null,
2883
3324
  createElement(CheckoutResume, null),
3325
+ embedEnabled ? createElement(EmbedBridge, null) : null,
2884
3326
  layout ? createElement(layout, null, children) : children
2885
3327
  );
2886
3328
  };
@@ -2894,6 +3336,7 @@ function createFunnelTree({
2894
3336
  tracker,
2895
3337
  checkout,
2896
3338
  sessionValues: opts.sessionValues,
3339
+ deferSessionValues: opts.deferSessionValues,
2897
3340
  experiments: opts.experiments,
2898
3341
  context: {
2899
3342
  mode,
@@ -2905,6 +3348,13 @@ function createFunnelTree({
2905
3348
  pages,
2906
3349
  layout: layoutWithResume,
2907
3350
  initialKey: opts.initialKey,
3351
+ // FLASH-FREE HYDRATION (Option B, FIX 1): trust the SERVER-resolved initialKey instead of
3352
+ // re-running the entry guard. The CLIENT sets this via deferSessionValues (its deferred
3353
+ // snapshot is empty, so re-running the guard would bounce a qualified deep-link and
3354
+ // mismatch the markup). The build-time PRERENDER sets `trustInitialKey` explicitly so a
3355
+ // guarded page prerenders its OWN default-state markup (the renderer picks target-vs-bounce
3356
+ // at serve time). Live SSR fallback leaves both off → the guard runs against real values.
3357
+ trustInitialKey: opts.trustInitialKey ?? opts.deferSessionValues,
2908
3358
  prefetch: "off"
2909
3359
  // renderer background-modulepreloads all chunks
2910
3360
  })