@funnelsgrove/runtime 0.7.6 → 0.7.8

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;
@@ -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;
@@ -5,6 +5,7 @@ import { applyUrlUserAttributesToUser, hasUrlUserProfileAttributes, resolveUrlUs
5
5
  import { getRuntimeMode } from './runtime-mode.service.js';
6
6
  const DEFAULT_USER_ID_STORAGE_KEY = 'funnel:user-id';
7
7
  const DEFAULT_BOOTSTRAP_IDEMPOTENCY_KEY_STORAGE_KEY = 'funnel:bootstrap-idempotency-key';
8
+ const SUBSCRIPTION_MANAGEMENT_LINK_ERROR = 'This subscription management link is invalid or has expired. Please request a new link or contact support.';
8
9
  const canUseDom = () => {
9
10
  return typeof window !== 'undefined';
10
11
  };
@@ -214,15 +215,15 @@ class ApiService {
214
215
  }
215
216
  return persistUserId(generateUserId(), FUNNEL_ID);
216
217
  }
217
- getSubscriptionManagementUserId() {
218
- const locationUserId = resolveUrlUserAttributes().userId;
219
- if (locationUserId) {
220
- return persistUserId(locationUserId, FUNNEL_ID);
221
- }
222
- if (this.getSubscriptionManagementStripeCustomerId()) {
223
- return null;
218
+ persistCanonicalUserId(userId) {
219
+ const normalizedUserId = normalizeUserId(userId);
220
+ if (!normalizedUserId) {
221
+ throw new Error('Invalid canonical user id');
224
222
  }
225
- return this.getOrCreateClientUserId();
223
+ return persistUserId(normalizedUserId, FUNNEL_ID);
224
+ }
225
+ getSubscriptionManagementUserId() {
226
+ return resolveUrlUserAttributes().userId;
226
227
  }
227
228
  getSubscriptionManagementStripeCustomerId() {
228
229
  return resolveUrlUserAttributes().stripeCustomerId;
@@ -313,7 +314,10 @@ class ApiService {
313
314
  });
314
315
  }
315
316
  async updateUser(user, options) {
316
- const persistedUserId = persistUserId(user.id || this.getOrCreateClientUserId(), FUNNEL_ID);
317
+ const userId = normalizeUserId(user.id) || this.getOrCreateClientUserId();
318
+ const persistedUserId = (options === null || options === void 0 ? void 0 : options.persistClientIdentity) === false
319
+ ? userId
320
+ : persistUserId(userId, FUNNEL_ID);
317
321
  const publishableKey = getFunnelSdkPublishableKey();
318
322
  if (!publishableKey) {
319
323
  return toAppUser({
@@ -347,7 +351,7 @@ class ApiService {
347
351
  });
348
352
  }
349
353
  async captureEmail(input) {
350
- var _a, _b;
354
+ var _a, _b, _c, _d;
351
355
  const userId = input.userId.trim();
352
356
  const email = input.email.trim().toLowerCase();
353
357
  const payload = await funnelSdkService.captureEmail({
@@ -359,15 +363,42 @@ class ApiService {
359
363
  const responseUser = isRecord(payload.user) ? payload.user : null;
360
364
  const responseUserId = responseUser === null || responseUser === void 0 ? void 0 : responseUser.user_id;
361
365
  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;
366
+ const canonicalUserValue = payload.canonicalUser;
367
+ const canonicalUserRecord = isRecord(canonicalUserValue)
368
+ ? canonicalUserValue
369
+ : null;
370
+ const canonicalUserId = asString(canonicalUserRecord === null || canonicalUserRecord === void 0 ? void 0 : canonicalUserRecord.user_id);
371
+ 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;
372
+ const hasCanonicalUser = canonicalUserValue !== undefined
373
+ && canonicalUserValue !== null;
374
+ const canonicalUserKeys = canonicalUserRecord
375
+ ? Object.keys(canonicalUserRecord)
376
+ : [];
362
377
  if (!eventId
363
378
  || typeof payload.eventCreated !== 'boolean'
379
+ || typeof payload.activeSubscription !== 'boolean'
364
380
  || !responseUser
365
381
  || typeof responseUserId !== 'string'
366
382
  || responseUserId !== userId
367
- || responseEmail !== email) {
383
+ || responseEmail !== email
384
+ || (hasCanonicalUser
385
+ && (!canonicalUserRecord
386
+ || !canonicalUserId
387
+ || canonicalUserEmail !== email
388
+ || canonicalUserKeys.length !== 2
389
+ || canonicalUserKeys.some((key) => key !== 'user_id' && key !== 'email')))) {
368
390
  throw new Error('Invalid email capture response');
369
391
  }
370
392
  const persistedUserId = persistUserId(userId, FUNNEL_ID);
393
+ const canonicalUser = canonicalUserRecord && canonicalUserId
394
+ ? {
395
+ id: canonicalUserId,
396
+ name: '',
397
+ email,
398
+ attributes: {},
399
+ document: {},
400
+ }
401
+ : null;
371
402
  return {
372
403
  user: toAppUser({
373
404
  apiUser: Object.assign(Object.assign({}, responseUser), { user_id: userId, email }),
@@ -376,6 +407,8 @@ class ApiService {
376
407
  }),
377
408
  eventId,
378
409
  eventCreated: payload.eventCreated,
410
+ canonicalUser,
411
+ activeSubscription: payload.activeSubscription,
379
412
  };
380
413
  }
381
414
  async syncUrlUserAttributes(input) {
@@ -468,15 +501,28 @@ class ApiService {
468
501
  async getManageSubscriptions() {
469
502
  const userId = this.getSubscriptionManagementUserId();
470
503
  const stripeCustomerId = this.getSubscriptionManagementStripeCustomerId();
471
- return funnelSdkService.listSubscriptions({
504
+ if (!userId && !stripeCustomerId) {
505
+ throw new Error(SUBSCRIPTION_MANAGEMENT_LINK_ERROR);
506
+ }
507
+ const payload = await funnelSdkService.listSubscriptions({
472
508
  userId,
473
509
  stripeCustomerId,
474
510
  funnelId: FUNNEL_ID || undefined,
475
511
  });
512
+ if (!payload.user) {
513
+ throw new Error(SUBSCRIPTION_MANAGEMENT_LINK_ERROR);
514
+ }
515
+ if (userId) {
516
+ persistUserId(payload.user.user_id || userId, FUNNEL_ID);
517
+ }
518
+ return payload;
476
519
  }
477
520
  async updateSubscription(input) {
478
521
  const userId = this.getSubscriptionManagementUserId();
479
522
  const stripeCustomerId = this.getSubscriptionManagementStripeCustomerId();
523
+ if (!userId && !stripeCustomerId) {
524
+ throw new Error(SUBSCRIPTION_MANAGEMENT_LINK_ERROR);
525
+ }
480
526
  return funnelSdkService.updateSubscription({
481
527
  subscriptionId: input.subscriptionId,
482
528
  action: input.action,
@@ -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.6",
3
+ "version": "0.7.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",