@funnelsgrove/runtime 0.7.5 → 0.7.7

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.
@@ -21,6 +21,7 @@ export type FunnelContextValue = {
21
21
  activeStepId: FunnelStepId;
22
22
  featureFlags: Record<string, string>;
23
23
  isBuilder: boolean;
24
+ isReturningSubscriber: boolean;
24
25
  goToStep: (stepId: string, outcome: FunnelNavigationOutcome) => void;
25
26
  goNext: () => void;
26
27
  goChoice: (choice: 'yes' | 'no', stepId?: string) => void;
@@ -119,18 +119,24 @@ const resolveOfferItemCatalogKey = (item, baseCatalog) => {
119
119
  const explicitKey = readOfferItemExplicitRuntimePlanKey(item);
120
120
  return ((_c = (_b = (_a = (explicitKey ? resolveCatalogKey(baseCatalog, explicitKey) : null)) !== null && _a !== void 0 ? _a : resolveCatalogKey(baseCatalog, item.funnelPlanKey)) !== null && _b !== void 0 ? _b : explicitKey) !== null && _c !== void 0 ? _c : readString(item.funnelPlanKey));
121
121
  };
122
- const readGeneratedPlanStringValue = (item, mode, itemKeys, metadataKeys = itemKeys) => {
123
- var _a, _b;
122
+ const readGeneratedPlanStringValue = (item, mode, itemKeys, metadataKeys = itemKeys, fallbackMode) => {
123
+ var _a, _b, _c;
124
124
  const modeConfig = readGeneratedModeConfig(item, mode);
125
+ const fallbackModeConfig = fallbackMode
126
+ ? readGeneratedModeConfig(item, fallbackMode)
127
+ : {};
125
128
  const metadata = readOfferItemMetadata(item);
126
- return ((_b = (_a = readStringField(modeConfig, metadataKeys)) !== null && _a !== void 0 ? _a : itemKeys.reduce((result, key) => result !== null && result !== void 0 ? result : readString(item[key]), null)) !== null && _b !== void 0 ? _b : readStringField(metadata, metadataKeys));
129
+ return ((_c = (_b = (_a = readStringField(modeConfig, metadataKeys)) !== null && _a !== void 0 ? _a : readStringField(fallbackModeConfig, metadataKeys)) !== null && _b !== void 0 ? _b : itemKeys.reduce((result, key) => result !== null && result !== void 0 ? result : readString(item[key]), null)) !== null && _c !== void 0 ? _c : readStringField(metadata, metadataKeys));
127
130
  };
128
- const readGeneratedPlanNumberValue = (item, mode, itemKeys, metadataKeys = itemKeys) => {
129
- var _a, _b;
131
+ const readGeneratedPlanNumberValue = (item, mode, itemKeys, metadataKeys = itemKeys, fallbackMode) => {
132
+ var _a, _b, _c;
130
133
  const modeConfig = readGeneratedModeConfig(item, mode);
134
+ const fallbackModeConfig = fallbackMode
135
+ ? readGeneratedModeConfig(item, fallbackMode)
136
+ : {};
131
137
  const metadata = readOfferItemMetadata(item);
132
138
  const itemValue = itemKeys.reduce((result, key) => (result !== null && result !== void 0 ? result : readFiniteNumber(item[key])), null);
133
- return ((_b = (_a = readFiniteNumberField(modeConfig, metadataKeys)) !== null && _a !== void 0 ? _a : itemValue) !== null && _b !== void 0 ? _b : readFiniteNumberField(metadata, metadataKeys));
139
+ return ((_c = (_b = (_a = readFiniteNumberField(modeConfig, metadataKeys)) !== null && _a !== void 0 ? _a : readFiniteNumberField(fallbackModeConfig, metadataKeys)) !== null && _b !== void 0 ? _b : itemValue) !== null && _c !== void 0 ? _c : readFiniteNumberField(metadata, metadataKeys));
134
140
  };
135
141
  const buildGeneratedPlanOverrides = (item, mode, isDefault) => {
136
142
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
@@ -140,17 +146,20 @@ const buildGeneratedPlanOverrides = (item, mode, isDefault) => {
140
146
  const projectPlanId = (_a = readString(item.projectBillingPlanId)) !== null && _a !== void 0 ? _a : readStringField(metadata, ['projectBillingPlanId', 'projectPlanId', 'project_billing_plan_id']);
141
147
  const title = (_c = (_b = readString(item.title)) !== null && _b !== void 0 ? _b : readString(item.displayNameOverride)) !== null && _c !== void 0 ? _c : readStringField(metadata, ['title', 'displayName', 'publicPlanName', 'public_plan_name']);
142
148
  const description = readGeneratedPlanStringValue(item, mode, ['description']);
143
- const priceLabel = readGeneratedPlanStringValue(item, mode, ['priceLabel']);
144
- const perDayAmount = readGeneratedPlanStringValue(item, mode, ['perDayAmount']);
145
- const perDayLabel = readGeneratedPlanStringValue(item, mode, ['perDayLabel']);
146
- const checkoutSummaryLabel = readGeneratedPlanStringValue(item, mode, ['checkoutSummaryLabel']);
147
- const billingInterval = readGeneratedPlanStringValue(item, mode, ['billingInterval']);
148
- const billingIntervalCount = readGeneratedPlanNumberValue(item, mode, ['billingIntervalCount']);
149
+ const priceDisplayFallbackMode = mode === 'live' ? 'test' : undefined;
150
+ const readPriceString = (key) => (readGeneratedPlanStringValue(item, mode, [key], [String(key)], priceDisplayFallbackMode));
151
+ const readPriceNumber = (key) => (readGeneratedPlanNumberValue(item, mode, [key], [String(key)], priceDisplayFallbackMode));
152
+ const priceLabel = readPriceString('priceLabel');
153
+ const perDayAmount = readPriceString('perDayAmount');
154
+ const perDayLabel = readPriceString('perDayLabel');
155
+ const checkoutSummaryLabel = readPriceString('checkoutSummaryLabel');
156
+ const billingInterval = readPriceString('billingInterval');
157
+ const billingIntervalCount = readPriceNumber('billingIntervalCount');
149
158
  const followUpPlanTitle = (_e = (_d = readString(item.followUpPlanTitle)) !== null && _d !== void 0 ? _d : readString(item.followUpTitle)) !== null && _e !== void 0 ? _e : readStringField(metadata, ['followUpPlanTitle', 'followUpTitle', 'follow_up_plan_title']);
150
159
  const followUpBillingInterval = readGeneratedPlanStringValue(item, mode, ['followUpBillingInterval'], ['followUpBillingInterval', 'follow_up_billing_interval']);
151
160
  const followUpBillingIntervalCount = readGeneratedPlanNumberValue(item, mode, ['followUpBillingIntervalCount'], ['followUpBillingIntervalCount', 'follow_up_billing_interval_count']);
152
161
  const introIterations = readGeneratedPlanNumberValue(item, mode, ['introIterations'], ['introIterations', 'intro_iterations']);
153
- const amountCents = readGeneratedPlanNumberValue(item, mode, ['amountCents']);
162
+ const amountCents = readPriceNumber('amountCents');
154
163
  const modeConfig = readGeneratedModeConfig(item, mode);
155
164
  const paymentMode = (_m = (_l = (_k = (_j = (_h = (_g = (_f = readOneTimePaymentMode(modeConfig.paymentMode)) !== null && _f !== void 0 ? _f : readOneTimePaymentMode(modeConfig.payment_mode)) !== null && _g !== void 0 ? _g : readOneTimePaymentMode(modeConfig.checkoutMode)) !== null && _h !== void 0 ? _h : readOneTimePaymentMode(modeConfig.checkout_mode)) !== null && _j !== void 0 ? _j : readOneTimePaymentMode(metadata.paymentMode)) !== null && _k !== void 0 ? _k : readOneTimePaymentMode(metadata.payment_mode)) !== null && _l !== void 0 ? _l : readOneTimePaymentMode(metadata.checkoutMode)) !== null && _m !== void 0 ? _m : readOneTimePaymentMode(metadata.checkout_mode);
156
165
  const analyticsPurchaseValue = (_o = readFiniteNumber(item.analyticsPurchaseValue)) !== null && _o !== void 0 ? _o : readFiniteNumberField(metadata, ['analyticsPurchaseValue']);
@@ -2,6 +2,8 @@ export type EmailCapturePersistenceResult<User> = {
2
2
  user: User;
3
3
  eventId: string;
4
4
  eventCreated: boolean;
5
+ canonicalUser: User | null;
6
+ activeSubscription: boolean;
5
7
  };
6
8
  export type EmailCaptureSingleFlight = {
7
9
  current: Promise<void> | null;
@@ -13,10 +15,11 @@ export type SubmitEmailCaptureInput<User> = {
13
15
  retryPendingNavigation?: () => boolean | Promise<boolean>;
14
16
  persist: (email: string) => Promise<EmailCapturePersistenceResult<User>>;
15
17
  applyUser: (user: User) => void;
18
+ adoptCanonicalUser: (user: User) => void;
16
19
  applyPreviewEmail: (email: string) => void;
17
20
  fanOutBrowserDestinations: (eventId: string) => void | Promise<void>;
18
21
  completeVisit: () => boolean;
19
- resolveNextStep: () => string | null;
22
+ resolveNextStep: (canonicalUser: User | null, activeSubscription: boolean) => string | null;
20
23
  navigate: (stepId: string) => void;
21
24
  };
22
25
  export declare const createEmailCaptureSingleFlight: () => EmailCaptureSingleFlight;
@@ -15,11 +15,15 @@ const runEmailCapture = async (input) => {
15
15
  return;
16
16
  }
17
17
  const email = normalizeEmail(input.email);
18
+ let canonicalUser = null;
19
+ let activeSubscription = false;
18
20
  if (input.isPreview) {
19
21
  input.applyPreviewEmail(email);
20
22
  }
21
23
  else {
22
24
  const result = await input.persist(email);
25
+ canonicalUser = result.canonicalUser;
26
+ activeSubscription = result.activeSubscription;
23
27
  input.applyUser(result.user);
24
28
  if (result.eventCreated) {
25
29
  try {
@@ -33,7 +37,10 @@ const runEmailCapture = async (input) => {
33
37
  if (!input.completeVisit()) {
34
38
  return;
35
39
  }
36
- const nextStepId = input.resolveNextStep();
40
+ if (canonicalUser) {
41
+ input.adoptCanonicalUser(canonicalUser);
42
+ }
43
+ const nextStepId = input.resolveNextStep(canonicalUser, activeSubscription);
37
44
  if (nextStepId) {
38
45
  input.navigate(nextStepId);
39
46
  }
@@ -105,7 +105,7 @@ type FunnelFlowAnalyticsAdapter = {
105
105
  featureFlags?: Record<string, string>;
106
106
  }) => string | null;
107
107
  };
108
- export type FunnelFlowApiAdapter = Pick<typeof apiService, 'getOrCreateClientUserId' | 'getBootstrapCandidateUserId' | 'bootstrapSession' | 'pingUserContext' | 'updateUser' | 'captureEmail'>;
108
+ export type FunnelFlowApiAdapter = Pick<typeof apiService, 'getOrCreateClientUserId' | 'getBootstrapCandidateUserId' | 'bootstrapSession' | 'pingUserContext' | 'persistCanonicalUserId' | 'updateUser' | 'captureEmail'>;
109
109
  type UseFunnelFlowControllerInput<StepId extends string> = {
110
110
  api?: FunnelFlowApiAdapter;
111
111
  analytics?: FunnelFlowAnalyticsAdapter;
@@ -319,6 +319,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
319
319
  completedSteps: [],
320
320
  }));
321
321
  const [userBootstrapped, setUserBootstrapped] = useState(isPreviewRuntime);
322
+ const [isReturningSubscriber, setIsReturningSubscriber] = useState(false);
322
323
  const [attributionReady, setAttributionReady] = useState(isPreviewRuntime);
323
324
  const attributesAtStepStart = useRef({});
324
325
  const attributesRef = useRef(attributes);
@@ -624,6 +625,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
624
625
  document: (_b = updatedUser.document) !== null && _b !== void 0 ? _b : {},
625
626
  }, {
626
627
  attribution: collectCurrentFunnelAttribution(),
628
+ persistClientIdentity: false,
627
629
  })
628
630
  .catch((error) => {
629
631
  logger.error('Failed to persist step completion:', error);
@@ -761,9 +763,15 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
761
763
  environment: getRuntimeMode(),
762
764
  }),
763
765
  applyUser: (capturedUser) => {
766
+ setIsReturningSubscriber(false);
764
767
  currentUserIdRef.current = capturedUser.id;
765
768
  setUser((prev) => (Object.assign(Object.assign(Object.assign({}, prev), capturedUser), { completedSteps: prev.completedSteps })));
766
769
  },
770
+ adoptCanonicalUser: (canonicalUser) => {
771
+ const canonicalUserId = api.persistCanonicalUserId(canonicalUser.id);
772
+ currentUserIdRef.current = canonicalUserId;
773
+ setUser((prev) => (Object.assign(Object.assign({}, prev), { id: canonicalUserId, email: canonicalUser.email, completedSteps: prev.completedSteps })));
774
+ },
767
775
  applyPreviewEmail: (normalizedEmail) => {
768
776
  setUser((prev) => (Object.assign(Object.assign({}, prev), { email: normalizedEmail })));
769
777
  },
@@ -785,10 +793,21 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
785
793
  && ((_a = activeVisitTokenRef.current) === null || _a === void 0 ? void 0 : _a.visitId) === submittedVisitId
786
794
  && claimActiveVisit(submittedStepId, { type: 'complete' }));
787
795
  },
788
- resolveNextStep: () => {
789
- const targetStepId = resolveNextStepId(submittedStepId, {
796
+ resolveNextStep: (_canonicalUser, activeSubscription) => {
797
+ var _a;
798
+ const manifestTargetStepId = resolveNextStepId(submittedStepId, {
790
799
  ignoreExperiment: Boolean(activeExperimentForStep && submittedStepId === safeActiveStepId),
791
800
  });
801
+ const handoffStepId = activeSubscription
802
+ ? (_a = stepSequence.find((stepId) => {
803
+ const step = stepById[stepId];
804
+ return (step === null || step === void 0 ? void 0 : step.type) === 'purchase_completed'
805
+ && step.kind === 'subscription-handoff'
806
+ && Boolean(stepComponentById[stepId]);
807
+ })) !== null && _a !== void 0 ? _a : null
808
+ : null;
809
+ const targetStepId = handoffStepId || manifestTargetStepId;
810
+ setIsReturningSubscriber(Boolean(activeSubscription && handoffStepId));
792
811
  if (submittedVisitId !== null) {
793
812
  pendingEmailCaptureNavigationRef.current = {
794
813
  visitId: submittedVisitId,
@@ -816,6 +835,8 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
816
835
  resolveNextStepId,
817
836
  safeActiveStepId,
818
837
  stepById,
838
+ stepComponentById,
839
+ stepSequence,
819
840
  ]);
820
841
  useEffect(() => {
821
842
  const abandonCurrentVisit = (event) => {
@@ -1296,6 +1317,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
1296
1317
  activeStepId: renderedStepId,
1297
1318
  featureFlags: postHogFeatureFlags,
1298
1319
  isBuilder: isPreviewRuntime,
1320
+ isReturningSubscriber,
1299
1321
  goToStep: goToStepFromContext,
1300
1322
  goNext,
1301
1323
  goChoice,
@@ -1331,6 +1353,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
1331
1353
  goNext,
1332
1354
  goToStepFromContext,
1333
1355
  isPreviewRuntime,
1356
+ isReturningSubscriber,
1334
1357
  postHogFeatureFlags,
1335
1358
  renderedStepId,
1336
1359
  resolveRenderableStepId,
@@ -55,10 +55,13 @@ export type EmailCaptureResult = {
55
55
  user: AppUser;
56
56
  eventId: string;
57
57
  eventCreated: boolean;
58
+ canonicalUser: AppUser | null;
59
+ activeSubscription: boolean;
58
60
  };
59
61
  declare class ApiService {
60
62
  private readFileAsDataUrl;
61
63
  getOrCreateClientUserId(): string;
64
+ persistCanonicalUserId(userId: string): string;
62
65
  getSubscriptionManagementUserId(): string | null;
63
66
  getSubscriptionManagementStripeCustomerId(): string | null;
64
67
  getBootstrapCandidateUserId(fallbackUserId?: string | null): string | null;
@@ -72,6 +75,7 @@ declare class ApiService {
72
75
  }): Promise<AppUser>;
73
76
  updateUser(user: AppUser, options?: {
74
77
  attribution?: FunnelUserAttribution;
78
+ persistClientIdentity?: boolean;
75
79
  }): Promise<AppUser>;
76
80
  captureEmail(input: {
77
81
  userId: string;
@@ -214,6 +214,13 @@ class ApiService {
214
214
  }
215
215
  return persistUserId(generateUserId(), FUNNEL_ID);
216
216
  }
217
+ persistCanonicalUserId(userId) {
218
+ const normalizedUserId = normalizeUserId(userId);
219
+ if (!normalizedUserId) {
220
+ throw new Error('Invalid canonical user id');
221
+ }
222
+ return persistUserId(normalizedUserId, FUNNEL_ID);
223
+ }
217
224
  getSubscriptionManagementUserId() {
218
225
  const locationUserId = resolveUrlUserAttributes().userId;
219
226
  if (locationUserId) {
@@ -313,7 +320,10 @@ class ApiService {
313
320
  });
314
321
  }
315
322
  async updateUser(user, options) {
316
- const persistedUserId = persistUserId(user.id || this.getOrCreateClientUserId(), FUNNEL_ID);
323
+ const userId = normalizeUserId(user.id) || this.getOrCreateClientUserId();
324
+ const persistedUserId = (options === null || options === void 0 ? void 0 : options.persistClientIdentity) === false
325
+ ? userId
326
+ : persistUserId(userId, FUNNEL_ID);
317
327
  const publishableKey = getFunnelSdkPublishableKey();
318
328
  if (!publishableKey) {
319
329
  return toAppUser({
@@ -347,7 +357,7 @@ class ApiService {
347
357
  });
348
358
  }
349
359
  async captureEmail(input) {
350
- var _a, _b;
360
+ var _a, _b, _c, _d;
351
361
  const userId = input.userId.trim();
352
362
  const email = input.email.trim().toLowerCase();
353
363
  const payload = await funnelSdkService.captureEmail({
@@ -359,15 +369,42 @@ class ApiService {
359
369
  const responseUser = isRecord(payload.user) ? payload.user : null;
360
370
  const responseUserId = responseUser === null || responseUser === void 0 ? void 0 : responseUser.user_id;
361
371
  const responseEmail = (_b = (_a = asString(responseUser === null || responseUser === void 0 ? void 0 : responseUser.email)) === null || _a === void 0 ? void 0 : _a.toLowerCase()) !== null && _b !== void 0 ? _b : null;
372
+ const canonicalUserValue = payload.canonicalUser;
373
+ const canonicalUserRecord = isRecord(canonicalUserValue)
374
+ ? canonicalUserValue
375
+ : null;
376
+ const canonicalUserId = asString(canonicalUserRecord === null || canonicalUserRecord === void 0 ? void 0 : canonicalUserRecord.user_id);
377
+ const canonicalUserEmail = (_d = (_c = asString(canonicalUserRecord === null || canonicalUserRecord === void 0 ? void 0 : canonicalUserRecord.email)) === null || _c === void 0 ? void 0 : _c.toLowerCase()) !== null && _d !== void 0 ? _d : null;
378
+ const hasCanonicalUser = canonicalUserValue !== undefined
379
+ && canonicalUserValue !== null;
380
+ const canonicalUserKeys = canonicalUserRecord
381
+ ? Object.keys(canonicalUserRecord)
382
+ : [];
362
383
  if (!eventId
363
384
  || typeof payload.eventCreated !== 'boolean'
385
+ || typeof payload.activeSubscription !== 'boolean'
364
386
  || !responseUser
365
387
  || typeof responseUserId !== 'string'
366
388
  || responseUserId !== userId
367
- || responseEmail !== email) {
389
+ || responseEmail !== email
390
+ || (hasCanonicalUser
391
+ && (!canonicalUserRecord
392
+ || !canonicalUserId
393
+ || canonicalUserEmail !== email
394
+ || canonicalUserKeys.length !== 2
395
+ || canonicalUserKeys.some((key) => key !== 'user_id' && key !== 'email')))) {
368
396
  throw new Error('Invalid email capture response');
369
397
  }
370
398
  const persistedUserId = persistUserId(userId, FUNNEL_ID);
399
+ const canonicalUser = canonicalUserRecord && canonicalUserId
400
+ ? {
401
+ id: canonicalUserId,
402
+ name: '',
403
+ email,
404
+ attributes: {},
405
+ document: {},
406
+ }
407
+ : null;
371
408
  return {
372
409
  user: toAppUser({
373
410
  apiUser: Object.assign(Object.assign({}, responseUser), { user_id: userId, email }),
@@ -376,6 +413,8 @@ class ApiService {
376
413
  }),
377
414
  eventId,
378
415
  eventCreated: payload.eventCreated,
416
+ canonicalUser,
417
+ activeSubscription: payload.activeSubscription,
379
418
  };
380
419
  }
381
420
  async syncUrlUserAttributes(input) {
@@ -36,6 +36,11 @@ export type FunnelSdkCaptureEmailResponse = {
36
36
  email?: unknown;
37
37
  document?: unknown;
38
38
  };
39
+ canonicalUser?: {
40
+ user_id?: unknown;
41
+ email?: unknown;
42
+ } | null;
43
+ activeSubscription: boolean;
39
44
  eventId: string;
40
45
  eventCreated: boolean;
41
46
  };
@@ -193,6 +193,7 @@ export const runFunnelContractJourney = async (options) => {
193
193
  getBootstrapCandidateUserId: () => journeyUser.id,
194
194
  bootstrapSession: async () => journeyUser,
195
195
  pingUserContext: async () => null,
196
+ persistCanonicalUserId: (userId) => userId,
196
197
  updateUser: async (user) => user,
197
198
  captureEmail: async (captureInput) => {
198
199
  emailCaptureCalls.push(captureInput);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",