@umituz/web-dashboard 6.0.0 → 6.2.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 (133) hide show
  1. package/README.md +47 -73
  2. package/package.json +15 -7
  3. package/src/config/DashboardConfig.ts +19 -68
  4. package/src/domains/analytics/components/index.ts +0 -6
  5. package/src/domains/analytics/hooks/index.ts +0 -6
  6. package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
  7. package/src/domains/analytics/index.ts +0 -10
  8. package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
  9. package/src/domains/analytics/services/PerformanceService.ts +27 -138
  10. package/src/domains/analytics/services/index.ts +0 -4
  11. package/src/domains/analytics/types/analytics.ts +100 -270
  12. package/src/domains/analytics/types/index.ts +0 -6
  13. package/src/domains/analytics/utils/analytics.test.ts +2 -9
  14. package/src/domains/analytics/utils/analytics.ts +1 -22
  15. package/src/domains/analytics/utils/index.ts +0 -6
  16. package/src/domains/calendar/config.ts +0 -7
  17. package/src/domains/calendar/hooks/index.ts +0 -4
  18. package/src/domains/calendar/hooks/useCalendar.ts +7 -27
  19. package/src/domains/calendar/index.ts +0 -4
  20. package/src/domains/calendar/services/CalendarService.ts +0 -21
  21. package/src/domains/calendar/services/index.ts +0 -4
  22. package/src/domains/calendar/types/calendar.types.ts +9 -53
  23. package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
  24. package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
  25. package/src/domains/calendar/utils/index.ts +0 -26
  26. package/src/domains/layouts/components/index.ts +0 -4
  27. package/src/domains/layouts/hooks/dashboard.ts +0 -21
  28. package/src/domains/layouts/hooks/index.ts +0 -4
  29. package/src/domains/layouts/index.ts +0 -6
  30. package/src/domains/layouts/theme/default.ts +0 -12
  31. package/src/domains/layouts/theme/index.ts +0 -4
  32. package/src/domains/layouts/theme/presets.ts +0 -9
  33. package/src/domains/layouts/theme/utils.ts +0 -15
  34. package/src/domains/layouts/types/index.ts +0 -4
  35. package/src/domains/layouts/types/layout.ts +18 -56
  36. package/src/domains/layouts/types/notification.ts +4 -17
  37. package/src/domains/layouts/types/sidebar.ts +8 -28
  38. package/src/domains/layouts/types/theme.ts +23 -59
  39. package/src/domains/layouts/types/user.ts +9 -30
  40. package/src/domains/layouts/utils/dashboard.ts +4 -48
  41. package/src/domains/layouts/utils/index.ts +0 -4
  42. package/src/domains/modules/build.tsx +2 -16
  43. package/src/domains/modules/types.ts +8 -23
  44. package/src/domains/onboarding/calc/calc.test.ts +173 -0
  45. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +27 -0
  46. package/src/domains/onboarding/calc/count-with-locale.ts +8 -0
  47. package/src/domains/onboarding/calc/duration-equal-split.ts +12 -0
  48. package/src/domains/onboarding/calc/elapsed-millis.ts +6 -0
  49. package/src/domains/onboarding/calc/index.ts +36 -0
  50. package/src/domains/onboarding/calc/progress-percentage.ts +27 -0
  51. package/src/domains/onboarding/calc/slider-value-bounds.ts +34 -0
  52. package/src/domains/onboarding/calc/step-index-clamp.ts +19 -0
  53. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +54 -0
  54. package/src/domains/onboarding/components/AnimationWrapper.tsx +69 -0
  55. package/src/domains/onboarding/components/OnboardingErrorBoundary.tsx +61 -0
  56. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +459 -0
  57. package/src/domains/onboarding/components/OnboardingFlow.tsx +313 -0
  58. package/src/domains/onboarding/components/OnboardingIntl.tsx +98 -0
  59. package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
  60. package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
  61. package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
  62. package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
  63. package/src/domains/onboarding/components/SwipeCard.test.tsx +49 -0
  64. package/src/domains/onboarding/components/SwipeCard.tsx +88 -0
  65. package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
  66. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +79 -0
  67. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +33 -0
  68. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +49 -0
  69. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +59 -0
  70. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  71. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +122 -0
  72. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +98 -0
  73. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +40 -0
  74. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +180 -0
  75. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +63 -0
  76. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +87 -0
  77. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +91 -0
  78. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +69 -0
  79. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +32 -0
  80. package/src/domains/onboarding/components/screens/index.ts +28 -0
  81. package/src/domains/onboarding/hooks/index.ts +7 -0
  82. package/src/domains/onboarding/hooks/useOnboardingAnalytics.test.tsx +74 -0
  83. package/src/domains/onboarding/hooks/useOnboardingAnalytics.ts +49 -0
  84. package/src/domains/onboarding/hooks/useOnboardingProgress.test.tsx +150 -0
  85. package/src/domains/onboarding/hooks/useOnboardingProgress.ts +249 -0
  86. package/src/domains/onboarding/hooks/usePermissionPriming.ts +45 -0
  87. package/src/domains/onboarding/hooks/useReducedMotion.ts +19 -0
  88. package/src/domains/onboarding/index.ts +153 -0
  89. package/src/domains/onboarding/predicates/has-handler.ts +21 -0
  90. package/src/domains/onboarding/predicates/icon-name-resolves.ts +97 -0
  91. package/src/domains/onboarding/predicates/index.ts +26 -0
  92. package/src/domains/onboarding/predicates/permission-granted.ts +21 -0
  93. package/src/domains/onboarding/predicates/permission-supports.ts +56 -0
  94. package/src/domains/onboarding/predicates/predicates.test.ts +188 -0
  95. package/src/domains/onboarding/predicates/screen-emits-data.ts +13 -0
  96. package/src/domains/onboarding/predicates/storage-available.ts +14 -0
  97. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +92 -0
  98. package/src/domains/onboarding/templates/art-onboarding.ts +71 -0
  99. package/src/domains/onboarding/templates/health-onboarding.ts +92 -0
  100. package/src/domains/onboarding/templates/index.ts +6 -0
  101. package/src/domains/onboarding/templates/platform-onboarding.ts +201 -0
  102. package/src/domains/onboarding/templates/video-onboarding.ts +95 -0
  103. package/src/domains/onboarding/test/setup.ts +23 -0
  104. package/src/domains/onboarding/types/config.ts +57 -0
  105. package/src/domains/onboarding/types/index.ts +10 -0
  106. package/src/domains/onboarding/types/labels.ts +49 -0
  107. package/src/domains/onboarding/types/screens.ts +185 -0
  108. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  109. package/src/domains/onboarding/utils/analytics-dispatch.ts +73 -0
  110. package/src/domains/onboarding/utils/default-storage-key.ts +1 -0
  111. package/src/domains/onboarding/utils/index.ts +12 -0
  112. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  113. package/src/domains/onboarding/utils/local-storage-bridge.ts +84 -0
  114. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  115. package/src/domains/onboarding/utils/permission-bridge.ts +87 -0
  116. package/src/domains/onboarding/validation/flow-structure-check.ts +22 -0
  117. package/src/domains/onboarding/validation/index.ts +9 -0
  118. package/src/domains/onboarding/validation/permission-status-check.ts +18 -0
  119. package/src/domains/onboarding/validation/screen-config-check.ts +100 -0
  120. package/src/domains/onboarding/validation/stored-progress-check.ts +59 -0
  121. package/src/domains/onboarding/validation/validation.test.ts +170 -0
  122. package/src/domains/permissions/hasPermission.ts +0 -8
  123. package/src/domains/permissions/types.ts +0 -20
  124. package/src/domains/settings/components/index.ts +0 -4
  125. package/src/domains/settings/hooks/index.ts +0 -4
  126. package/src/domains/settings/hooks/useSettings.ts +1 -26
  127. package/src/domains/settings/index.ts +0 -6
  128. package/src/domains/settings/types/index.ts +0 -4
  129. package/src/domains/settings/types/settings.ts +23 -67
  130. package/src/domains/settings/utils/index.ts +0 -4
  131. package/src/domains/settings/utils/settings.ts +0 -45
  132. package/src/styles/dashboard.contract.test.ts +1 -0
  133. package/src/styles/dashboard.css +818 -1
@@ -0,0 +1,69 @@
1
+ import type { ReactNode } from 'react';
2
+ import { AnimatePresence, motion } from 'framer-motion';
3
+ import { useReducedMotion } from '../hooks/useReducedMotion';
4
+
5
+ export type ScreenTransition = 'slide' | 'fade' | 'scale' | 'none';
6
+
7
+ export interface AnimationWrapperProps {
8
+ stepKey: string;
9
+ children: ReactNode;
10
+ transition?: ScreenTransition;
11
+ className?: string;
12
+ }
13
+
14
+ const variantsByTransition: Record<
15
+ Exclude<ScreenTransition, 'none'>,
16
+ {
17
+ initial: { opacity: number; x?: number; y?: number; scale?: number };
18
+ animate: { opacity: number; x?: number; y?: number; scale?: number };
19
+ exit: { opacity: number; x?: number; y?: number; scale?: number };
20
+ }
21
+ > = {
22
+ slide: {
23
+ initial: { opacity: 0, x: 40 },
24
+ animate: { opacity: 1, x: 0 },
25
+ exit: { opacity: 0, x: -40 },
26
+ },
27
+ fade: {
28
+ initial: { opacity: 0 },
29
+ animate: { opacity: 1 },
30
+ exit: { opacity: 0 },
31
+ },
32
+ scale: {
33
+ initial: { opacity: 0, scale: 0.96 },
34
+ animate: { opacity: 1, scale: 1 },
35
+ exit: { opacity: 0, scale: 1.04 },
36
+ },
37
+ };
38
+
39
+ export function AnimationWrapper({
40
+ stepKey,
41
+ children,
42
+ transition = 'slide',
43
+ className,
44
+ }: AnimationWrapperProps) {
45
+ const reduced = useReducedMotion();
46
+ const effective: ScreenTransition = reduced ? 'none' : transition;
47
+ if (effective === 'none') {
48
+ return (
49
+ <div key={stepKey} className={className}>
50
+ {children}
51
+ </div>
52
+ );
53
+ }
54
+ const variants = variantsByTransition[effective];
55
+ return (
56
+ <AnimatePresence mode="wait" initial={false}>
57
+ <motion.div
58
+ key={stepKey}
59
+ initial={variants.initial}
60
+ animate={variants.animate}
61
+ exit={variants.exit}
62
+ transition={{ duration: 0.3, ease: 'easeOut' }}
63
+ className={className}
64
+ >
65
+ {children}
66
+ </motion.div>
67
+ </AnimatePresence>
68
+ );
69
+ }
@@ -0,0 +1,61 @@
1
+ import { Component, Fragment, type ContextType, type ErrorInfo, type ReactNode } from 'react';
2
+ import { AlertTriangle } from 'lucide-react';
3
+ import { PrimaryButton } from './PrimaryButton';
4
+ import { OnboardingIntlContext, type OnboardingIntl } from './OnboardingIntl';
5
+
6
+ export interface OnboardingErrorBoundaryProps {
7
+ children: ReactNode;
8
+ onError?: (error: Error, info: ErrorInfo) => void;
9
+ renderError?: (error: Error, retry: () => void) => ReactNode;
10
+ }
11
+
12
+ interface OnboardingErrorBoundaryState {
13
+ error: Error | null;
14
+ attempt: number;
15
+ }
16
+
17
+ export class OnboardingErrorBoundary extends Component<
18
+ OnboardingErrorBoundaryProps,
19
+ OnboardingErrorBoundaryState
20
+ > {
21
+ static contextType = OnboardingIntlContext;
22
+ declare context: ContextType<typeof OnboardingIntlContext>;
23
+
24
+ state: OnboardingErrorBoundaryState = { error: null, attempt: 0 };
25
+
26
+ static getDerivedStateFromError(error: Error): OnboardingErrorBoundaryState {
27
+ return { error, attempt: 0 };
28
+ }
29
+
30
+ componentDidCatch(error: Error, info: ErrorInfo): void {
31
+ this.props.onError?.(error, info);
32
+ console.error('[onboarding] render error', error, info.componentStack);
33
+ }
34
+
35
+ retry = (): void => {
36
+ this.setState((prev) => ({ error: null, attempt: prev.attempt + 1 }));
37
+ };
38
+
39
+ render(): ReactNode {
40
+ const { error, attempt } = this.state;
41
+ const intl: OnboardingIntl = this.context;
42
+
43
+ if (!error) {
44
+ return <Fragment key={attempt}>{this.props.children}</Fragment>;
45
+ }
46
+
47
+ if (this.props.renderError) return this.props.renderError(error, this.retry);
48
+
49
+ const { labels } = intl;
50
+ return (
51
+ <div role="alert" className="udb-onb udb-onb-errorpage">
52
+ <div className="udb-onb-errorpage__icon" aria-hidden="true">
53
+ <AlertTriangle className="udb-onb-icon--lg" />
54
+ </div>
55
+ <h2 className="udb-onb-title">{labels.somethingWentWrong}</h2>
56
+ <p className="udb-onb-lead">{error.message || labels.unexpectedError}</p>
57
+ <PrimaryButton onClick={this.retry}>{labels.tryAgain}</PrimaryButton>
58
+ </div>
59
+ );
60
+ }
61
+ }
@@ -0,0 +1,459 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { OnboardingFlow } from './OnboardingFlow';
4
+ import {
5
+ artOnboardingTemplate,
6
+ healthOnboardingTemplate,
7
+ platformOnboardingTemplate,
8
+ videoOnboardingTemplate,
9
+ } from '../templates';
10
+ import { defaultStorageKey } from '../utils/default-storage-key';
11
+ import type { OnboardingConfig, OnboardingScreen } from '../types';
12
+
13
+ const paywall: OnboardingScreen = {
14
+ id: 'pay',
15
+ type: 'paywall',
16
+ title: 'Go Premium',
17
+ subtitle: 'Everything, unlocked.',
18
+ price: '$6.99',
19
+ trialDays: 7,
20
+ features: [{ iconName: 'Zap', text: 'Faster everything' }],
21
+ cta: 'Start free trial',
22
+ onAccept: async () => ({ accepted: true, trialStarted: true }),
23
+ };
24
+
25
+ const accountGate: OnboardingScreen = {
26
+ id: 'gate',
27
+ type: 'account-gate',
28
+ title: 'Create your account',
29
+ optional: false,
30
+ signInMethod: 'email',
31
+ onSignIn: async () => ({ signedIn: true, method: 'email' }),
32
+ };
33
+
34
+ const welcome: OnboardingScreen = {
35
+ id: 'w',
36
+ type: 'welcome',
37
+ title: 'Welcome',
38
+ subtitle: 'Hello',
39
+ cta: 'Get started',
40
+ };
41
+
42
+ const minimalConfig = (overrides: Partial<OnboardingConfig> = {}): OnboardingConfig => ({
43
+ category: 'health',
44
+ screens: [welcome, accountGate, paywall],
45
+ ...overrides,
46
+ });
47
+
48
+ type FlowProps = Omit<Partial<React.ComponentProps<typeof OnboardingFlow>>, 'config'>;
49
+
50
+ function renderFlow(config: OnboardingConfig, props: FlowProps = {}) {
51
+ return render(
52
+ <OnboardingFlow config={{ ...config, storageKey: 'test:flow' }} ephemeral {...props} />,
53
+ );
54
+ }
55
+
56
+ async function passAccountGate(nextTitle: string) {
57
+ fireEvent.click(screen.getByRole('button', { name: 'Continue with email' }));
58
+ await waitFor(() => expect(screen.getByText(nextTitle)).toBeTruthy());
59
+ }
60
+
61
+ beforeEach(() => {
62
+ window.localStorage.clear();
63
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.restoreAllMocks();
68
+ });
69
+
70
+ describe('OnboardingFlow — navigation', () => {
71
+ it('renders the first screen with the progress label "Step 1 of 3"', () => {
72
+ renderFlow(minimalConfig());
73
+ expect(screen.getByText('Welcome')).toBeTruthy();
74
+ expect(screen.getByText('Step 1 of 3')).toBeTruthy();
75
+ });
76
+
77
+ it('advances through screens via the primary CTA (§1.1)', () => {
78
+ renderFlow(minimalConfig());
79
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
80
+ expect(screen.getByText('Create your account')).toBeTruthy();
81
+ expect(screen.getByText('Step 2 of 3')).toBeTruthy();
82
+ });
83
+
84
+ it('navigates back and offers no back affordance on the first screen (§1.2)', () => {
85
+ renderFlow(minimalConfig());
86
+ expect(screen.queryByText('Back')).toBeNull();
87
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
88
+ fireEvent.click(screen.getByText('Back'));
89
+ expect(screen.getByText('Welcome')).toBeTruthy();
90
+ expect(screen.getByText('Step 1 of 3')).toBeTruthy();
91
+ expect(screen.queryByText('Back')).toBeNull();
92
+ });
93
+
94
+ it('invokes onSkip and fires onboarding_step_skipped (§1.3)', () => {
95
+ const onSkip = vi.fn();
96
+ const tracker = vi.fn();
97
+ renderFlow(
98
+ minimalConfig({
99
+ onSkip,
100
+ analytics: { trackEvents: true, provider: 'custom', customTracker: tracker },
101
+ }),
102
+ );
103
+ fireEvent.click(screen.getByText('Skip'));
104
+ expect(onSkip).toHaveBeenCalledTimes(1);
105
+ expect(tracker.mock.calls.map((c) => c[0].name)).toContain('onboarding_step_skipped');
106
+ });
107
+
108
+ it('hides the skip affordance when no onSkip is configured', () => {
109
+ renderFlow(minimalConfig({ onSkip: undefined }));
110
+ expect(screen.queryByText('Skip')).toBeNull();
111
+ });
112
+ });
113
+
114
+ describe('OnboardingFlow — analytics funnel', () => {
115
+ it('emits started, per-step completed, and completed — each exactly once', async () => {
116
+ const tracker = vi.fn();
117
+ const onComplete = vi.fn();
118
+ renderFlow(
119
+ minimalConfig({
120
+ onComplete,
121
+ analytics: { trackEvents: true, provider: 'custom', customTracker: tracker },
122
+ }),
123
+ );
124
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
125
+ await passAccountGate('Go Premium');
126
+ fireEvent.click(screen.getByRole('button', { name: 'Start free trial' }));
127
+
128
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
129
+ const names = tracker.mock.calls.map((c) => c[0].name);
130
+ expect(names[0]).toBe('onboarding_started');
131
+ expect(names.filter((n) => n === 'onboarding_started')).toHaveLength(1);
132
+ expect(names.filter((n) => n === 'onboarding_step_completed')).toHaveLength(3);
133
+ expect(names).toContain('paywall_viewed');
134
+ expect(names).toContain('paywall_accepted');
135
+ expect(names.filter((n) => n === 'onboarding_completed')).toHaveLength(1);
136
+ });
137
+
138
+ it('emits onboarding_abandoned on unmount after engagement, never before', () => {
139
+ const tracker = vi.fn();
140
+ const analytics = { trackEvents: true, provider: 'custom', customTracker: tracker } as const;
141
+ const first = renderFlow(minimalConfig({ analytics }));
142
+ first.unmount(); expect(tracker.mock.calls.map((c) => c[0].name)).not.toContain('onboarding_abandoned');
143
+
144
+ const second = renderFlow(minimalConfig({ analytics }));
145
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
146
+ second.unmount();
147
+ const names = tracker.mock.calls.map((c) => c[0].name);
148
+ expect(names).toContain('onboarding_abandoned');
149
+ const abandoned = tracker.mock.calls.find((c) => c[0].name === 'onboarding_abandoned');
150
+ expect(abandoned?.[0].lastStepId).toBe('gate');
151
+ });
152
+
153
+ it('stays silent when analytics is disabled (§17.2)', () => {
154
+ const tracker = vi.fn();
155
+ renderFlow(
156
+ minimalConfig({ analytics: { trackEvents: false, provider: 'custom', customTracker: tracker } }),
157
+ );
158
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
159
+ expect(tracker).not.toHaveBeenCalled();
160
+ });
161
+ });
162
+
163
+ describe('OnboardingFlow — validation & error boundary', () => {
164
+ it('fails fast on duplicate step ids (§1.11)', () => {
165
+ const bad = minimalConfig({ screens: [welcome, { ...welcome }] });
166
+ expect(() => renderFlow(bad)).toThrow(/duplicate step id/);
167
+ });
168
+
169
+ const brokenDemo = (): OnboardingScreen => {
170
+ const Boom = (): React.ReactNode => {
171
+ throw new Error('demo boom');
172
+ };
173
+ return {
174
+ id: 'demo',
175
+ type: 'app-demo',
176
+ demo: {
177
+ component: Boom as unknown as React.ComponentType<Record<string, unknown>>,
178
+ onSave: () => undefined,
179
+ },
180
+ };
181
+ };
182
+
183
+ it('recovers via the default error UI (§1.7)', () => {
184
+ renderFlow(minimalConfig({ screens: [welcome, brokenDemo()] }));
185
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
186
+ expect(screen.getByText('Something went wrong')).toBeTruthy();
187
+ expect(screen.getByText('demo boom')).toBeTruthy();
188
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeTruthy();
189
+ });
190
+
191
+ it('renders a custom renderError UI instead of the default (§1.8)', () => {
192
+ renderFlow(minimalConfig({ screens: [welcome, brokenDemo()] }), {
193
+ renderError: (err: Error, retry: () => void) => (
194
+ <div>
195
+ <p>Custom handler: {err.message}</p>
196
+ <button type="button" onClick={retry}>
197
+ Retry anyway
198
+ </button>
199
+ </div>
200
+ ),
201
+ });
202
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
203
+ expect(screen.getByText('Custom handler: demo boom')).toBeTruthy();
204
+ expect(screen.getByRole('button', { name: 'Retry anyway' })).toBeTruthy();
205
+ });
206
+ });
207
+
208
+ describe('OnboardingFlow — persistence', () => {
209
+ it('restores progress across remounts (non-ephemeral)', () => {
210
+ const config = { ...minimalConfig(), storageKey: 'persist:1' };
211
+ const { unmount } = render(<OnboardingFlow config={config} />);
212
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
213
+ unmount();
214
+
215
+ render(<OnboardingFlow config={config} />);
216
+ expect(screen.getByText('Create your account')).toBeTruthy();
217
+ expect(screen.getByText('Step 2 of 3')).toBeTruthy();
218
+ });
219
+
220
+ it('does not persist in ephemeral mode', () => {
221
+ renderFlow(minimalConfig());
222
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
223
+ expect(localStorage.getItem('test:flow')).toBeNull();
224
+ expect(localStorage.getItem(defaultStorageKey)).toBeNull();
225
+ });
226
+ });
227
+
228
+ describe('OnboardingFlow — labels & locale', () => {
229
+ it('overrides default labels via config.labels', () => {
230
+ renderFlow(
231
+ minimalConfig({
232
+ labels: { back: 'Geri', skip: 'Atla' },
233
+ onSkip: () => undefined,
234
+ }),
235
+ );
236
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
237
+ expect(screen.getByText('Geri')).toBeTruthy();
238
+ expect(screen.getByText('Atla')).toBeTruthy();
239
+ });
240
+
241
+ it('formats counts with the configured locale', () => {
242
+ renderFlow(
243
+ minimalConfig({
244
+ screens: [welcome, { ...paywall, socialProof: { text: 'Loved by', count: 1200000 } }],
245
+ locale: 'de-DE',
246
+ }),
247
+ );
248
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
249
+ expect(screen.getByText(/1\.200\.000/)).toBeTruthy();
250
+ });
251
+ });
252
+
253
+ describe('screens — account gate & paywall contracts', () => {
254
+ it('account gate: no skip affordance when required (§14.1)', () => {
255
+ renderFlow(minimalConfig());
256
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
257
+ expect(screen.queryByText('Skip for now')).toBeNull();
258
+ });
259
+
260
+ it('account gate: optional gate offers "Skip for now" which advances unsigned-in (§14.2)', () => {
261
+ renderFlow(minimalConfig({
262
+ screens: [welcome, { ...accountGate, optional: true }, paywall],
263
+ }));
264
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
265
+ fireEvent.click(screen.getByText('Skip for now'));
266
+ expect(screen.getByText('Go Premium')).toBeTruthy();
267
+ });
268
+
269
+ it('account gate: sign-in failure shows an error and does not advance (§14.3)', async () => {
270
+ const config = minimalConfig({
271
+ screens: [
272
+ welcome,
273
+ {
274
+ ...accountGate,
275
+ onSignIn: async () => {
276
+ throw new Error('provider down');
277
+ },
278
+ },
279
+ paywall,
280
+ ],
281
+ });
282
+ renderFlow(config);
283
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
284
+ fireEvent.click(screen.getByRole('button', { name: 'Continue with email' }));
285
+ await waitFor(() =>
286
+ expect(screen.getByRole('alert').textContent).toContain('provider down'),
287
+ );
288
+ expect(screen.getByText('Create your account')).toBeTruthy(); });
289
+
290
+ it('paywall: accept advances and fires paywall_accepted (§15.1)', async () => {
291
+ const tracker = vi.fn();
292
+ const onComplete = vi.fn();
293
+ renderFlow(
294
+ minimalConfig({
295
+ onComplete,
296
+ analytics: { trackEvents: true, provider: 'custom', customTracker: tracker },
297
+ }),
298
+ );
299
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
300
+ await passAccountGate('Go Premium');
301
+ fireEvent.click(screen.getByRole('button', { name: 'Start free trial' }));
302
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
303
+ expect(tracker.mock.calls.map((c) => c[0].name)).toContain('paywall_accepted');
304
+ });
305
+
306
+ it('paywall: secondary CTA declines and advances (§15.2)', async () => {
307
+ const onSecondaryCta = vi.fn();
308
+ const tracker = vi.fn();
309
+ renderFlow(
310
+ minimalConfig({
311
+ screens: [welcome, accountGate, { ...paywall, secondaryCta: 'No thanks', onSecondaryCta }],
312
+ analytics: { trackEvents: true, provider: 'custom', customTracker: tracker },
313
+ }),
314
+ );
315
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
316
+ await passAccountGate('Go Premium');
317
+ fireEvent.click(screen.getByRole('button', { name: 'No thanks' }));
318
+ expect(onSecondaryCta).toHaveBeenCalledTimes(1);
319
+ await waitFor(() =>
320
+ expect(tracker.mock.calls.map((c) => c[0].name)).toContain('paywall_declined'),
321
+ );
322
+ });
323
+
324
+ it('paywall: a throwing onAccept KEEPS the user on the screen with an error (revised §15.3)', async () => {
325
+ const onComplete = vi.fn();
326
+ renderFlow(
327
+ minimalConfig({
328
+ onComplete,
329
+ screens: [
330
+ welcome,
331
+ accountGate,
332
+ {
333
+ ...paywall,
334
+ onAccept: async () => {
335
+ throw new Error('charge failed');
336
+ },
337
+ },
338
+ ],
339
+ }),
340
+ );
341
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
342
+ await passAccountGate('Go Premium');
343
+ fireEvent.click(screen.getByRole('button', { name: 'Start free trial' }));
344
+ await waitFor(() =>
345
+ expect(screen.getByRole('alert').textContent).toContain('charge failed'),
346
+ );
347
+ expect(screen.getByText('Go Premium')).toBeTruthy();
348
+ expect(onComplete).not.toHaveBeenCalled();
349
+ });
350
+
351
+ it('paywall: renders "{count}-day free trial" without trialPrice (§15.4)', async () => {
352
+ renderFlow(minimalConfig());
353
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
354
+ await passAccountGate('Go Premium');
355
+ expect(screen.getByText('7-day free trial')).toBeTruthy();
356
+ });
357
+
358
+ it('paywall: uses the trial intro pattern when trialPrice is set (§15.4)', async () => {
359
+ renderFlow(
360
+ minimalConfig({ screens: [welcome, accountGate, { ...paywall, trialPrice: '$0.99' }] }),
361
+ );
362
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
363
+ await passAccountGate('Go Premium');
364
+ expect(screen.getByText('$0.99 for the first 7 days')).toBeTruthy();
365
+ });
366
+ });
367
+
368
+ describe('permission priming screen — funnel events', () => {
369
+ const priming: OnboardingScreen = {
370
+ id: 'perm',
371
+ type: 'permission-priming',
372
+ title: 'Stay on track',
373
+ description: 'Reminders keep streaks alive.',
374
+ permission: 'notifications',
375
+ benefits: ['Timely nudges'],
376
+ onRequest: async () => 'granted',
377
+ };
378
+
379
+ it('fires requested + granted and advances on success (§10.1)', async () => {
380
+ const tracker = vi.fn();
381
+ renderFlow(
382
+ minimalConfig({
383
+ screens: [welcome, priming, accountGate],
384
+ analytics: { trackEvents: true, provider: 'custom', customTracker: tracker },
385
+ }),
386
+ );
387
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
388
+ fireEvent.click(screen.getByRole('button', { name: 'Allow access' }));
389
+ await waitFor(() => expect(screen.getByText('Create your account')).toBeTruthy());
390
+ const names = tracker.mock.calls.map((c) => c[0].name);
391
+ expect(names).toContain('permission_requested');
392
+ expect(names).toContain('permission_granted');
393
+ });
394
+
395
+ it('shows an inline error and does NOT advance when onRequest throws (§10.3)', async () => {
396
+ const tracker = vi.fn();
397
+ renderFlow(
398
+ minimalConfig({
399
+ screens: [
400
+ welcome,
401
+ { ...priming, onRequest: async () => Promise.reject(new Error('user cancelled')) },
402
+ accountGate,
403
+ ],
404
+ analytics: { trackEvents: true, provider: 'custom', customTracker: tracker },
405
+ }),
406
+ );
407
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
408
+ fireEvent.click(screen.getByRole('button', { name: 'Allow access' }));
409
+ await waitFor(() =>
410
+ expect(screen.getByRole('alert').textContent).toContain('user cancelled'),
411
+ );
412
+ expect(screen.getByText('Stay on track')).toBeTruthy(); expect(tracker.mock.calls.map((c) => c[0].name)).toContain('permission_denied');
413
+ });
414
+ });
415
+
416
+ describe('processing screen — auto-advance', () => {
417
+ it('completes each step on its timer, then advances the flow (§11.1)', async () => {
418
+ vi.useFakeTimers();
419
+ try {
420
+ const processing: OnboardingScreen = {
421
+ id: 'proc',
422
+ type: 'processing',
423
+ title: 'Building your plan',
424
+ steps: ['A', 'B', 'C'],
425
+ durationMs: 300, onComplete: () => undefined,
426
+ };
427
+ renderFlow(minimalConfig({ screens: [welcome, processing, accountGate] }));
428
+ fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
429
+ expect(screen.getByText('Building your plan')).toBeTruthy();
430
+
431
+ for (let i = 0; i < 3; i++) {
432
+ await act(async () => {
433
+ vi.advanceTimersByTime(100);
434
+ });
435
+ }
436
+ expect(screen.getByText('Create your account')).toBeTruthy();
437
+ } finally {
438
+ vi.useRealTimers();
439
+ }
440
+ });
441
+ });
442
+
443
+ describe('templates — mount regression (§19)', () => {
444
+ const cases: Array<[string, OnboardingConfig]> = [
445
+ ['health', healthOnboardingTemplate],
446
+ ['art', artOnboardingTemplate],
447
+ ['video', videoOnboardingTemplate],
448
+ ['platform', platformOnboardingTemplate],
449
+ ];
450
+
451
+ it.each(cases)('%s template renders its first screen without crashing', (name, template) => {
452
+ expect(() =>
453
+ render(<OnboardingFlow config={{ ...template, storageKey: `tpl:${name}` }} ephemeral />),
454
+ ).not.toThrow();
455
+ expect(
456
+ screen.getByText(new RegExp(`Step 1 of ${template.screens.length}`)),
457
+ ).toBeTruthy();
458
+ });
459
+ });