@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.
- package/dist/components/FunnelContext.d.ts +1 -0
- package/dist/runtime/submit-email-capture.d.ts +4 -1
- package/dist/runtime/submit-email-capture.js +8 -1
- package/dist/runtime/use-funnel-flow-controller.d.ts +1 -1
- package/dist/runtime/use-funnel-flow-controller.js +25 -2
- package/dist/services/api.service.d.ts +4 -0
- package/dist/services/api.service.js +58 -12
- package/dist/services/funnel-sdk.service.d.ts +5 -0
- package/dist/testing/funnel-contract-journey-driver.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|