@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.
- package/dist/{chunk-AKO6XKXP.js → chunk-AXXG3ODP.js} +10 -3
- package/dist/chunk-AXXG3ODP.js.map +1 -0
- package/dist/{chunk-M6U3FNRW.js → chunk-CFFMZYPE.js} +3 -3
- package/dist/{chunk-M6U3FNRW.js.map → chunk-CFFMZYPE.js.map} +1 -1
- package/dist/chunk-EMMSS5I5.cjs +37 -0
- package/dist/chunk-EMMSS5I5.cjs.map +1 -0
- package/dist/chunk-G3PMV62Z.js +33 -0
- package/dist/chunk-G3PMV62Z.js.map +1 -0
- package/dist/{chunk-JSRKA375.cjs → chunk-G44BUG6G.cjs} +10 -3
- package/dist/chunk-G44BUG6G.cjs.map +1 -0
- package/dist/{chunk-LJYLGLFS.cjs → chunk-QMAZGLGV.cjs} +2 -35
- package/dist/chunk-QMAZGLGV.cjs.map +1 -0
- package/dist/{chunk-YY375F2B.js → chunk-TNLRQPVY.js} +3 -33
- package/dist/chunk-TNLRQPVY.js.map +1 -0
- package/dist/{chunk-CY4VBSMX.cjs → chunk-VAOR77NW.cjs} +7 -7
- package/dist/{chunk-CY4VBSMX.cjs.map → chunk-VAOR77NW.cjs.map} +1 -1
- package/dist/chunk-WYUDL4FI.cjs +8 -0
- package/dist/chunk-WYUDL4FI.cjs.map +1 -0
- package/dist/chunk-ZZJG4EYL.js +6 -0
- package/dist/chunk-ZZJG4EYL.js.map +1 -0
- package/dist/driver-paddle.cjs +23 -22
- package/dist/driver-paddle.cjs.map +1 -1
- package/dist/driver-paddle.js +3 -2
- package/dist/driver-paddle.js.map +1 -1
- package/dist/driver-stripe.cjs +37 -36
- package/dist/driver-stripe.cjs.map +1 -1
- package/dist/driver-stripe.js +3 -2
- package/dist/driver-stripe.js.map +1 -1
- package/dist/index.cjs +541 -91
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -75
- package/dist/index.d.ts +151 -75
- package/dist/index.js +484 -34
- package/dist/index.js.map +1 -1
- package/dist/{manifest-Cr2y1op6.d.cts → manifest-Dl7faaKe.d.cts} +34 -0
- package/dist/{manifest-Cr2y1op6.d.ts → manifest-Dl7faaKe.d.ts} +34 -0
- package/dist/manifest-entry.cjs +45 -30
- package/dist/manifest-entry.cjs.map +1 -1
- package/dist/manifest-entry.d.cts +1 -1
- package/dist/manifest-entry.d.ts +1 -1
- package/dist/manifest-entry.js +10 -3
- package/dist/manifest-entry.js.map +1 -1
- package/dist/protocol.cjs +13 -0
- package/dist/protocol.cjs.map +1 -0
- package/dist/protocol.d.cts +182 -0
- package/dist/protocol.d.ts +182 -0
- package/dist/protocol.js +4 -0
- package/dist/protocol.js.map +1 -0
- package/package.json +14 -2
- package/dist/chunk-AKO6XKXP.js.map +0 -1
- package/dist/chunk-JSRKA375.cjs.map +0 -1
- package/dist/chunk-LJYLGLFS.cjs.map +0 -1
- 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-
|
|
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-
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
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("
|
|
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
|
})
|