@umituz/web-dashboard 6.0.0 → 6.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.
Files changed (87) hide show
  1. package/package.json +19 -7
  2. package/src/domains/onboarding/calc/calc.test.ts +181 -0
  3. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +33 -0
  4. package/src/domains/onboarding/calc/count-with-locale.ts +16 -0
  5. package/src/domains/onboarding/calc/duration-equal-split.ts +24 -0
  6. package/src/domains/onboarding/calc/elapsed-millis.ts +12 -0
  7. package/src/domains/onboarding/calc/index.ts +36 -0
  8. package/src/domains/onboarding/calc/progress-percentage.ts +37 -0
  9. package/src/domains/onboarding/calc/slider-value-bounds.ts +44 -0
  10. package/src/domains/onboarding/calc/step-index-clamp.ts +28 -0
  11. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +67 -0
  12. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +481 -0
  13. package/src/domains/onboarding/components/OnboardingFlow.tsx +361 -0
  14. package/src/domains/onboarding/components/OnboardingIntl.tsx +108 -0
  15. package/src/domains/onboarding/components/hooks/index.ts +7 -0
  16. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.test.tsx +74 -0
  17. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.ts +62 -0
  18. package/src/domains/onboarding/components/hooks/useOnboardingProgress.test.tsx +150 -0
  19. package/src/domains/onboarding/components/hooks/useOnboardingProgress.ts +260 -0
  20. package/src/domains/onboarding/components/hooks/usePermissionPriming.ts +52 -0
  21. package/src/domains/onboarding/components/hooks/useReducedMotion.ts +24 -0
  22. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +89 -0
  23. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +39 -0
  24. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +59 -0
  25. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +56 -0
  26. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  27. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +138 -0
  28. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +113 -0
  29. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +53 -0
  30. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +194 -0
  31. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +79 -0
  32. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +116 -0
  33. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +108 -0
  34. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +85 -0
  35. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +52 -0
  36. package/src/domains/onboarding/components/screens/index.ts +28 -0
  37. package/src/domains/onboarding/components/shared/AnimationWrapper.tsx +73 -0
  38. package/src/domains/onboarding/components/shared/OnboardingErrorBoundary.tsx +81 -0
  39. package/src/domains/onboarding/components/shared/OptionCard.tsx +73 -0
  40. package/src/domains/onboarding/components/shared/PrimaryButton.tsx +58 -0
  41. package/src/domains/onboarding/components/shared/ProgressBar.tsx +51 -0
  42. package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +36 -0
  43. package/src/domains/onboarding/components/shared/ScreenContainer.tsx +29 -0
  44. package/src/domains/onboarding/components/shared/SwipeCard.test.tsx +51 -0
  45. package/src/domains/onboarding/components/shared/SwipeCard.tsx +106 -0
  46. package/src/domains/onboarding/components/shared/index.ts +16 -0
  47. package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +20 -0
  48. package/src/domains/onboarding/index.ts +182 -0
  49. package/src/domains/onboarding/onboarding.css +1 -0
  50. package/src/domains/onboarding/predicates/has-handler.ts +25 -0
  51. package/src/domains/onboarding/predicates/icon-name-resolves.ts +113 -0
  52. package/src/domains/onboarding/predicates/index.ts +26 -0
  53. package/src/domains/onboarding/predicates/permission-granted.ts +26 -0
  54. package/src/domains/onboarding/predicates/permission-supports.ts +66 -0
  55. package/src/domains/onboarding/predicates/predicates.test.ts +202 -0
  56. package/src/domains/onboarding/predicates/screen-emits-data.ts +17 -0
  57. package/src/domains/onboarding/predicates/storage-available.ts +22 -0
  58. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +112 -0
  59. package/src/domains/onboarding/templates/art-onboarding.ts +81 -0
  60. package/src/domains/onboarding/templates/health-onboarding.ts +97 -0
  61. package/src/domains/onboarding/templates/index.ts +6 -0
  62. package/src/domains/onboarding/templates/platform-onboarding.ts +210 -0
  63. package/src/domains/onboarding/templates/video-onboarding.ts +100 -0
  64. package/src/domains/onboarding/test/setup.ts +30 -0
  65. package/src/domains/onboarding/theme/cn.ts +10 -0
  66. package/src/domains/onboarding/theme/index.css +131 -0
  67. package/src/domains/onboarding/theme/index.ts +12 -0
  68. package/src/domains/onboarding/theme/tokens.ts +122 -0
  69. package/src/domains/onboarding/types/config.ts +68 -0
  70. package/src/domains/onboarding/types/index.ts +10 -0
  71. package/src/domains/onboarding/types/labels.ts +83 -0
  72. package/src/domains/onboarding/types/screens.ts +193 -0
  73. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  74. package/src/domains/onboarding/utils/analytics-dispatch.ts +92 -0
  75. package/src/domains/onboarding/utils/default-storage-key.ts +2 -0
  76. package/src/domains/onboarding/utils/index.ts +13 -0
  77. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  78. package/src/domains/onboarding/utils/local-storage-bridge.ts +104 -0
  79. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  80. package/src/domains/onboarding/utils/permission-bridge.ts +95 -0
  81. package/src/domains/onboarding/validation/flow-structure-check.ts +28 -0
  82. package/src/domains/onboarding/validation/index.ts +9 -0
  83. package/src/domains/onboarding/validation/permission-status-check.ts +23 -0
  84. package/src/domains/onboarding/validation/screen-config-check.ts +105 -0
  85. package/src/domains/onboarding/validation/stored-progress-check.ts +68 -0
  86. package/src/domains/onboarding/validation/validation.test.ts +175 -0
  87. package/src/styles/dashboard.contract.test.ts +1 -0
@@ -0,0 +1,361 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import type { OnboardingScreen } from '../types/screens';
3
+ import type { OnboardingConfig } from '../types/config';
4
+ import { useOnboardingProgress } from './hooks/useOnboardingProgress';
5
+ import { useOnboardingAnalytics } from './hooks/useOnboardingAnalytics';
6
+ import { OnboardingIntlProvider } from './OnboardingIntl';
7
+ import { ProgressBar } from './shared/ProgressBar';
8
+ import { ScreenContainer } from './shared/ScreenContainer';
9
+ import { AnimationWrapper, type ScreenTransition } from './shared/AnimationWrapper';
10
+ import { OnboardingErrorBoundary } from './shared/OnboardingErrorBoundary';
11
+ import { useOnboardingIntl } from './OnboardingIntl';
12
+ import { checkFlowStructure } from '../validation/flow-structure-check';
13
+ import {
14
+ WelcomeScreen,
15
+ GoalQuestionScreen,
16
+ PainPointsScreen,
17
+ SocialProofScreen,
18
+ TinderCardsScreen,
19
+ PersonalizedSolutionScreen,
20
+ ComparisonScreen,
21
+ PreferencesScreen,
22
+ PermissionPrimingScreen,
23
+ ProcessingScreen,
24
+ AppDemoScreen,
25
+ ValueDeliveryScreen,
26
+ AccountGateScreen,
27
+ PaywallScreen,
28
+ } from './screens';
29
+
30
+ export interface OnboardingFlowProps {
31
+ config: OnboardingConfig;
32
+ /** Test/Storybook hook: skip persistence. */
33
+ ephemeral?: boolean;
34
+ /** Show progress bar — defaults to true. */
35
+ showProgress?: boolean;
36
+ /** Show back button on non-first screens — defaults to true. */
37
+ allowBack?: boolean;
38
+ /** Show skip button — defaults to true. Calls config.onSkip on click. */
39
+ allowSkip?: boolean;
40
+ /** Custom label for the skip button (overrides `labels.skip`). */
41
+ skipLabel?: string;
42
+ /** Transition style between screens — defaults to 'slide'. */
43
+ transition?: ScreenTransition;
44
+ /** Override the default error UI from inside the boundary. */
45
+ renderError?: (error: Error, retry: () => void) => React.ReactNode;
46
+ }
47
+
48
+ /**
49
+ * Factory + dispatch table: maps a screen type to its component.
50
+ * Each screen is responsible for emitting its own data shape via onNext.
51
+ */
52
+ type ProgressReturn = ReturnType<typeof useOnboardingProgress>;
53
+
54
+ function renderScreen(
55
+ screen: OnboardingScreen,
56
+ progress: ProgressReturn,
57
+ config: OnboardingConfig,
58
+ onStepCompleted: (data: unknown) => void,
59
+ ) {
60
+ switch (screen.type) {
61
+ case 'welcome':
62
+ return <WelcomeScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
63
+ case 'goal-question':
64
+ return (
65
+ <GoalQuestionScreen
66
+ config={screen}
67
+ onNext={(data) => onStepCompleted(data)}
68
+ initialSelected={
69
+ (progress.collected[screen.id] as { selected?: string[] } | undefined)?.selected
70
+ }
71
+ />
72
+ );
73
+ case 'pain-points':
74
+ return (
75
+ <PainPointsScreen
76
+ config={screen}
77
+ onNext={(data) => onStepCompleted(data)}
78
+ initialSelected={
79
+ (progress.collected[screen.id] as { selected?: string[] } | undefined)?.selected
80
+ }
81
+ />
82
+ );
83
+ case 'social-proof':
84
+ return <SocialProofScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
85
+ case 'tinder-cards':
86
+ return <TinderCardsScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
87
+ case 'personalized-solution': {
88
+ // Deterministic: personalization is driven by the pain-points screen's
89
+ // collected selections — never by "whichever screen happens to have a
90
+ // `selected` field".
91
+ const painScreen = config.screens.find((s) => s.type === 'pain-points');
92
+ const painIds = painScreen
93
+ ? ((progress.collected[painScreen.id] as { selected?: string[] } | undefined)
94
+ ?.selected ?? [])
95
+ : [];
96
+ return (
97
+ <PersonalizedSolutionScreen
98
+ config={{ ...screen, selectedPainPointIds: painIds }}
99
+ onNext={() => onStepCompleted(undefined)}
100
+ />
101
+ );
102
+ }
103
+ case 'comparison':
104
+ return <ComparisonScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
105
+ case 'preferences':
106
+ return <PreferencesScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
107
+ case 'permission-priming':
108
+ return (
109
+ <PermissionPrimingScreen
110
+ config={screen}
111
+ onNext={(data) => onStepCompleted({ status: data.status })}
112
+ analytics={config.analytics}
113
+ />
114
+ );
115
+ case 'processing':
116
+ return <ProcessingScreen config={screen} onComplete={(data) => onStepCompleted(data)} />;
117
+ case 'app-demo':
118
+ return <AppDemoScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
119
+ case 'value-delivery':
120
+ return <ValueDeliveryScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
121
+ case 'account-gate':
122
+ return <AccountGateScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
123
+ case 'paywall':
124
+ return (
125
+ <PaywallScreen
126
+ config={screen}
127
+ onNext={(data) => onStepCompleted(data)}
128
+ analytics={config.analytics}
129
+ />
130
+ );
131
+ default: {
132
+ // Exhaustive check — TypeScript will flag missing cases at compile time.
133
+ const _exhaustive: never = screen;
134
+ return _exhaustive;
135
+ }
136
+ }
137
+ }
138
+
139
+ /** Back/skip affordances. Rendered inside the intl provider so label overrides apply. */
140
+ function FlowFooterNavigation({
141
+ allowBack,
142
+ allowSkip,
143
+ skipLabel,
144
+ isFirst,
145
+ onBack,
146
+ onSkip,
147
+ visible,
148
+ }: {
149
+ allowBack: boolean;
150
+ allowSkip: boolean;
151
+ skipLabel?: string;
152
+ isFirst: boolean;
153
+ onBack: () => void;
154
+ onSkip: () => void;
155
+ visible: boolean;
156
+ }) {
157
+ const { labels } = useOnboardingIntl();
158
+ if (!visible) return null;
159
+ return (
160
+ <div className="flex items-center justify-between">
161
+ {allowBack && !isFirst ? (
162
+ <button
163
+ type="button"
164
+ onClick={onBack}
165
+ className="text-sm font-semibold text-[rgb(var(--uob-text-muted))] underline-offset-4 hover:underline"
166
+ >
167
+ {labels.back}
168
+ </button>
169
+ ) : (
170
+ <span />
171
+ )}
172
+ {allowSkip ? (
173
+ <button
174
+ type="button"
175
+ onClick={onSkip}
176
+ className="text-sm font-semibold text-[rgb(var(--uob-text-muted))] underline-offset-4 hover:underline"
177
+ >
178
+ {skipLabel ?? labels.skip}
179
+ </button>
180
+ ) : (
181
+ <span />
182
+ )}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ /**
188
+ * OnboardingFlow — the orchestrator.
189
+ * Responsibilities (SRP):
190
+ * 1. Validate the configured flow at mount (fail fast on bad config).
191
+ * 2. Own the progress state machine via useOnboardingProgress.
192
+ * 3. Dispatch the active screen via the factory above.
193
+ * 4. Emit lifecycle analytics (started / step / completed / abandoned — each exactly once).
194
+ * 5. Provide skip/back affordances and progress.
195
+ * 6. Wrap everything in an Error Boundary so a single broken screen never breaks the flow.
196
+ *
197
+ * Does NOT own:
198
+ * - Styling tokens (theme/cn)
199
+ * - Analytics provider internals (useOnboardingAnalytics)
200
+ * - Storage (useOnboardingProgress)
201
+ * - Screen-specific UI (rendered components)
202
+ */
203
+ export function OnboardingFlow({
204
+ config,
205
+ ephemeral = false,
206
+ showProgress = true,
207
+ allowBack = true,
208
+ allowSkip = true,
209
+ skipLabel,
210
+ transition = 'slide',
211
+ renderError,
212
+ }: OnboardingFlowProps) {
213
+ const progress = useOnboardingProgress({
214
+ screens: config.screens,
215
+ storageKey: config.storageKey,
216
+ ephemeral,
217
+ });
218
+ const analytics = useOnboardingAnalytics(config.analytics);
219
+
220
+ // Validate configuration once at mount. Surface errors rather than render broken UI.
221
+ const validation = useMemo(() => checkFlowStructure(config.screens), [config.screens]);
222
+ useEffect(() => {
223
+ if (!validation.ok) {
224
+ throw new Error(`[web-onboarding] invalid flow: ${validation.reason}`);
225
+ }
226
+ }, [validation]);
227
+
228
+ // Lifecycle analytics — started fires exactly once, even if the consumer
229
+ // recreates the config object on every render.
230
+ const startedRef = useRef(false);
231
+ useEffect(() => {
232
+ if (startedRef.current) return;
233
+ startedRef.current = true;
234
+ analytics.trackEvent({
235
+ name: 'onboarding_started',
236
+ category: config.category,
237
+ timestamp: Date.now(),
238
+ });
239
+ }, [analytics, config.category]);
240
+
241
+ // Completion — fires exactly once per finished run.
242
+ const completedRef = useRef(false);
243
+ useEffect(() => {
244
+ if (!progress.isFinished || !progress.result) return;
245
+ if (completedRef.current) return;
246
+ completedRef.current = true;
247
+ analytics.trackEvent({
248
+ name: 'onboarding_completed',
249
+ durationMs: progress.result.durationMs,
250
+ timestamp: Date.now(),
251
+ });
252
+ config.onComplete?.(progress.result);
253
+ }, [progress.isFinished, progress.result, analytics, config]);
254
+
255
+ // Abandonment — when the flow unmounts without finishing AND the user had
256
+ // engaged (advanced at least one step), report where they dropped off.
257
+ // React StrictMode dev double-mounts happen before engagement, so they do not
258
+ // produce spurious events.
259
+ const abandonSnapshotRef = useRef({ engaged: false, finished: false, currentStep: 0, startedAt: new Date() });
260
+ useEffect(() => {
261
+ abandonSnapshotRef.current = {
262
+ engaged: progress.completedSteps.length > 0,
263
+ finished: progress.isFinished,
264
+ currentStep: progress.currentStep,
265
+ startedAt: progress.startedAt,
266
+ };
267
+ }, [progress.completedSteps.length, progress.isFinished, progress.currentStep, progress.startedAt]);
268
+ useEffect(() => {
269
+ return () => {
270
+ const snapshot = abandonSnapshotRef.current;
271
+ if (snapshot.finished || !snapshot.engaged) return;
272
+ analytics.trackEvent({
273
+ name: 'onboarding_abandoned',
274
+ lastStepId: config.screens[Math.min(snapshot.currentStep, config.screens.length - 1)]?.id ?? '',
275
+ durationMs: Math.max(0, Date.now() - snapshot.startedAt.getTime()),
276
+ timestamp: Date.now(),
277
+ });
278
+ };
279
+ // Config/analytics are read via closure on unmount; the effect intentionally
280
+ // installs once for the lifetime of the flow.
281
+ }, []);
282
+
283
+ const handleStepCompleted = useCallback(
284
+ (data: unknown) => {
285
+ const current = config.screens[progress.currentStep];
286
+ if (!current) return;
287
+ analytics.trackEvent({
288
+ name: 'onboarding_step_completed',
289
+ stepId: current.id,
290
+ stepType: current.type,
291
+ index: progress.currentStep,
292
+ timestamp: Date.now(),
293
+ });
294
+ progress.next(data);
295
+ },
296
+ [analytics, config.screens, progress],
297
+ );
298
+
299
+ const handleBack = useCallback(() => {
300
+ if (progress.isFirst) return;
301
+ progress.prev();
302
+ }, [progress]);
303
+
304
+ const handleSkip = useCallback(() => {
305
+ const current = config.screens[progress.currentStep];
306
+ if (!current) return;
307
+ analytics.trackEvent({
308
+ name: 'onboarding_step_skipped',
309
+ stepId: current.id,
310
+ index: progress.currentStep,
311
+ timestamp: Date.now(),
312
+ });
313
+ config.onSkip?.();
314
+ }, [analytics, config, progress.currentStep]);
315
+
316
+ const active = config.screens[progress.currentStep];
317
+ if (!active) {
318
+ return null;
319
+ }
320
+
321
+ return (
322
+ <OnboardingIntlProvider labels={config.labels} locale={config.locale}>
323
+ <OnboardingErrorBoundary {...(renderError ? { renderError } : {})}>
324
+ <div className="uob-flow">
325
+ <ScreenContainer
326
+ header={
327
+ <>
328
+ {config.headerSlot}
329
+ {showProgress && (
330
+ <ProgressBar
331
+ current={progress.currentStep + 1}
332
+ total={progress.totalSteps}
333
+ showLabel
334
+ />
335
+ )}
336
+ </>
337
+ }
338
+ footer={
339
+ <>
340
+ <FlowFooterNavigation
341
+ allowBack={allowBack}
342
+ allowSkip={allowSkip && Boolean(config.onSkip)}
343
+ skipLabel={skipLabel}
344
+ isFirst={progress.isFirst}
345
+ onBack={handleBack}
346
+ onSkip={handleSkip}
347
+ visible={allowBack || allowSkip}
348
+ />
349
+ {config.footerSlot}
350
+ </>
351
+ }
352
+ >
353
+ <AnimationWrapper stepKey={active.id} transition={transition}>
354
+ {renderScreen(active, progress, config, handleStepCompleted)}
355
+ </AnimationWrapper>
356
+ </ScreenContainer>
357
+ </div>
358
+ </OnboardingErrorBoundary>
359
+ </OnboardingIntlProvider>
360
+ );
361
+ }
@@ -0,0 +1,108 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react';
2
+ import type { OnboardingLabels } from '../types/labels';
3
+
4
+ /** English source strings. Override any subset via `OnboardingConfig.labels`. */
5
+ export const defaultOnboardingLabels: Readonly<OnboardingLabels> = {
6
+ continueAction: 'Continue',
7
+ back: 'Back',
8
+ skip: 'Skip',
9
+ loading: 'Loading…',
10
+ stepOfPattern: 'Step {current} of {total}',
11
+ countOfPattern: '{current} of {total}',
12
+
13
+ selectAllThatApply: 'Select all that apply',
14
+ selectOne: 'Select one',
15
+ pickUpToPattern: 'Pick up to {max}',
16
+ pickTheOne: 'Pick the one that bothers you most',
17
+ tailorExperience: 'Tailor the experience to your needs',
18
+
19
+ agree: 'Agree',
20
+ disagree: 'Disagree',
21
+ swipeHint: 'Swipe right if you agree, left if you don’t. Or use the arrow keys.',
22
+ allDone: 'All done — that was easy.',
23
+
24
+ withoutUs: 'Without us',
25
+ withUs: 'With us',
26
+
27
+ previous: 'Previous',
28
+ nextAction: 'Next',
29
+
30
+ continueWithResult: 'Continue with my result',
31
+
32
+ allowAccess: 'Allow access',
33
+ permissionRequestFailed: 'Permission request failed',
34
+
35
+ continueWithEmail: 'Continue with email',
36
+ continueWithGoogle: 'Continue with Google',
37
+ continueWithApple: 'Continue with Apple',
38
+ continueWithGitHub: 'Continue with GitHub',
39
+ skipForNow: 'Skip for now',
40
+ signInFailed: 'Sign-in failed',
41
+
42
+ couldNotStartTrial: 'Could not start trial',
43
+ trialDaysPattern: '{count}-day free trial',
44
+ trialIntroPattern: '{price} for the first {days} days',
45
+ cancelAnytimeNote: 'Cancel anytime. No hidden fees.',
46
+
47
+ share: 'Share',
48
+ copied: 'Copied!',
49
+
50
+ somethingWentWrong: 'Something went wrong',
51
+ unexpectedError: 'An unexpected error occurred during onboarding.',
52
+ tryAgain: 'Try again',
53
+ };
54
+
55
+ /** Replace `{placeholder}` tokens in a pattern label. */
56
+ export function formatLabel(
57
+ pattern: string,
58
+ vars: Record<string, string | number>,
59
+ ): string {
60
+ return pattern.replace(/\{(\w+)\}/g, (match, key: string) =>
61
+ key in vars ? String(vars[key]) : match,
62
+ );
63
+ }
64
+
65
+ /** Labels + locale bundled for React context. */
66
+ export interface OnboardingIntl {
67
+ labels: Readonly<OnboardingLabels>;
68
+ /** BCP-47 tag used for locale-aware formatting (e.g. large counts). */
69
+ locale: string | undefined;
70
+ }
71
+
72
+ /** Exported for class-based consumers (error boundaries) that cannot call hooks. */
73
+ export const OnboardingIntlContext = createContext<OnboardingIntl>({
74
+ labels: defaultOnboardingLabels,
75
+ locale: undefined,
76
+ });
77
+
78
+ export interface OnboardingIntlProviderProps {
79
+ labels?: Partial<OnboardingLabels>;
80
+ locale?: string;
81
+ children: ReactNode;
82
+ }
83
+
84
+ /**
85
+ * Provides label/locale overrides to every screen in the flow. Screens used
86
+ * standalone (outside `OnboardingFlow`) fall back to the English defaults.
87
+ */
88
+ export function OnboardingIntlProvider({
89
+ labels,
90
+ locale,
91
+ children,
92
+ }: OnboardingIntlProviderProps) {
93
+ const value = useMemo<OnboardingIntl>(
94
+ () => ({
95
+ labels: labels ? { ...defaultOnboardingLabels, ...labels } : defaultOnboardingLabels,
96
+ locale,
97
+ }),
98
+ [labels, locale],
99
+ );
100
+ return (
101
+ <OnboardingIntlContext.Provider value={value}>{children}</OnboardingIntlContext.Provider>
102
+ );
103
+ }
104
+
105
+ /** Access the active labels and locale. English defaults when no provider. */
106
+ export function useOnboardingIntl(): OnboardingIntl {
107
+ return useContext(OnboardingIntlContext);
108
+ }
@@ -0,0 +1,7 @@
1
+ export { useOnboardingProgress } from './useOnboardingProgress';
2
+ export type { UseOnboardingProgressReturn } from './useOnboardingProgress';
3
+ export { usePermissionPriming } from './usePermissionPriming';
4
+ export type { UsePermissionPrimingReturn } from './usePermissionPriming';
5
+ export { useOnboardingAnalytics } from './useOnboardingAnalytics';
6
+ export type { UseOnboardingAnalyticsReturn } from './useOnboardingAnalytics';
7
+ export { useReducedMotion } from './useReducedMotion';
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useOnboardingAnalytics } from './useOnboardingAnalytics';
4
+ import type { OnboardingAnalyticsEvent } from '../../types/config';
5
+
6
+ const event: OnboardingAnalyticsEvent = {
7
+ name: 'onboarding_step_completed',
8
+ stepId: 'goal',
9
+ stepType: 'goal-question',
10
+ index: 1,
11
+ timestamp: 0,
12
+ };
13
+
14
+ describe('useOnboardingAnalytics', () => {
15
+ it('fires events through the custom tracker when enabled', () => {
16
+ const tracker = vi.fn();
17
+ const { result } = renderHook(() =>
18
+ useOnboardingAnalytics({ trackEvents: true, provider: 'custom', customTracker: tracker }),
19
+ );
20
+ result.current.trackEvent(event);
21
+ expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
22
+ });
23
+
24
+ it('trackFunnel is an alias of trackEvent (same dispatch)', () => {
25
+ const tracker = vi.fn();
26
+ const { result } = renderHook(() =>
27
+ useOnboardingAnalytics({ trackEvents: true, provider: 'custom', customTracker: tracker }),
28
+ );
29
+ result.current.trackFunnel(event);
30
+ expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
31
+ });
32
+
33
+ it('never dispatches when trackEvents is false', () => {
34
+ const tracker = vi.fn();
35
+ const { result } = renderHook(() =>
36
+ useOnboardingAnalytics({ trackEvents: false, provider: 'custom', customTracker: tracker }),
37
+ );
38
+ result.current.trackEvent(event);
39
+ expect(tracker).not.toHaveBeenCalled();
40
+ expect(result.current.enabled).toBe(false);
41
+ });
42
+
43
+ it('never dispatches without a config at all', () => {
44
+ const { result } = renderHook(() => useOnboardingAnalytics(undefined));
45
+ expect(() => result.current.trackEvent(event)).not.toThrow();
46
+ expect(result.current.enabled).toBe(false);
47
+ });
48
+
49
+ it('keeps callback identity stable across re-renders', () => {
50
+ const tracker = vi.fn();
51
+ const { result, rerender } = renderHook(() =>
52
+ useOnboardingAnalytics({ trackEvents: true, provider: 'custom', customTracker: tracker }),
53
+ );
54
+ const first = result.current.trackEvent;
55
+ rerender();
56
+ expect(result.current.trackEvent).toBe(first);
57
+ });
58
+
59
+ it('survives a throwing tracker (analytics is an observation channel)', () => {
60
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
61
+ const { result } = renderHook(() =>
62
+ useOnboardingAnalytics({
63
+ trackEvents: true,
64
+ provider: 'custom',
65
+ customTracker: () => {
66
+ throw new Error('boom');
67
+ },
68
+ }),
69
+ );
70
+ expect(() => result.current.trackEvent(event)).not.toThrow();
71
+ expect(error).toHaveBeenCalled();
72
+ error.mockRestore();
73
+ });
74
+ });
@@ -0,0 +1,62 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import type {
3
+ AnalyticsConfig,
4
+ OnboardingAnalyticsEvent,
5
+ } from '../../types/config';
6
+ import { trackOnboardingEvent } from '../../utils/analytics-dispatch';
7
+
8
+ export interface UseOnboardingAnalyticsReturn {
9
+ trackEvent: (event: OnboardingAnalyticsEvent) => void;
10
+ /** Alias of `trackEvent` kept for call-site readability on funnel events. */
11
+ trackFunnel: (event: OnboardingAnalyticsEvent) => void;
12
+ enabled: boolean;
13
+ }
14
+
15
+ /**
16
+ * Hook façade for analytics dispatch.
17
+ * Single responsibility: provide a stable callback that honors the analytics config.
18
+ *
19
+ * The returned callbacks are identity-stable across re-renders even when the
20
+ * consumer recreates the `config` object every render — event handlers and
21
+ * effects that depend on them do not re-fire spuriously. The latest config is
22
+ * read through a ref synchronized after each commit.
23
+ *
24
+ * Dispatch never throws: analytics failures are logged by the dispatcher and
25
+ * dropped (see `utils/analytics-dispatch`).
26
+ */
27
+ export function useOnboardingAnalytics(config: AnalyticsConfig | undefined): UseOnboardingAnalyticsReturn {
28
+ const configRef = useRef(config);
29
+ useEffect(() => {
30
+ configRef.current = config;
31
+ }, [config]);
32
+
33
+ const enabled = config?.trackEvents === true;
34
+
35
+ const dispatch = useCallback(
36
+ (event: OnboardingAnalyticsEvent) => {
37
+ if (!enabled) return;
38
+ const current = configRef.current;
39
+ if (!current) return;
40
+ trackOnboardingEvent(event, {
41
+ provider: current.provider,
42
+ customTracker: current.customTracker,
43
+ });
44
+ },
45
+ [enabled],
46
+ );
47
+
48
+ const trackEvent = useCallback(
49
+ (event: OnboardingAnalyticsEvent) => dispatch(event),
50
+ [dispatch],
51
+ );
52
+
53
+ const trackFunnel = useCallback(
54
+ (event: OnboardingAnalyticsEvent) => dispatch(event),
55
+ [dispatch],
56
+ );
57
+
58
+ return useMemo(
59
+ () => ({ trackEvent, trackFunnel, enabled }),
60
+ [trackEvent, trackFunnel, enabled],
61
+ );
62
+ }