@funnelsgrove/runtime 0.1.0
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/README.md +42 -0
- package/dist/components/FunnelContext.d.ts +43 -0
- package/dist/components/FunnelContext.js +14 -0
- package/dist/components/FunnelEditorPanel.d.ts +22 -0
- package/dist/components/FunnelEditorPanel.js +116 -0
- package/dist/components/shared/PrimaryButton.d.ts +8 -0
- package/dist/components/shared/PrimaryButton.js +4 -0
- package/dist/config/builder-preview.protocol.d.ts +4 -0
- package/dist/config/builder-preview.protocol.js +4 -0
- package/dist/config/funnel-theme.d.ts +59 -0
- package/dist/config/funnel-theme.js +69 -0
- package/dist/config/funnel.manifest.types.d.ts +50 -0
- package/dist/config/funnel.manifest.types.js +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/runtime/browser-helpers.d.ts +9 -0
- package/dist/runtime/browser-helpers.js +57 -0
- package/dist/runtime/experiment-assignment.d.ts +4 -0
- package/dist/runtime/experiment-assignment.js +32 -0
- package/dist/runtime/funnel-flow.d.ts +23 -0
- package/dist/runtime/funnel-flow.js +66 -0
- package/dist/runtime/funnel-manifest.validation.d.ts +2 -0
- package/dist/runtime/funnel-manifest.validation.js +63 -0
- package/dist/runtime/funnel-runtime.d.ts +34 -0
- package/dist/runtime/funnel-runtime.js +75 -0
- package/dist/runtime/preview-bridge.d.ts +51 -0
- package/dist/runtime/preview-bridge.js +230 -0
- package/dist/runtime/route-resolver.d.ts +18 -0
- package/dist/runtime/route-resolver.js +91 -0
- package/dist/runtime/use-funnel-flow-controller.d.ts +58 -0
- package/dist/runtime/use-funnel-flow-controller.js +523 -0
- package/dist/sdk/userAnswers.d.ts +45 -0
- package/dist/sdk/userAnswers.js +10 -0
- package/dist/services/api.service.d.ts +81 -0
- package/dist/services/api.service.js +346 -0
- package/dist/services/logger.d.ts +8 -0
- package/dist/services/logger.js +16 -0
- package/dist/services/preview-frame.service.d.ts +4 -0
- package/dist/services/preview-frame.service.js +31 -0
- package/dist/services/runtime-api.config.d.ts +7 -0
- package/dist/services/runtime-api.config.js +40 -0
- package/dist/services/runtime-mode.service.d.ts +5 -0
- package/dist/services/runtime-mode.service.js +113 -0
- package/dist/steps/types.d.ts +22 -0
- package/dist/steps/types.js +17 -0
- package/package.json +39 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Dispatch, type SetStateAction, type ComponentType } from 'react';
|
|
2
|
+
import type { FunnelContextValue } from '../components/FunnelContext';
|
|
3
|
+
import type { FunnelManifestExperiment } from '../config/funnel.manifest.types';
|
|
4
|
+
import type { FunnelStepMeta } from '../steps/types';
|
|
5
|
+
export type FunnelFlowControllerExperiment<StepId extends string> = FunnelManifestExperiment & {
|
|
6
|
+
stepId: StepId;
|
|
7
|
+
variants: readonly (FunnelManifestExperiment['variants'][number] & {
|
|
8
|
+
routeToStepId: StepId;
|
|
9
|
+
})[];
|
|
10
|
+
};
|
|
11
|
+
type UseFunnelFlowControllerInput<StepId extends string> = {
|
|
12
|
+
initialStepId?: StepId;
|
|
13
|
+
lockToInitialStep?: boolean;
|
|
14
|
+
defaultStepId: StepId;
|
|
15
|
+
stepSequence: readonly StepId[];
|
|
16
|
+
stepById: Record<string, FunnelStepMeta>;
|
|
17
|
+
stepComponentById: Record<string, ComponentType>;
|
|
18
|
+
funnelExperiments: readonly FunnelFlowControllerExperiment<StepId>[];
|
|
19
|
+
getPathForStep: (stepId: StepId) => string;
|
|
20
|
+
getStepIdFromPath: (pathname: string) => StepId | null;
|
|
21
|
+
getSequentialNextStepId: (stepId: StepId) => StepId | null;
|
|
22
|
+
getChoiceTargetsForStep: (stepId: StepId) => {
|
|
23
|
+
yes?: StepId;
|
|
24
|
+
no?: StepId;
|
|
25
|
+
} | null;
|
|
26
|
+
isFunnelStepId: (value: string) => value is StepId;
|
|
27
|
+
resolveConfiguredNextStep: (stepId: StepId, context: {
|
|
28
|
+
attributes: Record<string, unknown>;
|
|
29
|
+
userId: string;
|
|
30
|
+
getAssignedVariantId: (experiment: FunnelManifestExperiment) => string | null;
|
|
31
|
+
}) => StepId | null;
|
|
32
|
+
resolveRuntimeInitialStepId: (input: {
|
|
33
|
+
requestedStepId?: string | null;
|
|
34
|
+
entryPointId?: string | null;
|
|
35
|
+
}) => StepId;
|
|
36
|
+
};
|
|
37
|
+
type UseFunnelFlowControllerResult<StepId extends string> = {
|
|
38
|
+
activeStepComponent: ComponentType | undefined;
|
|
39
|
+
activeStepId: StepId;
|
|
40
|
+
activeStepMeta: FunnelStepMeta | undefined;
|
|
41
|
+
buttonText: string;
|
|
42
|
+
buttonVariant: 'muted' | null;
|
|
43
|
+
contextValue: FunnelContextValue;
|
|
44
|
+
editorModeEnabled: boolean;
|
|
45
|
+
editorPanelExpanded: boolean;
|
|
46
|
+
experimentAssignmentForEditor: (experiment: FunnelFlowControllerExperiment<StepId>) => string;
|
|
47
|
+
funnelExperiments: readonly FunnelFlowControllerExperiment<StepId>[];
|
|
48
|
+
handleContinue: () => void;
|
|
49
|
+
renderedStepId: StepId;
|
|
50
|
+
renderedStepMeta: FunnelStepMeta | undefined;
|
|
51
|
+
runtimeMode: 'test' | 'live';
|
|
52
|
+
setEditorPanelExpanded: Dispatch<SetStateAction<boolean>>;
|
|
53
|
+
setEditorVariantSelection: (experiment: FunnelFlowControllerExperiment<StepId>, variantId: string) => void;
|
|
54
|
+
setRuntimeMode: (mode: 'test' | 'live') => void;
|
|
55
|
+
showShellContinue: boolean;
|
|
56
|
+
};
|
|
57
|
+
export declare function useFunnelFlowController<StepId extends string>({ initialStepId, lockToInitialStep, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }: UseFunnelFlowControllerInput<StepId>): UseFunnelFlowControllerResult<StepId>;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { writeStepChoice } from '../sdk/userAnswers';
|
|
4
|
+
import { apiService } from '../services/api.service';
|
|
5
|
+
import { logger } from '../services/logger';
|
|
6
|
+
import { buildHostedStepLocation, dispatchWindowCustomEvent, readWindowStorageValue, writeWindowStorageValue, } from './browser-helpers';
|
|
7
|
+
import { getAssignedVariantId, getRequestedEntryPointId, getRuntimeFunnelIdentity, isPreviewStepLockRequested, resolveNextStepFromContext, } from './funnel-flow';
|
|
8
|
+
import { usePreviewBridge } from './preview-bridge';
|
|
9
|
+
import { getExperimentAssignmentAttributeKey, getExperimentAssignmentStorageKey } from './experiment-assignment';
|
|
10
|
+
import { isEditorEnabled, useRuntimeMode } from '../services/runtime-mode.service';
|
|
11
|
+
import { isPreviewFrameRuntime } from '../services/preview-frame.service';
|
|
12
|
+
export function useFunnelFlowController({ initialStepId, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
|
|
13
|
+
var _a, _b;
|
|
14
|
+
const [runtimeMode, setRuntimeMode] = useRuntimeMode();
|
|
15
|
+
const [editorModeEnabled, setEditorModeEnabled] = useState(false);
|
|
16
|
+
const [editorPanelExpanded, setEditorPanelExpanded] = useState(false);
|
|
17
|
+
const shouldLockToInitialStep = lockToInitialStep || isPreviewStepLockRequested();
|
|
18
|
+
const isPreviewRuntime = useMemo(() => isPreviewFrameRuntime(), []);
|
|
19
|
+
const experimentAssignmentStorage = useMemo(() => ({
|
|
20
|
+
read: readWindowStorageValue,
|
|
21
|
+
write: writeWindowStorageValue,
|
|
22
|
+
}), []);
|
|
23
|
+
const resolveRenderableStepId = useCallback((stepId) => {
|
|
24
|
+
if (!stepId || !isFunnelStepId(stepId)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (!stepById[stepId] || !stepComponentById[stepId]) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return stepId;
|
|
31
|
+
}, [isFunnelStepId, stepById, stepComponentById]);
|
|
32
|
+
const resolveFallbackStepId = useCallback((preferredStepId) => {
|
|
33
|
+
const preferred = resolveRenderableStepId(preferredStepId);
|
|
34
|
+
if (preferred) {
|
|
35
|
+
return preferred;
|
|
36
|
+
}
|
|
37
|
+
for (const candidate of stepSequence) {
|
|
38
|
+
const resolved = resolveRenderableStepId(candidate);
|
|
39
|
+
if (resolved) {
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const fromRegistry = resolveRenderableStepId(Object.keys(stepComponentById)[0] || null);
|
|
44
|
+
return fromRegistry !== null && fromRegistry !== void 0 ? fromRegistry : defaultStepId;
|
|
45
|
+
}, [defaultStepId, resolveRenderableStepId, stepComponentById, stepSequence]);
|
|
46
|
+
const safeInitialStepId = useMemo(() => {
|
|
47
|
+
const requestedStepId = resolveRuntimeInitialStepId({
|
|
48
|
+
requestedStepId: initialStepId,
|
|
49
|
+
entryPointId: getRequestedEntryPointId(),
|
|
50
|
+
});
|
|
51
|
+
return resolveFallbackStepId(requestedStepId);
|
|
52
|
+
}, [initialStepId, resolveFallbackStepId, resolveRuntimeInitialStepId]);
|
|
53
|
+
const [activeStepId, setActiveStepId] = useState(safeInitialStepId);
|
|
54
|
+
const [attributes, setAttributes] = useState({});
|
|
55
|
+
const [user, setUser] = useState(() => ({
|
|
56
|
+
id: apiService.getOrCreateClientUserId(),
|
|
57
|
+
name: '',
|
|
58
|
+
email: '',
|
|
59
|
+
attributes: {},
|
|
60
|
+
completedSteps: [],
|
|
61
|
+
}));
|
|
62
|
+
const attributesAtStepStart = useRef({});
|
|
63
|
+
const attributesRef = useRef(attributes);
|
|
64
|
+
const prevStepIdRef = useRef(null);
|
|
65
|
+
const stepStartedAtByIdRef = useRef({});
|
|
66
|
+
const currentUserIdRef = useRef(user.id);
|
|
67
|
+
const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
|
|
68
|
+
const activeStepMeta = stepById[safeActiveStepId];
|
|
69
|
+
const renderedStepId = useMemo(() => {
|
|
70
|
+
const activeExperiment = funnelExperiments.find((experiment) => experiment.status === 'active' && experiment.stepId === safeActiveStepId);
|
|
71
|
+
if (!activeExperiment) {
|
|
72
|
+
return safeActiveStepId;
|
|
73
|
+
}
|
|
74
|
+
const assignedVariantId = getAssignedVariantId(activeExperiment, user.id, experimentAssignmentStorage);
|
|
75
|
+
if (!assignedVariantId) {
|
|
76
|
+
return safeActiveStepId;
|
|
77
|
+
}
|
|
78
|
+
const assignedVariant = activeExperiment.variants.find((variant) => variant.id === assignedVariantId);
|
|
79
|
+
const variantStepId = resolveRenderableStepId((assignedVariant === null || assignedVariant === void 0 ? void 0 : assignedVariant.routeToStepId) || null);
|
|
80
|
+
return variantStepId !== null && variantStepId !== void 0 ? variantStepId : safeActiveStepId;
|
|
81
|
+
}, [experimentAssignmentStorage, funnelExperiments, resolveRenderableStepId, safeActiveStepId, user.id]);
|
|
82
|
+
const renderedStepMeta = stepById[renderedStepId];
|
|
83
|
+
const activeStepComponent = stepComponentById[renderedStepId];
|
|
84
|
+
const goToStep = useCallback((stepId) => {
|
|
85
|
+
var _a;
|
|
86
|
+
if (shouldLockToInitialStep) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const safeStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : safeInitialStepId;
|
|
90
|
+
const stepPath = getPathForStep(safeStepId);
|
|
91
|
+
const nextLocation = buildHostedStepLocation({
|
|
92
|
+
currentHref: window.location.href,
|
|
93
|
+
stepPath,
|
|
94
|
+
stepId: safeStepId,
|
|
95
|
+
});
|
|
96
|
+
const currentLocation = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
97
|
+
if (currentLocation !== nextLocation) {
|
|
98
|
+
if (isPreviewFrameRuntime()) {
|
|
99
|
+
window.history.replaceState({ stepId: safeStepId }, '', nextLocation);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
window.history.pushState({ stepId: safeStepId }, '', nextLocation);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
setActiveStepId(safeStepId);
|
|
106
|
+
}, [getPathForStep, resolveRenderableStepId, safeInitialStepId, shouldLockToInitialStep]);
|
|
107
|
+
const setAnswer = useCallback((key, value) => {
|
|
108
|
+
setAttributes((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
|
|
109
|
+
}, []);
|
|
110
|
+
const getAnswer = useCallback((key) => {
|
|
111
|
+
return attributesRef.current[key];
|
|
112
|
+
}, []);
|
|
113
|
+
const setAttribute = useCallback((key, value) => {
|
|
114
|
+
setAttributes((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
|
|
115
|
+
}, []);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
currentUserIdRef.current = user.id;
|
|
118
|
+
}, [user.id]);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
attributesRef.current = attributes;
|
|
121
|
+
}, [attributes]);
|
|
122
|
+
const resolveNextStepId = useCallback((stepId) => {
|
|
123
|
+
const activeUserId = currentUserIdRef.current || user.id;
|
|
124
|
+
return resolveNextStepFromContext({
|
|
125
|
+
stepId,
|
|
126
|
+
attributes: attributesRef.current,
|
|
127
|
+
userId: activeUserId,
|
|
128
|
+
safeInitialStepId,
|
|
129
|
+
resolveRenderableStepId,
|
|
130
|
+
getConfiguredNextStepId: (currentStepId, context) => resolveConfiguredNextStep(currentStepId, context),
|
|
131
|
+
getSequentialNextStepId,
|
|
132
|
+
getAssignedVariantId: (experiment, currentUserId) => getAssignedVariantId(experiment, currentUserId, experimentAssignmentStorage),
|
|
133
|
+
});
|
|
134
|
+
}, [
|
|
135
|
+
experimentAssignmentStorage,
|
|
136
|
+
getSequentialNextStepId,
|
|
137
|
+
resolveConfiguredNextStep,
|
|
138
|
+
resolveRenderableStepId,
|
|
139
|
+
safeInitialStepId,
|
|
140
|
+
user.id,
|
|
141
|
+
]);
|
|
142
|
+
const goNext = useCallback(() => {
|
|
143
|
+
goToStep(resolveNextStepId(safeActiveStepId));
|
|
144
|
+
}, [goToStep, resolveNextStepId, safeActiveStepId]);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const localUserId = apiService.getOrCreateClientUserId();
|
|
147
|
+
setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
|
|
148
|
+
if (isPreviewRuntime) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
apiService
|
|
152
|
+
.bootstrapSession({
|
|
153
|
+
userId: localUserId,
|
|
154
|
+
})
|
|
155
|
+
.then((sessionUser) => {
|
|
156
|
+
const sessionAttributes = Object.keys(sessionUser.attributes || {}).length > 0
|
|
157
|
+
? sessionUser.attributes
|
|
158
|
+
: null;
|
|
159
|
+
if (sessionAttributes) {
|
|
160
|
+
setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
|
|
161
|
+
}
|
|
162
|
+
setUser((prev) => (Object.assign(Object.assign({}, prev), { id: sessionUser.id || prev.id, name: sessionUser.name || prev.name, email: sessionUser.email || prev.email, attributes: sessionAttributes ? Object.assign(Object.assign({}, sessionAttributes), prev.attributes) : prev.attributes })));
|
|
163
|
+
})
|
|
164
|
+
.catch((error) => {
|
|
165
|
+
logger.error('Failed to bootstrap user session:', error);
|
|
166
|
+
});
|
|
167
|
+
}, [isPreviewRuntime]);
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!currentUserIdRef.current) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const assignmentUpdates = {};
|
|
173
|
+
const assignmentEvents = [];
|
|
174
|
+
for (const experiment of funnelExperiments) {
|
|
175
|
+
if (experiment.status !== 'active') {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const assignedVariantId = getAssignedVariantId(experiment, currentUserIdRef.current, experimentAssignmentStorage);
|
|
179
|
+
if (!assignedVariantId) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const assignmentKey = getExperimentAssignmentAttributeKey(experiment.experimentId);
|
|
183
|
+
if (attributesRef.current[assignmentKey] === assignedVariantId) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
assignmentUpdates[assignmentKey] = assignedVariantId;
|
|
187
|
+
const assignedVariant = experiment.variants.find((variant) => variant.id === assignedVariantId);
|
|
188
|
+
assignmentEvents.push({
|
|
189
|
+
experiment,
|
|
190
|
+
variantId: assignedVariantId,
|
|
191
|
+
variantLabel: (assignedVariant === null || assignedVariant === void 0 ? void 0 : assignedVariant.label) || assignedVariantId,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const entries = Object.entries(assignmentUpdates);
|
|
195
|
+
if (entries.length === 0) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
setAttributes((prev) => {
|
|
199
|
+
let changed = false;
|
|
200
|
+
const next = Object.assign({}, prev);
|
|
201
|
+
for (const [key, value] of entries) {
|
|
202
|
+
if (next[key] !== value) {
|
|
203
|
+
next[key] = value;
|
|
204
|
+
changed = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return changed ? next : prev;
|
|
208
|
+
});
|
|
209
|
+
if (!isPreviewRuntime) {
|
|
210
|
+
assignmentEvents.forEach(({ experiment, variantId, variantLabel }) => {
|
|
211
|
+
const stepMeta = stepById[experiment.stepId];
|
|
212
|
+
apiService.trackFunnelEvent({
|
|
213
|
+
userId: currentUserIdRef.current,
|
|
214
|
+
eventType: 'experiment_variant_assigned',
|
|
215
|
+
stepId: experiment.stepId,
|
|
216
|
+
stepName: (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.name) || (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.title) || experiment.stepId,
|
|
217
|
+
selected: {
|
|
218
|
+
experimentId: experiment.experimentId,
|
|
219
|
+
variantId,
|
|
220
|
+
variantLabel,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}, [experimentAssignmentStorage, funnelExperiments, isPreviewRuntime, stepById]);
|
|
226
|
+
const recordStepCompletion = useCallback((stepId, explicitChoices) => {
|
|
227
|
+
const meta = stepById[stepId];
|
|
228
|
+
if (!meta) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
let choices;
|
|
232
|
+
if (explicitChoices) {
|
|
233
|
+
choices = explicitChoices;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const snapshot = attributesAtStepStart.current;
|
|
237
|
+
choices = {};
|
|
238
|
+
for (const [key, value] of Object.entries(attributesRef.current)) {
|
|
239
|
+
if (snapshot[key] !== value) {
|
|
240
|
+
choices[key] = value;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const record = {
|
|
245
|
+
stepId: meta.id,
|
|
246
|
+
stepName: meta.name || meta.title,
|
|
247
|
+
completedAt: new Date().toISOString(),
|
|
248
|
+
choices,
|
|
249
|
+
};
|
|
250
|
+
const startedAt = stepStartedAtByIdRef.current[meta.id] || record.completedAt;
|
|
251
|
+
dispatchWindowCustomEvent('funnel:step-completed', record);
|
|
252
|
+
if (!isPreviewRuntime) {
|
|
253
|
+
apiService.trackStepCompleted({
|
|
254
|
+
userId: currentUserIdRef.current,
|
|
255
|
+
stepId: record.stepId,
|
|
256
|
+
stepName: record.stepName,
|
|
257
|
+
startedAt,
|
|
258
|
+
endedAt: record.completedAt,
|
|
259
|
+
selected: record.choices,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
setUser((prev) => {
|
|
263
|
+
var _a;
|
|
264
|
+
const updatedSteps = [...((_a = prev.completedSteps) !== null && _a !== void 0 ? _a : []), record];
|
|
265
|
+
const updatedUser = Object.assign(Object.assign({}, prev), { attributes: Object.assign(Object.assign({}, prev.attributes), attributesRef.current), completedSteps: updatedSteps });
|
|
266
|
+
if (!isPreviewRuntime) {
|
|
267
|
+
apiService
|
|
268
|
+
.updateUser({
|
|
269
|
+
id: updatedUser.id,
|
|
270
|
+
name: updatedUser.name,
|
|
271
|
+
email: updatedUser.email,
|
|
272
|
+
attributes: Object.assign(Object.assign({}, updatedUser.attributes), { completedSteps: updatedSteps }),
|
|
273
|
+
})
|
|
274
|
+
.catch((error) => {
|
|
275
|
+
logger.error('Failed to persist step completion:', error);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return updatedUser;
|
|
279
|
+
});
|
|
280
|
+
}, [isPreviewRuntime, stepById]);
|
|
281
|
+
const completeStep = useCallback((stepId, choices) => {
|
|
282
|
+
recordStepCompletion(stepId, choices);
|
|
283
|
+
}, [recordStepCompletion]);
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (safeActiveStepId !== activeStepId) {
|
|
286
|
+
logger.warn(`[FunnelFlow] Unknown or non-renderable step "${activeStepId}", falling back to "${safeActiveStepId}".`);
|
|
287
|
+
setActiveStepId(safeActiveStepId);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const prevId = prevStepIdRef.current;
|
|
291
|
+
if (prevId && prevId !== safeActiveStepId) {
|
|
292
|
+
recordStepCompletion(prevId);
|
|
293
|
+
}
|
|
294
|
+
const startedAt = new Date().toISOString();
|
|
295
|
+
stepStartedAtByIdRef.current[safeActiveStepId] = startedAt;
|
|
296
|
+
if (!isPreviewRuntime) {
|
|
297
|
+
apiService.trackStepStarted({
|
|
298
|
+
userId: currentUserIdRef.current,
|
|
299
|
+
stepId: safeActiveStepId,
|
|
300
|
+
stepName: (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId,
|
|
301
|
+
startedAt,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const stepExperiments = funnelExperiments.filter((experiment) => experiment.status === 'active' && experiment.stepId === safeActiveStepId);
|
|
305
|
+
for (const experiment of stepExperiments) {
|
|
306
|
+
const assignedVariantId = getAssignedVariantId(experiment, currentUserIdRef.current, experimentAssignmentStorage);
|
|
307
|
+
if (!assignedVariantId) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const assignedVariant = experiment.variants.find((variant) => variant.id === assignedVariantId);
|
|
311
|
+
if (!isPreviewRuntime) {
|
|
312
|
+
apiService.trackFunnelEvent({
|
|
313
|
+
userId: currentUserIdRef.current,
|
|
314
|
+
eventType: 'experiment_step_exposed',
|
|
315
|
+
stepId: safeActiveStepId,
|
|
316
|
+
stepName: (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId,
|
|
317
|
+
startedAt,
|
|
318
|
+
selected: {
|
|
319
|
+
experimentId: experiment.experimentId,
|
|
320
|
+
variantId: assignedVariantId,
|
|
321
|
+
variantLabel: (assignedVariant === null || assignedVariant === void 0 ? void 0 : assignedVariant.label) || assignedVariantId,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
attributesAtStepStart.current = Object.assign({}, attributesRef.current);
|
|
327
|
+
prevStepIdRef.current = safeActiveStepId;
|
|
328
|
+
}, [
|
|
329
|
+
activeStepId,
|
|
330
|
+
activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name,
|
|
331
|
+
activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title,
|
|
332
|
+
experimentAssignmentStorage,
|
|
333
|
+
funnelExperiments,
|
|
334
|
+
isPreviewRuntime,
|
|
335
|
+
recordStepCompletion,
|
|
336
|
+
safeActiveStepId,
|
|
337
|
+
]);
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
setActiveStepId(safeInitialStepId);
|
|
340
|
+
}, [safeInitialStepId]);
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
setEditorModeEnabled(isEditorEnabled());
|
|
343
|
+
}, [safeActiveStepId]);
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!editorModeEnabled) {
|
|
346
|
+
setEditorPanelExpanded(false);
|
|
347
|
+
}
|
|
348
|
+
}, [editorModeEnabled]);
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
if (shouldLockToInitialStep) {
|
|
351
|
+
setActiveStepId(safeInitialStepId);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const syncStepWithPath = () => {
|
|
355
|
+
var _a;
|
|
356
|
+
const resolvedPathStep = getStepIdFromPath(window.location.pathname);
|
|
357
|
+
const resolvedStep = (_a = resolveRenderableStepId(resolvedPathStep)) !== null && _a !== void 0 ? _a : safeInitialStepId;
|
|
358
|
+
setActiveStepId(resolvedStep);
|
|
359
|
+
};
|
|
360
|
+
window.addEventListener('popstate', syncStepWithPath);
|
|
361
|
+
return () => window.removeEventListener('popstate', syncStepWithPath);
|
|
362
|
+
}, [getStepIdFromPath, resolveRenderableStepId, safeInitialStepId, shouldLockToInitialStep]);
|
|
363
|
+
const nextStepId = useMemo(() => resolveNextStepFromContext({
|
|
364
|
+
stepId: safeActiveStepId,
|
|
365
|
+
attributes,
|
|
366
|
+
userId: user.id,
|
|
367
|
+
safeInitialStepId,
|
|
368
|
+
resolveRenderableStepId,
|
|
369
|
+
getConfiguredNextStepId: (currentStepId, context) => resolveConfiguredNextStep(currentStepId, context),
|
|
370
|
+
getSequentialNextStepId,
|
|
371
|
+
getAssignedVariantId: (experiment, currentUserId) => getAssignedVariantId(experiment, currentUserId, experimentAssignmentStorage),
|
|
372
|
+
}), [
|
|
373
|
+
attributes,
|
|
374
|
+
experimentAssignmentStorage,
|
|
375
|
+
getSequentialNextStepId,
|
|
376
|
+
resolveConfiguredNextStep,
|
|
377
|
+
resolveRenderableStepId,
|
|
378
|
+
safeActiveStepId,
|
|
379
|
+
safeInitialStepId,
|
|
380
|
+
user.id,
|
|
381
|
+
]);
|
|
382
|
+
const choiceTargets = getChoiceTargetsForStep(safeActiveStepId);
|
|
383
|
+
const actionBar = (_a = renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.actionBar) !== null && _a !== void 0 ? _a : activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.actionBar;
|
|
384
|
+
const isAutoAdvanceStep = typeof (actionBar === null || actionBar === void 0 ? void 0 : actionBar.autoAdvanceMs) === 'number';
|
|
385
|
+
const autoAdvanceMs = actionBar === null || actionBar === void 0 ? void 0 : actionBar.autoAdvanceMs;
|
|
386
|
+
const buttonVariant = (_b = actionBar === null || actionBar === void 0 ? void 0 : actionBar.buttonVariant) !== null && _b !== void 0 ? _b : null;
|
|
387
|
+
const buttonText = (actionBar === null || actionBar === void 0 ? void 0 : actionBar.buttonText) || 'Continue';
|
|
388
|
+
const hideActionBar = (actionBar === null || actionBar === void 0 ? void 0 : actionBar.hidden) === true;
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (shouldLockToInitialStep) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!autoAdvanceMs) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const autoAdvanceTarget = resolveNextStepId(safeActiveStepId);
|
|
397
|
+
const timer = window.setTimeout(() => {
|
|
398
|
+
goToStep(autoAdvanceTarget);
|
|
399
|
+
}, autoAdvanceMs);
|
|
400
|
+
return () => window.clearTimeout(timer);
|
|
401
|
+
}, [autoAdvanceMs, goToStep, resolveNextStepId, safeActiveStepId, shouldLockToInitialStep]);
|
|
402
|
+
const handleContinue = useCallback(() => {
|
|
403
|
+
goToStep(nextStepId);
|
|
404
|
+
}, [goToStep, nextStepId]);
|
|
405
|
+
const goToStepFromContext = useCallback((stepId) => {
|
|
406
|
+
if (!isFunnelStepId(stepId)) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
goToStep(stepId);
|
|
410
|
+
}, [goToStep, isFunnelStepId]);
|
|
411
|
+
const goChoice = useCallback((choice, stepId) => {
|
|
412
|
+
var _a, _b;
|
|
413
|
+
const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : safeActiveStepId;
|
|
414
|
+
setAttributes((prev) => writeStepChoice(prev, sourceStepId, choice));
|
|
415
|
+
const sourceChoiceTargets = getChoiceTargetsForStep(sourceStepId);
|
|
416
|
+
const choiceTarget = (_b = resolveRenderableStepId(sourceChoiceTargets === null || sourceChoiceTargets === void 0 ? void 0 : sourceChoiceTargets[choice])) !== null && _b !== void 0 ? _b : resolveNextStepId(sourceStepId);
|
|
417
|
+
goToStep(choiceTarget);
|
|
418
|
+
}, [getChoiceTargetsForStep, goToStep, resolveNextStepId, resolveRenderableStepId, safeActiveStepId]);
|
|
419
|
+
usePreviewBridge({
|
|
420
|
+
activeStepId: safeActiveStepId,
|
|
421
|
+
onGoToStep: goToStepFromContext,
|
|
422
|
+
resolveRenderableStepId,
|
|
423
|
+
setRuntimeMode,
|
|
424
|
+
shouldLockToInitialStep,
|
|
425
|
+
});
|
|
426
|
+
const setEditorVariantSelection = useCallback((experiment, variantId) => {
|
|
427
|
+
const nextVariantId = variantId.trim();
|
|
428
|
+
if (!nextVariantId) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const selectedVariant = experiment.variants.find((variant) => variant.id === nextVariantId);
|
|
432
|
+
if (!selectedVariant) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const userId = currentUserIdRef.current || user.id;
|
|
436
|
+
if (!userId) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const storageKey = getExperimentAssignmentStorageKey(getRuntimeFunnelIdentity(), userId, experiment.experimentId);
|
|
441
|
+
writeWindowStorageValue(storageKey, selectedVariant.id);
|
|
442
|
+
}
|
|
443
|
+
catch (_a) {
|
|
444
|
+
// ignore storage access failures
|
|
445
|
+
}
|
|
446
|
+
const assignmentKey = getExperimentAssignmentAttributeKey(experiment.experimentId);
|
|
447
|
+
setAttributes((prev) => prev[assignmentKey] === selectedVariant.id
|
|
448
|
+
? prev
|
|
449
|
+
: Object.assign(Object.assign({}, prev), { [assignmentKey]: selectedVariant.id }));
|
|
450
|
+
if (!isPreviewRuntime) {
|
|
451
|
+
const stepMeta = stepById[experiment.stepId];
|
|
452
|
+
apiService.trackFunnelEvent({
|
|
453
|
+
userId,
|
|
454
|
+
eventType: 'experiment_variant_selected_editor',
|
|
455
|
+
stepId: experiment.stepId,
|
|
456
|
+
stepName: (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.name) || (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.title) || experiment.stepId,
|
|
457
|
+
selected: {
|
|
458
|
+
experimentId: experiment.experimentId,
|
|
459
|
+
variantId: selectedVariant.id,
|
|
460
|
+
variantLabel: selectedVariant.label,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}, [isPreviewRuntime, stepById, user.id]);
|
|
465
|
+
const experimentAssignmentForEditor = useCallback((experiment) => {
|
|
466
|
+
var _a;
|
|
467
|
+
return (getAssignedVariantId(experiment, user.id, experimentAssignmentStorage) ||
|
|
468
|
+
((_a = experiment.variants[0]) === null || _a === void 0 ? void 0 : _a.id) ||
|
|
469
|
+
'');
|
|
470
|
+
}, [experimentAssignmentStorage, user.id]);
|
|
471
|
+
const contextValue = useMemo(() => ({
|
|
472
|
+
activeStepId: safeActiveStepId,
|
|
473
|
+
goToStep: goToStepFromContext,
|
|
474
|
+
goNext,
|
|
475
|
+
goChoice,
|
|
476
|
+
getChoiceTargets: (stepId) => {
|
|
477
|
+
var _a;
|
|
478
|
+
const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : safeActiveStepId;
|
|
479
|
+
return getChoiceTargetsForStep(sourceStepId);
|
|
480
|
+
},
|
|
481
|
+
setAnswer,
|
|
482
|
+
getAnswer,
|
|
483
|
+
answers: attributes,
|
|
484
|
+
setAttribute,
|
|
485
|
+
attributes,
|
|
486
|
+
user,
|
|
487
|
+
setUser,
|
|
488
|
+
completeStep,
|
|
489
|
+
}), [
|
|
490
|
+
attributes,
|
|
491
|
+
completeStep,
|
|
492
|
+
getAnswer,
|
|
493
|
+
getChoiceTargetsForStep,
|
|
494
|
+
goChoice,
|
|
495
|
+
goNext,
|
|
496
|
+
goToStepFromContext,
|
|
497
|
+
resolveRenderableStepId,
|
|
498
|
+
safeActiveStepId,
|
|
499
|
+
setAnswer,
|
|
500
|
+
setAttribute,
|
|
501
|
+
user,
|
|
502
|
+
]);
|
|
503
|
+
return {
|
|
504
|
+
activeStepComponent,
|
|
505
|
+
activeStepId: safeActiveStepId,
|
|
506
|
+
activeStepMeta,
|
|
507
|
+
buttonText,
|
|
508
|
+
buttonVariant,
|
|
509
|
+
contextValue,
|
|
510
|
+
editorModeEnabled,
|
|
511
|
+
editorPanelExpanded,
|
|
512
|
+
experimentAssignmentForEditor,
|
|
513
|
+
funnelExperiments,
|
|
514
|
+
handleContinue,
|
|
515
|
+
renderedStepId,
|
|
516
|
+
renderedStepMeta,
|
|
517
|
+
runtimeMode,
|
|
518
|
+
setEditorPanelExpanded,
|
|
519
|
+
setEditorVariantSelection,
|
|
520
|
+
setRuntimeMode,
|
|
521
|
+
showShellContinue: !hideActionBar && !isAutoAdvanceStep && !choiceTargets,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FunnelStepId } from '../runtime/funnel-runtime';
|
|
2
|
+
export type FunnelBinaryChoice = 'yes' | 'no';
|
|
3
|
+
export type FunnelBirthDate = {
|
|
4
|
+
year: number;
|
|
5
|
+
month: number;
|
|
6
|
+
day: number;
|
|
7
|
+
};
|
|
8
|
+
export type FunnelTempUpload = {
|
|
9
|
+
key: string;
|
|
10
|
+
publicUrl: string;
|
|
11
|
+
contentType: string;
|
|
12
|
+
sizeBytes: number;
|
|
13
|
+
uploadedAt: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Typed SDK-facing answers object shared across all steps.
|
|
17
|
+
* Keep this shape in sync with SDK_USER_OBJECT.md so future funnels can extend it safely.
|
|
18
|
+
*/
|
|
19
|
+
export type FunnelUserAnswers = Record<string, unknown> & {
|
|
20
|
+
/** Generic branching answers by step id for yes/no splits. */
|
|
21
|
+
stepChoices?: Partial<Record<FunnelStepId, FunnelBinaryChoice>>;
|
|
22
|
+
/** Core onboarding answers used by template steps. */
|
|
23
|
+
selectedDevices?: Array<'iphone' | 'laptop' | 'watch' | 'none'>;
|
|
24
|
+
/** Upsell answers. */
|
|
25
|
+
upsellAccepted?: boolean;
|
|
26
|
+
upsellEmail?: string;
|
|
27
|
+
upsellSelectedPlanId?: string;
|
|
28
|
+
selectedPaywallPlanId?: string;
|
|
29
|
+
/** Manage subscription answers. */
|
|
30
|
+
manageSubscriptionHasActive?: boolean;
|
|
31
|
+
manageSubscriptionReason?: string;
|
|
32
|
+
manageSubscriptionCancelled?: boolean;
|
|
33
|
+
manageSubscriptionCancelledId?: string;
|
|
34
|
+
/** Optional location and astro metadata shared by astrology-style funnels. */
|
|
35
|
+
birthDate?: FunnelBirthDate;
|
|
36
|
+
birthCity?: string;
|
|
37
|
+
sunSign?: string;
|
|
38
|
+
moonSign?: string;
|
|
39
|
+
ascendantSign?: string;
|
|
40
|
+
/** Optional media metadata for steps that capture and upload user images. */
|
|
41
|
+
palmPhotoUpload?: FunnelTempUpload;
|
|
42
|
+
palmPhotoUrl?: string;
|
|
43
|
+
};
|
|
44
|
+
export declare const readStepChoice: (answers: FunnelUserAnswers, stepId: FunnelStepId) => FunnelBinaryChoice | undefined;
|
|
45
|
+
export declare const writeStepChoice: (answers: FunnelUserAnswers, stepId: FunnelStepId, choice: FunnelBinaryChoice) => FunnelUserAnswers;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const readStepChoice = (answers, stepId) => {
|
|
2
|
+
var _a;
|
|
3
|
+
const choice = (_a = answers.stepChoices) === null || _a === void 0 ? void 0 : _a[stepId];
|
|
4
|
+
return choice === 'yes' || choice === 'no' ? choice : undefined;
|
|
5
|
+
};
|
|
6
|
+
export const writeStepChoice = (answers, stepId, choice) => {
|
|
7
|
+
return Object.assign(Object.assign({}, answers), {
|
|
8
|
+
// Keep legacy flat key for backward-compatible routing checks.
|
|
9
|
+
[stepId]: choice, [`${stepId}:yes`]: choice === 'yes', [`${stepId}:no`]: choice === 'no', stepChoices: Object.assign(Object.assign({}, (answers.stepChoices || {})), { [stepId]: choice }) });
|
|
10
|
+
};
|