@funnelsgrove/runtime 0.7.28 → 0.7.29

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.
@@ -0,0 +1,11 @@
1
+ import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
2
+ type FunnelAnswerDraftScope = {
3
+ funnelId: string | null | undefined;
4
+ funnelVersionId: string | null | undefined;
5
+ userId: string | null | undefined;
6
+ };
7
+ export declare const buildFunnelAnswerDraftStorageKey: (scope: FunnelAnswerDraftScope) => string | null;
8
+ export declare const readFunnelAnswerDraft: (scope: FunnelAnswerDraftScope, nowMs?: number) => FunnelUserAnswers | null;
9
+ export declare const writeFunnelAnswerDraft: (scope: FunnelAnswerDraftScope, answers: FunnelUserAnswers, nowMs?: number) => boolean;
10
+ export declare const clearFunnelAnswerDraft: (scope: FunnelAnswerDraftScope) => void;
11
+ export {};
@@ -0,0 +1,82 @@
1
+ const ANSWER_DRAFT_VERSION = 1;
2
+ const ANSWER_DRAFT_TTL_MS = 14 * 24 * 60 * 60 * 1000;
3
+ const MAX_ANSWER_DRAFT_BYTES = 65536;
4
+ const asNonEmptyPart = (value) => {
5
+ const normalized = value === null || value === void 0 ? void 0 : value.trim();
6
+ return normalized || null;
7
+ };
8
+ const isRecord = (value) => (Boolean(value) && typeof value === 'object' && !Array.isArray(value));
9
+ export const buildFunnelAnswerDraftStorageKey = (scope) => {
10
+ const funnelId = asNonEmptyPart(scope.funnelId);
11
+ const funnelVersionId = asNonEmptyPart(scope.funnelVersionId);
12
+ const userId = asNonEmptyPart(scope.userId);
13
+ if (!funnelId || !funnelVersionId || !userId) {
14
+ return null;
15
+ }
16
+ return `funnel:${funnelId}:${funnelVersionId}:${userId}:answer-draft`;
17
+ };
18
+ export const readFunnelAnswerDraft = (scope, nowMs = Date.now()) => {
19
+ const key = buildFunnelAnswerDraftStorageKey(scope);
20
+ if (!key || typeof window === 'undefined') {
21
+ return null;
22
+ }
23
+ try {
24
+ const raw = window.localStorage.getItem(key);
25
+ if (!raw) {
26
+ return null;
27
+ }
28
+ const parsed = JSON.parse(raw);
29
+ if (parsed.version !== ANSWER_DRAFT_VERSION
30
+ || typeof parsed.updatedAt !== 'number'
31
+ || !Number.isFinite(parsed.updatedAt)
32
+ || parsed.updatedAt > nowMs
33
+ || nowMs - parsed.updatedAt > ANSWER_DRAFT_TTL_MS
34
+ || !isRecord(parsed.answers)) {
35
+ window.localStorage.removeItem(key);
36
+ return null;
37
+ }
38
+ return parsed.answers;
39
+ }
40
+ catch (_a) {
41
+ try {
42
+ window.localStorage.removeItem(key);
43
+ }
44
+ catch (_b) {
45
+ // Storage may be unavailable in privacy-constrained browser contexts.
46
+ }
47
+ return null;
48
+ }
49
+ };
50
+ export const writeFunnelAnswerDraft = (scope, answers, nowMs = Date.now()) => {
51
+ const key = buildFunnelAnswerDraftStorageKey(scope);
52
+ if (!key || typeof window === 'undefined' || !isRecord(answers)) {
53
+ return false;
54
+ }
55
+ try {
56
+ const serialized = JSON.stringify({
57
+ version: ANSWER_DRAFT_VERSION,
58
+ updatedAt: nowMs,
59
+ answers,
60
+ });
61
+ if (new TextEncoder().encode(serialized).byteLength > MAX_ANSWER_DRAFT_BYTES) {
62
+ return false;
63
+ }
64
+ window.localStorage.setItem(key, serialized);
65
+ return true;
66
+ }
67
+ catch (_a) {
68
+ return false;
69
+ }
70
+ };
71
+ export const clearFunnelAnswerDraft = (scope) => {
72
+ const key = buildFunnelAnswerDraftStorageKey(scope);
73
+ if (!key || typeof window === 'undefined') {
74
+ return;
75
+ }
76
+ try {
77
+ window.localStorage.removeItem(key);
78
+ }
79
+ catch (_a) {
80
+ // Storage may be unavailable in privacy-constrained browser contexts.
81
+ }
82
+ };
@@ -4,7 +4,7 @@ import { useFunnelRuntimeConfig } from '../components/FunnelRuntimeConfigBoundar
4
4
  import { writeStepChoice } from '../sdk/userAnswers.js';
5
5
  import { apiService } from '../services/api.service.js';
6
6
  import { logger } from '../services/logger.js';
7
- import { FUNNEL_ID, POSTHOG_API_HOST, POSTHOG_PROJECT_API_KEY, PROJECT_ID, } from '../services/runtime-api.config.js';
7
+ import { FUNNEL_ID, FUNNEL_VERSION_ID, POSTHOG_API_HOST, POSTHOG_PROJECT_API_KEY, PROJECT_ID, } from '../services/runtime-api.config.js';
8
8
  import { buildHostedStepLocation, dispatchWindowCustomEvent, updateFunnelHistory, } from './browser-helpers.js';
9
9
  import { isPreviewStepLockRequested, resolveNextStepFromContext, shouldRunAutoAdvanceTimer, } from './funnel-flow.js';
10
10
  import { usePreviewBridge } from './preview-bridge.js';
@@ -19,6 +19,7 @@ import { isDevelopmentRuntime } from '../config/env.config.js';
19
19
  import { consumeChoiceCompletionMarker, enforceChoiceBypassGuard, registerInternalChoiceCommit, runAtomicChoiceCommit, } from './use-step-choices.js';
20
20
  import { createFunnelStepLifecycleState, finishVisit, startVisit, } from './funnel-step-lifecycle.js';
21
21
  import { createEmailCaptureSingleFlight, submitEmailCapture as submitEmailCapturePrimitive, } from './submit-email-capture.js';
22
+ import { clearFunnelAnswerDraft, readFunnelAnswerDraft, writeFunnelAnswerDraft, } from './funnel-answer-draft.js';
22
23
  const isTerminalCompletion = (stepMeta, outcome) => {
23
24
  var _a;
24
25
  return (outcome.type === 'complete'
@@ -322,14 +323,25 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
322
323
  }, [initialStepId, resolveFallbackStepId, resolveRuntimeInitialStepId]);
323
324
  const isSubscriptionManagementRuntime = ((_g = stepById[safeInitialStepId]) === null || _g === void 0 ? void 0 : _g.kind) === 'manage-subscription';
324
325
  const lifecycleEventsSuppressed = isPreviewRuntime || editorModeEnabled || isSubscriptionManagementRuntime;
326
+ const [initialRuntimeIdentity] = useState(() => {
327
+ const urlAttributes = resolveUrlUserAttributes();
328
+ const clientUserId = isSubscriptionManagementRuntime
329
+ ? urlAttributes.userId || ''
330
+ : api.getOrCreateClientUserId();
331
+ return { clientUserId, urlAttributes };
332
+ });
325
333
  const [activeStepId, setActiveStepId] = useState(safeInitialStepId);
326
- const [attributes, setAttributes] = useState(() => (Object.assign({}, (initialAttributes || {}))));
334
+ const [attributes, setAttributes] = useState(() => (Object.assign(Object.assign({}, (isSubscriptionManagementRuntime || isPreviewRuntime
335
+ ? {}
336
+ : readFunnelAnswerDraft({
337
+ funnelId: FUNNEL_ID,
338
+ funnelVersionId: FUNNEL_VERSION_ID,
339
+ userId: initialRuntimeIdentity.clientUserId,
340
+ }) || {})), (initialAttributes || {}))));
327
341
  const [user, setUser] = useState(() => ({
328
- id: isSubscriptionManagementRuntime
329
- ? resolveUrlUserAttributes().userId || ''
330
- : api.getOrCreateClientUserId(),
331
- name: '',
332
- email: '',
342
+ id: initialRuntimeIdentity.clientUserId,
343
+ name: initialRuntimeIdentity.urlAttributes.name || '',
344
+ email: initialRuntimeIdentity.urlAttributes.email || '',
333
345
  attributes: {},
334
346
  document: {},
335
347
  completedSteps: [],
@@ -353,6 +365,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
353
365
  const stepLifecycleStateRef = useRef(createFunnelStepLifecycleState());
354
366
  const activeVisitTokenRef = useRef(null);
355
367
  const emailCaptureSingleFlightRef = useRef(createEmailCaptureSingleFlight());
368
+ const answersCommittedRef = useRef(false);
356
369
  const pendingEmailCaptureNavigationRef = useRef(null);
357
370
  const appliedInitialStepIdRef = useRef(safeInitialStepId);
358
371
  const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
@@ -474,6 +487,16 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
474
487
  useEffect(() => {
475
488
  attributesRef.current = attributes;
476
489
  }, [attributes]);
490
+ useEffect(() => {
491
+ if (isPreviewRuntime || isSubscriptionManagementRuntime || answersCommittedRef.current) {
492
+ return;
493
+ }
494
+ writeFunnelAnswerDraft({
495
+ funnelId: FUNNEL_ID,
496
+ funnelVersionId: FUNNEL_VERSION_ID,
497
+ userId: user.id,
498
+ }, attributes);
499
+ }, [attributes, isPreviewRuntime, isSubscriptionManagementRuntime, user.id]);
477
500
  useEffect(() => {
478
501
  postHogFeatureFlagsRef.current = postHogFeatureFlags;
479
502
  }, [postHogFeatureFlags]);
@@ -522,12 +545,12 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
522
545
  setPostHogReady(true);
523
546
  return;
524
547
  }
525
- const localUserId = api.getOrCreateClientUserId();
548
+ const localUserId = initialRuntimeIdentity.clientUserId;
526
549
  const bootstrapCandidateUserId = api.getBootstrapCandidateUserId(localUserId);
527
550
  const initialAttribution = collectCurrentFunnelAttribution();
528
551
  initialAttributionRef.current = initialAttribution;
529
552
  setUserBootstrapped(isPreviewRuntime);
530
- setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
553
+ setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId, name: prev.name || urlUserAttributes.name || '', email: prev.email || urlUserAttributes.email || '' })));
531
554
  if (isPreviewRuntime) {
532
555
  return;
533
556
  }
@@ -551,8 +574,6 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
551
574
  const sessionBootstrap = api
552
575
  .bootstrapSession({
553
576
  userId: bootstrapCandidateUserId || localUserId,
554
- email: urlUserAttributes.email || undefined,
555
- name: urlUserAttributes.name || undefined,
556
577
  attribution: initialAttribution,
557
578
  })
558
579
  .then((sessionUser) => {
@@ -560,8 +581,13 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
560
581
  const sessionAttributes = Object.keys(sessionUser.attributes || {}).length > 0
561
582
  ? sessionUser.attributes
562
583
  : null;
563
- if (sessionAttributes) {
564
- setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
584
+ const sessionDraft = readFunnelAnswerDraft({
585
+ funnelId: FUNNEL_ID,
586
+ funnelVersionId: FUNNEL_VERSION_ID,
587
+ userId: sessionUser.id,
588
+ });
589
+ if (sessionAttributes || sessionDraft) {
590
+ setAttributes((prev) => (Object.assign(Object.assign(Object.assign({}, (sessionAttributes || {})), (sessionDraft || {})), prev)));
565
591
  }
566
592
  initialAttributionRef.current = resolveFunnelEventAttribution(sessionUser.document, initialAttribution);
567
593
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: sessionUser.id || prev.id, name: sessionUser.name || prev.name, email: sessionUser.email || prev.email, document: sessionUser.document, attributes: sessionAttributes ? Object.assign(Object.assign({}, sessionAttributes), prev.attributes) : prev.attributes })));
@@ -602,6 +628,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
602
628
  return cancelAttributionFailOpen;
603
629
  }, [
604
630
  api,
631
+ initialRuntimeIdentity.clientUserId,
605
632
  isPreviewRuntime,
606
633
  isSubscriptionManagementRuntime,
607
634
  sessionReplayEnabled,
@@ -640,25 +667,9 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
640
667
  const completionAttributes = Object.assign({}, attributesRef.current);
641
668
  dispatchWindowCustomEvent('funnel:step-completed', record);
642
669
  setUser((prev) => {
643
- var _a, _b;
670
+ var _a;
644
671
  const updatedSteps = [...((_a = prev.completedSteps) !== null && _a !== void 0 ? _a : []), record];
645
672
  const updatedUser = Object.assign(Object.assign({}, prev), { attributes: Object.assign(Object.assign({}, prev.attributes), completionAttributes), document: prev.document, completedSteps: updatedSteps });
646
- if (!isPreviewRuntime) {
647
- api
648
- .updateUser({
649
- id: updatedUser.id,
650
- name: updatedUser.name,
651
- email: updatedUser.email,
652
- attributes: Object.assign(Object.assign({}, updatedUser.attributes), { completedSteps: updatedSteps }),
653
- document: (_b = updatedUser.document) !== null && _b !== void 0 ? _b : {},
654
- }, {
655
- attribution: collectCurrentFunnelAttribution(),
656
- persistClientIdentity: false,
657
- })
658
- .catch((error) => {
659
- logger.error('Failed to persist step completion:', error);
660
- });
661
- }
662
673
  return updatedUser;
663
674
  });
664
675
  if (!isPreviewRuntime) {
@@ -670,7 +681,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
670
681
  logger.error('Failed to track step completion:', error);
671
682
  }
672
683
  }
673
- }, [analytics, api, attributionReady, isPreviewRuntime, lifecycleEventsSuppressed, stepById, stepContractVersion]);
684
+ }, [analytics, attributionReady, isPreviewRuntime, lifecycleEventsSuppressed, stepById, stepContractVersion]);
674
685
  const emitStepExit = useCallback((intent) => {
675
686
  var _a;
676
687
  const meta = stepById[intent.visit.stepId];
@@ -785,11 +796,33 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
785
796
  pendingEmailCaptureNavigationRef.current = null;
786
797
  return true;
787
798
  },
788
- persist: (normalizedEmail) => api.captureEmail({
789
- userId: submittedUserId,
790
- email: normalizedEmail,
791
- environment: getRuntimeMode(),
792
- }),
799
+ persist: async (normalizedEmail) => {
800
+ const result = await api.captureEmail({
801
+ userId: submittedUserId,
802
+ email: normalizedEmail,
803
+ environment: getRuntimeMode(),
804
+ answers: Object.assign({}, attributesRef.current),
805
+ });
806
+ answersCommittedRef.current = true;
807
+ clearFunnelAnswerDraft({
808
+ funnelId: FUNNEL_ID,
809
+ funnelVersionId: FUNNEL_VERSION_ID,
810
+ userId: submittedUserId,
811
+ });
812
+ clearFunnelAnswerDraft({
813
+ funnelId: FUNNEL_ID,
814
+ funnelVersionId: FUNNEL_VERSION_ID,
815
+ userId: initialRuntimeIdentity.clientUserId,
816
+ });
817
+ if (result.canonicalUser) {
818
+ clearFunnelAnswerDraft({
819
+ funnelId: FUNNEL_ID,
820
+ funnelVersionId: FUNNEL_VERSION_ID,
821
+ userId: result.canonicalUser.id,
822
+ });
823
+ }
824
+ return result;
825
+ },
793
826
  applyUser: (capturedUser) => {
794
827
  setIsReturningSubscriber(false);
795
828
  currentUserIdRef.current = capturedUser.id;
@@ -855,6 +888,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
855
888
  api,
856
889
  analytics,
857
890
  claimActiveVisit,
891
+ initialRuntimeIdentity.clientUserId,
858
892
  isFunnelStepId,
859
893
  isPreviewRuntime,
860
894
  navigateToStep,
@@ -80,6 +80,7 @@ declare class ApiService {
80
80
  userId: string;
81
81
  email: string;
82
82
  environment: 'test' | 'live';
83
+ answers?: FunnelUserAnswers;
83
84
  }): Promise<EmailCaptureResult>;
84
85
  syncUrlUserAttributes(input: {
85
86
  user: AppUserInput;
@@ -253,8 +253,8 @@ class ApiService {
253
253
  async bootstrapSession(input) {
254
254
  var _a, _b, _c;
255
255
  const urlUserAttributes = resolveUrlUserAttributes();
256
- const inputEmail = (input === null || input === void 0 ? void 0 : input.email) || urlUserAttributes.email || undefined;
257
- const inputName = (input === null || input === void 0 ? void 0 : input.name) || urlUserAttributes.name || undefined;
256
+ const inputEmail = (input === null || input === void 0 ? void 0 : input.email) || undefined;
257
+ const inputName = (input === null || input === void 0 ? void 0 : input.name) || undefined;
258
258
  const candidateUserId = (input === null || input === void 0 ? void 0 : input.userId) || urlUserAttributes.userId;
259
259
  const userId = await this.resolveBootstrapUserId(candidateUserId);
260
260
  const publishableKey = getFunnelSdkPublishableKey();
@@ -344,6 +344,7 @@ class ApiService {
344
344
  userId,
345
345
  email,
346
346
  environment: input.environment,
347
+ answers: input.answers,
347
348
  });
348
349
  const eventId = asString(payload.eventId);
349
350
  const responseUser = isRecord(payload.user) ? payload.user : null;
@@ -27,6 +27,7 @@ export type FunnelSdkCaptureEmailInput = {
27
27
  userId: string;
28
28
  email: string;
29
29
  environment: FunnelSdkRuntimeMode;
30
+ answers?: FunnelSdkJsonObject;
30
31
  runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
31
32
  };
32
33
  export type FunnelSdkCaptureEmailResponse = {
@@ -122,6 +122,7 @@ class FunnelSdkService {
122
122
  user_id: input.userId,
123
123
  email: input.email,
124
124
  environment: input.environment,
125
+ answers: input.answers,
125
126
  stepContractVersion: CURRENT_STEP_CONTRACT_VERSION,
126
127
  },
127
128
  errorMessage: 'Failed to capture email',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.7.28",
3
+ "version": "0.7.29",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",