@funnelsgrove/runtime 0.1.55 → 0.1.59
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 +5 -1
- package/dist/components/FunnelContext.js +2 -1
- package/dist/components/ManageSubscriptionScreen.js +4 -1
- package/dist/components/SubscriptionHandoffScreen.d.ts +2 -1
- package/dist/components/SubscriptionHandoffScreen.js +11 -6
- package/dist/config/funnel.manifest.types.d.ts +4 -2
- package/dist/generated/step-contract-hash.d.ts +1 -0
- package/dist/generated/step-contract-hash.js +2 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/migrations/step-contract-v2.d.ts +50 -0
- package/dist/migrations/step-contract-v2.js +310 -0
- package/dist/migrations/step-contract-v3.d.ts +41 -0
- package/dist/migrations/step-contract-v3.js +26 -0
- package/dist/runtime/funnel-attribution.d.ts +1 -0
- package/dist/runtime/funnel-attribution.js +24 -0
- package/dist/runtime/funnel-manifest.validation.d.ts +7 -2
- package/dist/runtime/funnel-manifest.validation.js +24 -68
- package/dist/runtime/funnel-step-lifecycle.d.ts +35 -0
- package/dist/runtime/funnel-step-lifecycle.js +49 -0
- package/dist/runtime/funnel-step-metadata.validation.d.ts +7 -0
- package/dist/runtime/funnel-step-metadata.validation.js +88 -0
- package/dist/runtime/submit-email-capture.d.ts +23 -0
- package/dist/runtime/submit-email-capture.js +54 -0
- package/dist/runtime/use-funnel-flow-controller.d.ts +71 -5
- package/dist/runtime/use-funnel-flow-controller.js +850 -116
- package/dist/runtime/use-step-choices.d.ts +58 -0
- package/dist/runtime/use-step-choices.js +187 -0
- package/dist/sdk/userAnswers.d.ts +1 -0
- package/dist/services/api.service.d.ts +10 -0
- package/dist/services/api.service.js +32 -0
- package/dist/services/funnel-sdk.service.d.ts +17 -0
- package/dist/services/funnel-sdk.service.js +13 -0
- package/dist/steps/choice-contract.d.ts +33 -0
- package/dist/steps/choice-contract.js +302 -0
- package/dist/steps/step-contract.d.ts +498 -0
- package/dist/steps/step-contract.js +506 -0
- package/dist/steps/types.d.ts +7 -5
- package/dist/steps/types.js +1 -18
- package/dist/testing/funnel-contract-journey-driver.d.ts +35 -0
- package/dist/testing/funnel-contract-journey-driver.js +266 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/validation/fixtures/invalid-reserved-identities.d.ts +249 -0
- package/dist/validation/fixtures/invalid-reserved-identities.js +20 -0
- package/dist/validation/fixtures/valid-v2-project.d.ts +419 -0
- package/dist/validation/fixtures/valid-v2-project.js +228 -0
- package/dist/validation/fixtures/valid-v3-project.d.ts +419 -0
- package/dist/validation/fixtures/valid-v3-project.js +30 -0
- package/dist/validation/funnel-contract-diagnostics.d.ts +49 -0
- package/dist/validation/funnel-contract-diagnostics.js +67 -0
- package/dist/validation/funnel-contract-validator.d.ts +30 -0
- package/dist/validation/funnel-contract-validator.js +1458 -0
- package/dist/validation/funnel-manifest-input.normalization.d.ts +67 -0
- package/dist/validation/funnel-manifest-input.normalization.js +386 -0
- package/package.json +9 -1
|
@@ -15,4 +15,5 @@ export declare const collectCurrentFunnelAttribution: (input?: {
|
|
|
15
15
|
posthogProperties?: Record<string, unknown> | null;
|
|
16
16
|
}) => FunnelUserAttribution;
|
|
17
17
|
export declare const mergeFunnelUserAttribution: (current: unknown, incoming: FunnelUserAttribution | null | undefined) => FunnelUserAttribution;
|
|
18
|
+
export declare const buildFunnelAttributionEventMetadata: (attribution: FunnelUserAttribution | null | undefined) => Record<string, string>;
|
|
18
19
|
export declare const getFunnelUserAttribution: (document: unknown) => FunnelUserAttribution | null;
|
|
@@ -6,6 +6,22 @@ const INTERNAL_ATTRIBUTION_QUERY_KEYS = new Set([
|
|
|
6
6
|
'step',
|
|
7
7
|
'user_id',
|
|
8
8
|
]);
|
|
9
|
+
const EVENT_ATTRIBUTION_CLICK_ID_KEYS = new Set([
|
|
10
|
+
'_fbc',
|
|
11
|
+
'_fbp',
|
|
12
|
+
'epik',
|
|
13
|
+
'fbc',
|
|
14
|
+
'fbclid',
|
|
15
|
+
'fbp',
|
|
16
|
+
'gbraid',
|
|
17
|
+
'gclid',
|
|
18
|
+
'li_fat_id',
|
|
19
|
+
'msclkid',
|
|
20
|
+
'scclid',
|
|
21
|
+
'ttclid',
|
|
22
|
+
'twclid',
|
|
23
|
+
'wbraid',
|
|
24
|
+
]);
|
|
9
25
|
const POSTHOG_CONTEXT_MAPPINGS = {
|
|
10
26
|
$geoip_city_name: 'cityName',
|
|
11
27
|
$geoip_country_code: 'countryCode',
|
|
@@ -41,6 +57,13 @@ const normalizeTouch = (value) => {
|
|
|
41
57
|
return [[normalizedKey, normalizedValue]];
|
|
42
58
|
}));
|
|
43
59
|
};
|
|
60
|
+
const isEventAttributionKey = (key) => {
|
|
61
|
+
const normalizedKey = key.trim().toLowerCase();
|
|
62
|
+
return normalizedKey.startsWith('utm_') || EVENT_ATTRIBUTION_CLICK_ID_KEYS.has(normalizedKey);
|
|
63
|
+
};
|
|
64
|
+
const normalizeEventAttributionTouch = (value) => {
|
|
65
|
+
return Object.fromEntries(Object.entries(normalizeTouch(value)).filter(([key]) => isEventAttributionKey(key)));
|
|
66
|
+
};
|
|
44
67
|
const normalizeContext = (value) => {
|
|
45
68
|
if (!isRecord(value)) {
|
|
46
69
|
return {};
|
|
@@ -219,6 +242,7 @@ export const mergeFunnelUserAttribution = (current, incoming) => {
|
|
|
219
242
|
context: deepMergeRecords(currentContext, nextContext),
|
|
220
243
|
};
|
|
221
244
|
};
|
|
245
|
+
export const buildFunnelAttributionEventMetadata = (attribution) => normalizeEventAttributionTouch(attribution === null || attribution === void 0 ? void 0 : attribution.firstTouch);
|
|
222
246
|
export const getFunnelUserAttribution = (document) => {
|
|
223
247
|
if (!isRecord(document) || !isRecord(document.attribution)) {
|
|
224
248
|
return null;
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
import type
|
|
2
|
-
export
|
|
1
|
+
import { type FunnelManifestContractInput } from '../validation/funnel-contract-validator.js';
|
|
2
|
+
export type LegacyReadableFunnelManifest = FunnelManifestContractInput;
|
|
3
|
+
export declare const normalizeLegacyFunnelManifestForRead: <T extends LegacyReadableFunnelManifest>(manifest: T) => T & {
|
|
4
|
+
stepContractVersion: number;
|
|
5
|
+
};
|
|
6
|
+
export declare const validateFunnelManifest: <T extends LegacyReadableFunnelManifest>(manifest: T) => T;
|
|
7
|
+
export declare const validateAuthoringFunnelManifest: <T extends LegacyReadableFunnelManifest>(manifest: T) => T;
|
|
@@ -1,75 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const duplicates = new Set();
|
|
7
|
-
for (const value of values) {
|
|
8
|
-
if (seen.has(value)) {
|
|
9
|
-
duplicates.add(value);
|
|
10
|
-
continue;
|
|
11
|
-
}
|
|
12
|
-
seen.add(value);
|
|
13
|
-
}
|
|
14
|
-
if (duplicates.size > 0) {
|
|
15
|
-
errors.push(`duplicate ${label}: ${Array.from(duplicates).join(', ')}`);
|
|
1
|
+
import { LEGACY_UNVERSIONED_STEP_CONTRACT_VERSION, } from '../steps/step-contract.js';
|
|
2
|
+
import { validateFunnelManifestContract, } from '../validation/funnel-contract-validator.js';
|
|
3
|
+
export const normalizeLegacyFunnelManifestForRead = (manifest) => {
|
|
4
|
+
if (manifest.stepContractVersion !== undefined) {
|
|
5
|
+
return manifest;
|
|
16
6
|
}
|
|
7
|
+
return Object.assign(Object.assign({}, manifest), { stepContractVersion: LEGACY_UNVERSIONED_STEP_CONTRACT_VERSION });
|
|
17
8
|
};
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
const formatDiagnosticValue = (value) => {
|
|
10
|
+
var _a;
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
return value;
|
|
21
13
|
}
|
|
14
|
+
return (_a = JSON.stringify(value)) !== null && _a !== void 0 ? _a : String(value);
|
|
22
15
|
};
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
const stepIds = manifest.steps.map((step) => step.id);
|
|
32
|
-
const stepPaths = manifest.steps.map((step) => step.path);
|
|
33
|
-
const filePaths = manifest.steps.map((step) => step.filePath);
|
|
34
|
-
const componentKeys = manifest.steps.map((step) => step.componentKey);
|
|
35
|
-
const validStepIds = new Set(stepIds);
|
|
36
|
-
pushDuplicateErrors('step ids', stepIds, errors);
|
|
37
|
-
pushDuplicateErrors('step paths', stepPaths, errors);
|
|
38
|
-
pushDuplicateErrors('step file paths', filePaths, errors);
|
|
39
|
-
pushDuplicateErrors('step component keys', componentKeys, errors);
|
|
40
|
-
for (const entryPoint of manifest.entryPoints || []) {
|
|
41
|
-
assertValidReference(`entry point "${entryPoint.id}"`, entryPoint.stepId, validStepIds, errors);
|
|
42
|
-
}
|
|
43
|
-
for (const [stepId, edges] of Object.entries(manifest.edgesByStepId)) {
|
|
44
|
-
assertValidReference(`edge source "${stepId}"`, stepId, validStepIds, errors);
|
|
45
|
-
for (const edge of edges || []) {
|
|
46
|
-
assertValidReference(`edge from "${stepId}"`, edge.toStepId, validStepIds, errors);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
pushDuplicateErrors('branch ids', (manifest.branches || []).map((branch) => branch.id), errors);
|
|
50
|
-
for (const branch of manifest.branches || []) {
|
|
51
|
-
if (!isDefinedString(branch.id)) {
|
|
52
|
-
errors.push('branch id is required');
|
|
53
|
-
}
|
|
54
|
-
if (!isDefinedString(branch.name)) {
|
|
55
|
-
errors.push(`branch "${branch.id || ''}" name is required`);
|
|
56
|
-
}
|
|
57
|
-
assertValidReference(`branch "${branch.id}" source`, branch.sourceStepId, validStepIds, errors);
|
|
58
|
-
if (branch.stepIds.length === 0) {
|
|
59
|
-
errors.push(`branch "${branch.id}" must own at least one step`);
|
|
60
|
-
}
|
|
61
|
-
for (const stepId of branch.stepIds) {
|
|
62
|
-
assertValidReference(`branch "${branch.id}" step`, stepId, validStepIds, errors);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
for (const experiment of manifest.experiments) {
|
|
66
|
-
assertValidReference(`experiment "${experiment.experimentId}"`, experiment.stepId, validStepIds, errors);
|
|
67
|
-
for (const variant of experiment.variants) {
|
|
68
|
-
assertValidReference(`experiment variant "${experiment.experimentId}:${variant.variantKey}"`, variant.routeToStepId, validStepIds, errors);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
if (errors.length > 0) {
|
|
72
|
-
throw new Error(`Invalid funnel manifest:\n- ${errors.join('\n- ')}`);
|
|
16
|
+
const validateAuthoringManifest = (manifest) => {
|
|
17
|
+
const diagnostics = validateFunnelManifestContract(manifest, { mode: 'authoring' });
|
|
18
|
+
if (diagnostics.length > 0) {
|
|
19
|
+
const details = diagnostics.map((item) => {
|
|
20
|
+
return `[${item.code}] ${item.reason} Expected ${formatDiagnosticValue(item.expected)}; received ${formatDiagnosticValue(item.received)}.`;
|
|
21
|
+
});
|
|
22
|
+
throw new Error(`Invalid funnel manifest:\n- ${details.join('\n- ')}`);
|
|
73
23
|
}
|
|
74
24
|
return manifest;
|
|
75
25
|
};
|
|
26
|
+
export const validateFunnelManifest = (manifest) => {
|
|
27
|
+
return validateAuthoringManifest(manifest);
|
|
28
|
+
};
|
|
29
|
+
export const validateAuthoringFunnelManifest = (manifest) => {
|
|
30
|
+
return validateAuthoringManifest(manifest);
|
|
31
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type FunnelStepExitReason = 'back' | 'replace' | 'cancel' | 'abandon';
|
|
2
|
+
export type FunnelNavigationOutcome = {
|
|
3
|
+
type: 'complete';
|
|
4
|
+
selected?: Record<string, unknown>;
|
|
5
|
+
} | {
|
|
6
|
+
type: 'exit';
|
|
7
|
+
reason: FunnelStepExitReason;
|
|
8
|
+
};
|
|
9
|
+
export type FunnelStepVisit = {
|
|
10
|
+
readonly visitId: number;
|
|
11
|
+
readonly stepId: string;
|
|
12
|
+
readonly startedAt: string;
|
|
13
|
+
readonly outcome: null | 'completed' | 'exited';
|
|
14
|
+
};
|
|
15
|
+
export type FunnelStepLifecycleIntent = {
|
|
16
|
+
readonly type: 'complete';
|
|
17
|
+
readonly selected?: Readonly<Record<string, unknown>>;
|
|
18
|
+
readonly visit: FunnelStepVisit;
|
|
19
|
+
} | {
|
|
20
|
+
readonly type: 'exit';
|
|
21
|
+
readonly reason: FunnelStepExitReason;
|
|
22
|
+
readonly visit: FunnelStepVisit;
|
|
23
|
+
};
|
|
24
|
+
export type FunnelStepLifecycleStartIntent = {
|
|
25
|
+
readonly type: 'start';
|
|
26
|
+
readonly visit: FunnelStepVisit;
|
|
27
|
+
};
|
|
28
|
+
declare const funnelStepLifecycleStateBrand: unique symbol;
|
|
29
|
+
export type FunnelStepLifecycleState = {
|
|
30
|
+
readonly [funnelStepLifecycleStateBrand]: true;
|
|
31
|
+
};
|
|
32
|
+
export declare const createFunnelStepLifecycleState: () => FunnelStepLifecycleState;
|
|
33
|
+
export declare const startVisit: (state: FunnelStepLifecycleState, input: Pick<FunnelStepVisit, "stepId" | "startedAt">) => FunnelStepLifecycleStartIntent | null;
|
|
34
|
+
export declare const finishVisit: (state: FunnelStepLifecycleState, visitId: FunnelStepVisit["visitId"], outcome: FunnelNavigationOutcome) => FunnelStepLifecycleIntent | null;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const stateValues = new WeakMap();
|
|
2
|
+
const readState = (state) => {
|
|
3
|
+
const value = stateValues.get(state);
|
|
4
|
+
if (!value) {
|
|
5
|
+
throw new Error('Invalid funnel step lifecycle state.');
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
};
|
|
9
|
+
const snapshotVisit = (visit) => Object.freeze(Object.assign({}, visit));
|
|
10
|
+
export const createFunnelStepLifecycleState = () => {
|
|
11
|
+
const state = Object.freeze({});
|
|
12
|
+
stateValues.set(state, { activeVisit: null, nextVisitId: 1 });
|
|
13
|
+
return state;
|
|
14
|
+
};
|
|
15
|
+
export const startVisit = (state, input) => {
|
|
16
|
+
var _a;
|
|
17
|
+
const stateValue = readState(state);
|
|
18
|
+
if (((_a = stateValue.activeVisit) === null || _a === void 0 ? void 0 : _a.outcome) === null) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const visit = {
|
|
22
|
+
visitId: stateValue.nextVisitId,
|
|
23
|
+
stepId: input.stepId,
|
|
24
|
+
startedAt: input.startedAt,
|
|
25
|
+
outcome: null,
|
|
26
|
+
};
|
|
27
|
+
stateValue.nextVisitId += 1;
|
|
28
|
+
stateValue.activeVisit = visit;
|
|
29
|
+
return Object.freeze({ type: 'start', visit: snapshotVisit(visit) });
|
|
30
|
+
};
|
|
31
|
+
export const finishVisit = (state, visitId, outcome) => {
|
|
32
|
+
const visit = readState(state).activeVisit;
|
|
33
|
+
if (!visit || visit.visitId !== visitId || visit.outcome !== null) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
visit.outcome = outcome.type === 'complete' ? 'completed' : 'exited';
|
|
37
|
+
const visitSnapshot = snapshotVisit(visit);
|
|
38
|
+
if (outcome.type === 'exit') {
|
|
39
|
+
return Object.freeze({ type: 'exit', reason: outcome.reason, visit: visitSnapshot });
|
|
40
|
+
}
|
|
41
|
+
if (outcome.selected === undefined) {
|
|
42
|
+
return Object.freeze({ type: 'complete', visit: visitSnapshot });
|
|
43
|
+
}
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
type: 'complete',
|
|
46
|
+
selected: Object.freeze(Object.assign({}, outcome.selected)),
|
|
47
|
+
visit: visitSnapshot,
|
|
48
|
+
});
|
|
49
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FunnelManifestStep } from '../config/funnel.manifest.types.js';
|
|
2
|
+
import type { FunnelStepMeta } from '../steps/types.js';
|
|
3
|
+
import { type FunnelContractDiagnostic } from '../validation/funnel-contract-diagnostics.js';
|
|
4
|
+
export type FunnelStepMetadataParityOptions = {
|
|
5
|
+
file?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const validateFunnelStepMetadataParity: (manifestStep: FunnelManifestStep, componentMeta: FunnelStepMeta, options?: FunnelStepMetadataParityOptions) => FunnelContractDiagnostic[];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createFunnelContractDiagnostic, } from '../validation/funnel-contract-diagnostics.js';
|
|
2
|
+
const OMITTED_STEP_KIND = Symbol('omitted-step-kind');
|
|
3
|
+
const normalizeKind = (kind) => {
|
|
4
|
+
return kind === undefined ? OMITTED_STEP_KIND : kind;
|
|
5
|
+
};
|
|
6
|
+
const isChoiceRecord = (value) => {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
};
|
|
9
|
+
const normalizeChoice = (value) => {
|
|
10
|
+
if (!isChoiceRecord(value)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const keys = Object.keys(value);
|
|
14
|
+
if (!Object.prototype.hasOwnProperty.call(value, 'answerKey')
|
|
15
|
+
|| keys.some((key) => key !== 'answerKey' && key !== 'allowEmpty')
|
|
16
|
+
|| typeof value.answerKey !== 'string'
|
|
17
|
+
|| (value.allowEmpty !== undefined && typeof value.allowEmpty !== 'boolean')) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
answerKey: value.answerKey,
|
|
22
|
+
allowEmpty: value.allowEmpty,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
const choicesMatch = (expected, received) => {
|
|
26
|
+
if (expected === undefined || received === undefined) {
|
|
27
|
+
return expected === received;
|
|
28
|
+
}
|
|
29
|
+
const normalizedExpected = normalizeChoice(expected);
|
|
30
|
+
const normalizedReceived = normalizeChoice(received);
|
|
31
|
+
if (normalizedExpected === null || normalizedReceived === null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return (normalizedExpected.answerKey === normalizedReceived.answerKey
|
|
35
|
+
&& normalizedExpected.allowEmpty === normalizedReceived.allowEmpty);
|
|
36
|
+
};
|
|
37
|
+
export const validateFunnelStepMetadataParity = (manifestStep, componentMeta, options = {}) => {
|
|
38
|
+
var _a;
|
|
39
|
+
const diagnostics = [];
|
|
40
|
+
const comparisons = [
|
|
41
|
+
{
|
|
42
|
+
field: 'id',
|
|
43
|
+
expected: manifestStep.id,
|
|
44
|
+
received: componentMeta.id,
|
|
45
|
+
matches: manifestStep.id === componentMeta.id,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
field: 'name',
|
|
49
|
+
expected: manifestStep.name,
|
|
50
|
+
received: componentMeta.name,
|
|
51
|
+
matches: manifestStep.name === componentMeta.name,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
field: 'type',
|
|
55
|
+
expected: manifestStep.type,
|
|
56
|
+
received: componentMeta.type,
|
|
57
|
+
matches: manifestStep.type === componentMeta.type,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
field: 'kind',
|
|
61
|
+
expected: manifestStep.kind,
|
|
62
|
+
received: componentMeta.kind,
|
|
63
|
+
matches: normalizeKind(manifestStep.kind) === normalizeKind(componentMeta.kind),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
field: 'choice',
|
|
67
|
+
expected: manifestStep.choice,
|
|
68
|
+
received: componentMeta.choice,
|
|
69
|
+
matches: choicesMatch(manifestStep.choice, componentMeta.choice),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
for (const comparison of comparisons) {
|
|
73
|
+
if (comparison.matches) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
diagnostics.push(createFunnelContractDiagnostic({
|
|
77
|
+
code: 'FG-PARITY-001',
|
|
78
|
+
file: (_a = options.file) !== null && _a !== void 0 ? _a : manifestStep.filePath,
|
|
79
|
+
stepId: manifestStep.id,
|
|
80
|
+
expected: comparison.expected,
|
|
81
|
+
received: comparison.received,
|
|
82
|
+
reason: `Component metadata ${comparison.field} does not match the manifest step.`,
|
|
83
|
+
guide: 'docs/product-specs/funnel-template-step-authoring.md',
|
|
84
|
+
repair: `Update the component metadata ${comparison.field} to match the manifest step.`,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
return diagnostics;
|
|
88
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type EmailCapturePersistenceResult<User> = {
|
|
2
|
+
user: User;
|
|
3
|
+
eventId: string;
|
|
4
|
+
eventCreated: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type EmailCaptureSingleFlight = {
|
|
7
|
+
current: Promise<void> | null;
|
|
8
|
+
};
|
|
9
|
+
export type SubmitEmailCaptureInput<User> = {
|
|
10
|
+
email: string;
|
|
11
|
+
isPreview: boolean;
|
|
12
|
+
singleFlight: EmailCaptureSingleFlight;
|
|
13
|
+
retryPendingNavigation?: () => boolean | Promise<boolean>;
|
|
14
|
+
persist: (email: string) => Promise<EmailCapturePersistenceResult<User>>;
|
|
15
|
+
applyUser: (user: User) => void;
|
|
16
|
+
applyPreviewEmail: (email: string) => void;
|
|
17
|
+
fanOutBrowserDestinations: (eventId: string) => void | Promise<void>;
|
|
18
|
+
completeVisit: () => boolean;
|
|
19
|
+
resolveNextStep: () => string | null;
|
|
20
|
+
navigate: (stepId: string) => void;
|
|
21
|
+
};
|
|
22
|
+
export declare const createEmailCaptureSingleFlight: () => EmailCaptureSingleFlight;
|
|
23
|
+
export declare const submitEmailCapture: <User>(input: SubmitEmailCaptureInput<User>) => Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2
|
+
const MAX_EMAIL_LENGTH = 320;
|
|
3
|
+
const normalizeEmail = (value) => {
|
|
4
|
+
const email = value.trim().toLowerCase();
|
|
5
|
+
if (!email || email.length > MAX_EMAIL_LENGTH || !EMAIL_PATTERN.test(email)) {
|
|
6
|
+
throw new Error('Invalid email address');
|
|
7
|
+
}
|
|
8
|
+
return email;
|
|
9
|
+
};
|
|
10
|
+
export const createEmailCaptureSingleFlight = () => ({
|
|
11
|
+
current: null,
|
|
12
|
+
});
|
|
13
|
+
const runEmailCapture = async (input) => {
|
|
14
|
+
if (input.retryPendingNavigation && await input.retryPendingNavigation()) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const email = normalizeEmail(input.email);
|
|
18
|
+
if (input.isPreview) {
|
|
19
|
+
input.applyPreviewEmail(email);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const result = await input.persist(email);
|
|
23
|
+
input.applyUser(result.user);
|
|
24
|
+
if (result.eventCreated) {
|
|
25
|
+
try {
|
|
26
|
+
await input.fanOutBrowserDestinations(result.eventId);
|
|
27
|
+
}
|
|
28
|
+
catch (_a) {
|
|
29
|
+
// The server already committed the logical conversion. Browser delivery is best effort.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!input.completeVisit()) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const nextStepId = input.resolveNextStep();
|
|
37
|
+
if (nextStepId) {
|
|
38
|
+
input.navigate(nextStepId);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
export const submitEmailCapture = (input) => {
|
|
42
|
+
if (input.singleFlight.current) {
|
|
43
|
+
return input.singleFlight.current;
|
|
44
|
+
}
|
|
45
|
+
const operation = runEmailCapture(input);
|
|
46
|
+
input.singleFlight.current = operation;
|
|
47
|
+
const clearSingleFlight = () => {
|
|
48
|
+
if (input.singleFlight.current === operation) {
|
|
49
|
+
input.singleFlight.current = null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
void operation.then(clearSingleFlight, clearSingleFlight);
|
|
53
|
+
return operation;
|
|
54
|
+
};
|
|
@@ -2,7 +2,10 @@ import { type Dispatch, type SetStateAction } from 'react';
|
|
|
2
2
|
import type { FunnelContextValue } from '../components/FunnelContext.js';
|
|
3
3
|
import type { FunnelManifestExperiment } from '../config/funnel.manifest.types.js';
|
|
4
4
|
import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
|
|
5
|
-
import
|
|
5
|
+
import { apiService } from '../services/api.service.js';
|
|
6
|
+
import { type FunnelUserAttribution } from './funnel-attribution.js';
|
|
7
|
+
import type { FunnelStepMeta, FunnelStepType } from '../steps/types.js';
|
|
8
|
+
import { type FunnelStepLifecycleIntent } from './funnel-step-lifecycle.js';
|
|
6
9
|
export type FunnelFlowControllerExperiment<StepId extends string> = FunnelManifestExperiment & {
|
|
7
10
|
stepId: StepId;
|
|
8
11
|
variants: readonly (FunnelManifestExperiment['variants'][number] & {
|
|
@@ -11,13 +14,22 @@ export type FunnelFlowControllerExperiment<StepId extends string> = FunnelManife
|
|
|
11
14
|
};
|
|
12
15
|
type StepComponentRegistry = Record<string, unknown>;
|
|
13
16
|
type FunnelFlowAnalyticsAdapter = {
|
|
17
|
+
flush?: () => unknown | Promise<unknown>;
|
|
18
|
+
trackEmailCapturedBrowserDestinations?: (input: {
|
|
19
|
+
eventId: string;
|
|
20
|
+
stepId: string;
|
|
21
|
+
stepName: string;
|
|
22
|
+
stepType: FunnelStepType;
|
|
23
|
+
}) => void;
|
|
14
24
|
trackStepStarted?: (input: {
|
|
15
25
|
userId?: string;
|
|
16
26
|
environment?: 'test' | 'live';
|
|
17
27
|
stepId: string;
|
|
18
28
|
stepName: string;
|
|
19
|
-
stepType
|
|
29
|
+
stepType: FunnelStepType;
|
|
20
30
|
startedAt: string;
|
|
31
|
+
stepContractVersion: number;
|
|
32
|
+
metadata?: Record<string, unknown>;
|
|
21
33
|
featureFlags?: Record<string, string>;
|
|
22
34
|
}) => string | null;
|
|
23
35
|
trackStepCompleted?: (input: {
|
|
@@ -25,10 +37,27 @@ type FunnelFlowAnalyticsAdapter = {
|
|
|
25
37
|
environment?: 'test' | 'live';
|
|
26
38
|
stepId: string;
|
|
27
39
|
stepName: string;
|
|
28
|
-
stepType
|
|
40
|
+
stepType: FunnelStepType;
|
|
29
41
|
startedAt: string;
|
|
30
42
|
endedAt: string;
|
|
43
|
+
stepContractVersion: number;
|
|
31
44
|
selected?: Record<string, unknown>;
|
|
45
|
+
metadata?: Record<string, unknown>;
|
|
46
|
+
featureFlags?: Record<string, string>;
|
|
47
|
+
}) => string | null;
|
|
48
|
+
trackStepExited?: (input: {
|
|
49
|
+
userId?: string;
|
|
50
|
+
environment?: 'test' | 'live';
|
|
51
|
+
stepId: string;
|
|
52
|
+
stepName: string;
|
|
53
|
+
stepType: FunnelStepType;
|
|
54
|
+
startedAt: string;
|
|
55
|
+
endedAt: string;
|
|
56
|
+
exitReason: Extract<FunnelStepLifecycleIntent, {
|
|
57
|
+
type: 'exit';
|
|
58
|
+
}>['reason'];
|
|
59
|
+
stepContractVersion: number;
|
|
60
|
+
metadata?: Record<string, unknown>;
|
|
32
61
|
featureFlags?: Record<string, string>;
|
|
33
62
|
}) => string | null;
|
|
34
63
|
trackFunnelStarted?: (input: {
|
|
@@ -37,7 +66,20 @@ type FunnelFlowAnalyticsAdapter = {
|
|
|
37
66
|
stepId: string;
|
|
38
67
|
stepName: string;
|
|
39
68
|
stepType?: string;
|
|
69
|
+
stepContractVersion: number;
|
|
70
|
+
occurredAt: string;
|
|
71
|
+
metadata?: Record<string, unknown>;
|
|
72
|
+
featureFlags?: Record<string, string>;
|
|
73
|
+
}) => string | null;
|
|
74
|
+
trackFunnelCompleted?: (input: {
|
|
75
|
+
userId?: string;
|
|
76
|
+
environment?: 'test' | 'live';
|
|
77
|
+
stepId: string;
|
|
78
|
+
stepName: string;
|
|
79
|
+
stepType: FunnelStepType;
|
|
80
|
+
stepContractVersion: number;
|
|
40
81
|
occurredAt: string;
|
|
82
|
+
metadata?: Record<string, unknown>;
|
|
41
83
|
featureFlags?: Record<string, string>;
|
|
42
84
|
}) => string | null;
|
|
43
85
|
trackFirstStepViewed?: (input: {
|
|
@@ -45,13 +87,29 @@ type FunnelFlowAnalyticsAdapter = {
|
|
|
45
87
|
environment?: 'test' | 'live';
|
|
46
88
|
stepId: string;
|
|
47
89
|
stepName: string;
|
|
48
|
-
stepType
|
|
90
|
+
stepType: FunnelStepType;
|
|
91
|
+
stepContractVersion: number;
|
|
92
|
+
occurredAt: string;
|
|
93
|
+
metadata?: Record<string, unknown>;
|
|
94
|
+
featureFlags?: Record<string, string>;
|
|
95
|
+
}) => string | null;
|
|
96
|
+
trackFirstStepClicked?: (input: {
|
|
97
|
+
userId?: string;
|
|
98
|
+
environment?: 'test' | 'live';
|
|
99
|
+
stepId: string;
|
|
100
|
+
stepName: string;
|
|
101
|
+
stepType: FunnelStepType;
|
|
102
|
+
stepContractVersion: number;
|
|
49
103
|
occurredAt: string;
|
|
104
|
+
metadata?: Record<string, unknown>;
|
|
50
105
|
featureFlags?: Record<string, string>;
|
|
51
106
|
}) => string | null;
|
|
52
107
|
};
|
|
108
|
+
export type FunnelFlowApiAdapter = Pick<typeof apiService, 'getOrCreateClientUserId' | 'getBootstrapCandidateUserId' | 'bootstrapSession' | 'pingUserContext' | 'updateUser' | 'captureEmail'>;
|
|
53
109
|
type UseFunnelFlowControllerInput<StepId extends string> = {
|
|
110
|
+
api?: FunnelFlowApiAdapter;
|
|
54
111
|
analytics?: FunnelFlowAnalyticsAdapter;
|
|
112
|
+
stepContractVersion: number;
|
|
55
113
|
initialStepId?: StepId;
|
|
56
114
|
initialAttributes?: FunnelUserAnswers;
|
|
57
115
|
lockToInitialStep?: boolean;
|
|
@@ -99,8 +157,16 @@ type UseFunnelFlowControllerResult<StepId extends string> = {
|
|
|
99
157
|
setRuntimeMode: (mode: 'test' | 'live') => void;
|
|
100
158
|
showShellContinue: boolean;
|
|
101
159
|
};
|
|
160
|
+
export declare function resolveFunnelEventAttribution(document: unknown, provisionalAttribution: FunnelUserAttribution | null): FunnelUserAttribution | null;
|
|
161
|
+
export declare function buildReadyFunnelAttributionEventMetadata(attributionReady: boolean, attribution: FunnelUserAttribution | null): Record<string, string> | null;
|
|
162
|
+
export declare function scheduleAttributionFailOpen(onReady: () => void, timeoutMs: number): () => void;
|
|
163
|
+
export declare function takeInitialFunnelAttributionMetadata(tracked: boolean, attributionReady: boolean, attribution: FunnelUserAttribution | null): {
|
|
164
|
+
tracked: boolean;
|
|
165
|
+
metadata: Record<string, string> | null;
|
|
166
|
+
};
|
|
102
167
|
export declare function computeRenderSuspended(input: {
|
|
103
168
|
activeStepId: string;
|
|
169
|
+
attributionReady?: boolean;
|
|
104
170
|
postHogReady: boolean;
|
|
105
171
|
experiments: ReadonlyArray<{
|
|
106
172
|
stepId: string;
|
|
@@ -114,5 +180,5 @@ export declare function resolveRenderedExperimentStepId(input: {
|
|
|
114
180
|
resolveRenderableStepId: (stepId: string | null | undefined) => string | null;
|
|
115
181
|
safeActiveStepId: string;
|
|
116
182
|
}): string;
|
|
117
|
-
export declare function useFunnelFlowController<StepId extends string>({ analytics, initialStepId, initialAttributes, lockToInitialStep, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }: UseFunnelFlowControllerInput<StepId>): UseFunnelFlowControllerResult<StepId>;
|
|
183
|
+
export declare function useFunnelFlowController<StepId extends string>({ api, analytics, stepContractVersion, initialStepId, initialAttributes, lockToInitialStep, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }: UseFunnelFlowControllerInput<StepId>): UseFunnelFlowControllerResult<StepId>;
|
|
118
184
|
export {};
|