@umituz/web-dashboard 6.1.0 → 6.2.1

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 (135) hide show
  1. package/README.md +47 -73
  2. package/package.json +5 -9
  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 +1 -9
  45. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +0 -6
  46. package/src/domains/onboarding/calc/count-with-locale.ts +0 -8
  47. package/src/domains/onboarding/calc/duration-equal-split.ts +0 -12
  48. package/src/domains/onboarding/calc/elapsed-millis.ts +0 -6
  49. package/src/domains/onboarding/calc/progress-percentage.ts +1 -11
  50. package/src/domains/onboarding/calc/slider-value-bounds.ts +1 -11
  51. package/src/domains/onboarding/calc/step-index-clamp.ts +0 -9
  52. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +0 -13
  53. package/src/domains/onboarding/components/{shared/AnimationWrapper.tsx → AnimationWrapper.tsx} +0 -4
  54. package/src/domains/onboarding/components/{shared/OnboardingErrorBoundary.tsx → OnboardingErrorBoundary.tsx} +7 -27
  55. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +8 -30
  56. package/src/domains/onboarding/components/OnboardingFlow.tsx +13 -61
  57. package/src/domains/onboarding/components/OnboardingIntl.tsx +1 -11
  58. package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
  59. package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
  60. package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
  61. package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
  62. package/src/domains/onboarding/components/{shared/SwipeCard.test.tsx → SwipeCard.test.tsx} +1 -3
  63. package/src/domains/onboarding/components/{shared/SwipeCard.tsx → SwipeCard.tsx} +10 -28
  64. package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
  65. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +7 -17
  66. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +4 -10
  67. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +12 -22
  68. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +9 -6
  69. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +10 -10
  70. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +22 -38
  71. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +14 -29
  72. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +10 -23
  73. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +13 -27
  74. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +11 -27
  75. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +22 -51
  76. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +13 -30
  77. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +13 -29
  78. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +12 -32
  79. package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingAnalytics.test.tsx +1 -1
  80. package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingAnalytics.ts +3 -16
  81. package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingProgress.test.tsx +2 -2
  82. package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingProgress.ts +10 -21
  83. package/src/domains/onboarding/{components/hooks → hooks}/usePermissionPriming.ts +3 -10
  84. package/src/domains/onboarding/{components/hooks → hooks}/useReducedMotion.ts +0 -5
  85. package/src/domains/onboarding/index.ts +16 -45
  86. package/src/domains/onboarding/predicates/has-handler.ts +0 -4
  87. package/src/domains/onboarding/predicates/icon-name-resolves.ts +1 -17
  88. package/src/domains/onboarding/predicates/permission-granted.ts +0 -5
  89. package/src/domains/onboarding/predicates/permission-supports.ts +1 -11
  90. package/src/domains/onboarding/predicates/predicates.test.ts +6 -20
  91. package/src/domains/onboarding/predicates/screen-emits-data.ts +0 -4
  92. package/src/domains/onboarding/predicates/storage-available.ts +0 -8
  93. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +19 -39
  94. package/src/domains/onboarding/templates/art-onboarding.ts +1 -11
  95. package/src/domains/onboarding/templates/health-onboarding.ts +0 -5
  96. package/src/domains/onboarding/templates/platform-onboarding.ts +0 -9
  97. package/src/domains/onboarding/templates/video-onboarding.ts +0 -5
  98. package/src/domains/onboarding/test/setup.ts +1 -8
  99. package/src/domains/onboarding/types/config.ts +7 -18
  100. package/src/domains/onboarding/types/labels.ts +25 -59
  101. package/src/domains/onboarding/types/screens.ts +3 -11
  102. package/src/domains/onboarding/utils/analytics-dispatch.ts +2 -21
  103. package/src/domains/onboarding/utils/default-storage-key.ts +0 -1
  104. package/src/domains/onboarding/utils/index.ts +0 -1
  105. package/src/domains/onboarding/utils/local-storage-bridge.ts +1 -21
  106. package/src/domains/onboarding/utils/permission-bridge.ts +0 -8
  107. package/src/domains/onboarding/validation/flow-structure-check.ts +0 -6
  108. package/src/domains/onboarding/validation/permission-status-check.ts +0 -5
  109. package/src/domains/onboarding/validation/screen-config-check.ts +0 -5
  110. package/src/domains/onboarding/validation/stored-progress-check.ts +1 -10
  111. package/src/domains/onboarding/validation/validation.test.ts +1 -6
  112. package/src/domains/permissions/hasPermission.ts +0 -8
  113. package/src/domains/permissions/types.ts +0 -20
  114. package/src/domains/settings/components/index.ts +0 -4
  115. package/src/domains/settings/hooks/index.ts +0 -4
  116. package/src/domains/settings/hooks/useSettings.ts +1 -26
  117. package/src/domains/settings/index.ts +0 -6
  118. package/src/domains/settings/types/index.ts +0 -4
  119. package/src/domains/settings/types/settings.ts +23 -67
  120. package/src/domains/settings/utils/index.ts +0 -4
  121. package/src/domains/settings/utils/settings.ts +0 -45
  122. package/src/styles/dashboard.css +818 -2
  123. package/src/domains/onboarding/components/shared/OptionCard.tsx +0 -73
  124. package/src/domains/onboarding/components/shared/PrimaryButton.tsx +0 -58
  125. package/src/domains/onboarding/components/shared/ProgressBar.tsx +0 -51
  126. package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +0 -36
  127. package/src/domains/onboarding/components/shared/ScreenContainer.tsx +0 -29
  128. package/src/domains/onboarding/components/shared/index.ts +0 -16
  129. package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +0 -20
  130. package/src/domains/onboarding/onboarding.css +0 -1
  131. package/src/domains/onboarding/theme/cn.ts +0 -10
  132. package/src/domains/onboarding/theme/index.css +0 -131
  133. package/src/domains/onboarding/theme/index.ts +0 -12
  134. package/src/domains/onboarding/theme/tokens.ts +0 -122
  135. /package/src/domains/onboarding/{components/hooks → hooks}/index.ts +0 -0
@@ -47,14 +47,12 @@ const minimalConfig = (overrides: Partial<OnboardingConfig> = {}): OnboardingCon
47
47
 
48
48
  type FlowProps = Omit<Partial<React.ComponentProps<typeof OnboardingFlow>>, 'config'>;
49
49
 
50
- /** Ephemeral render — keeps tests isolated from localStorage unless persistence is under test. */
51
50
  function renderFlow(config: OnboardingConfig, props: FlowProps = {}) {
52
51
  return render(
53
52
  <OnboardingFlow config={{ ...config, storageKey: 'test:flow' }} ephemeral {...props} />,
54
53
  );
55
54
  }
56
55
 
57
- /** Advance past the account gate. `onSignIn` is async — wait for the next screen. */
58
56
  async function passAccountGate(nextTitle: string) {
59
57
  fireEvent.click(screen.getByRole('button', { name: 'Continue with email' }));
60
58
  await waitFor(() => expect(screen.getByText(nextTitle)).toBeTruthy());
@@ -62,16 +60,13 @@ async function passAccountGate(nextTitle: string) {
62
60
 
63
61
  beforeEach(() => {
64
62
  window.localStorage.clear();
65
- // Silence React's error-boundary logging; the boundary's own console.error
66
- // is asserted behaviorally, not via the spy.
67
- vi.spyOn(console, 'error').mockImplementation(() => undefined);
63
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
68
64
  });
69
65
 
70
66
  afterEach(() => {
71
67
  vi.restoreAllMocks();
72
68
  });
73
69
 
74
- // TEST_SCENARIOS.md §1.1–§1.3
75
70
  describe('OnboardingFlow — navigation', () => {
76
71
  it('renders the first screen with the progress label "Step 1 of 3"', () => {
77
72
  renderFlow(minimalConfig());
@@ -116,7 +111,6 @@ describe('OnboardingFlow — navigation', () => {
116
111
  });
117
112
  });
118
113
 
119
- // TEST_SCENARIOS.md §17
120
114
  describe('OnboardingFlow — analytics funnel', () => {
121
115
  it('emits started, per-step completed, and completed — each exactly once', async () => {
122
116
  const tracker = vi.fn();
@@ -145,8 +139,7 @@ describe('OnboardingFlow — analytics funnel', () => {
145
139
  const tracker = vi.fn();
146
140
  const analytics = { trackEvents: true, provider: 'custom', customTracker: tracker } as const;
147
141
  const first = renderFlow(minimalConfig({ analytics }));
148
- first.unmount(); // no engagement yet -> no abandonment event
149
- expect(tracker.mock.calls.map((c) => c[0].name)).not.toContain('onboarding_abandoned');
142
+ first.unmount(); expect(tracker.mock.calls.map((c) => c[0].name)).not.toContain('onboarding_abandoned');
150
143
 
151
144
  const second = renderFlow(minimalConfig({ analytics }));
152
145
  fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
@@ -167,7 +160,6 @@ describe('OnboardingFlow — analytics funnel', () => {
167
160
  });
168
161
  });
169
162
 
170
- // TEST_SCENARIOS.md §1.11, §1.7, §1.8
171
163
  describe('OnboardingFlow — validation & error boundary', () => {
172
164
  it('fails fast on duplicate step ids (§1.11)', () => {
173
165
  const bad = minimalConfig({ screens: [welcome, { ...welcome }] });
@@ -213,7 +205,6 @@ describe('OnboardingFlow — validation & error boundary', () => {
213
205
  });
214
206
  });
215
207
 
216
- // TEST_SCENARIOS.md §1.4, §1.5
217
208
  describe('OnboardingFlow — persistence', () => {
218
209
  it('restores progress across remounts (non-ephemeral)', () => {
219
210
  const config = { ...minimalConfig(), storageKey: 'persist:1' };
@@ -259,8 +250,6 @@ describe('OnboardingFlow — labels & locale', () => {
259
250
  });
260
251
  });
261
252
 
262
- // TEST_SCENARIOS.md §14, §15 — with the REVISED paywall error contract:
263
- // a failed purchase attempt must NOT advance the user.
264
253
  describe('screens — account gate & paywall contracts', () => {
265
254
  it('account gate: no skip affordance when required (§14.1)', () => {
266
255
  renderFlow(minimalConfig());
@@ -296,8 +285,7 @@ describe('screens — account gate & paywall contracts', () => {
296
285
  await waitFor(() =>
297
286
  expect(screen.getByRole('alert').textContent).toContain('provider down'),
298
287
  );
299
- expect(screen.getByText('Create your account')).toBeTruthy(); // still here
300
- });
288
+ expect(screen.getByText('Create your account')).toBeTruthy(); });
301
289
 
302
290
  it('paywall: accept advances and fires paywall_accepted (§15.1)', async () => {
303
291
  const tracker = vi.fn();
@@ -356,8 +344,7 @@ describe('screens — account gate & paywall contracts', () => {
356
344
  await waitFor(() =>
357
345
  expect(screen.getByRole('alert').textContent).toContain('charge failed'),
358
346
  );
359
- // Still on the paywall — a failed purchase must never silently advance.
360
- expect(screen.getByText('Go Premium')).toBeTruthy();
347
+ expect(screen.getByText('Go Premium')).toBeTruthy();
361
348
  expect(onComplete).not.toHaveBeenCalled();
362
349
  });
363
350
 
@@ -378,7 +365,6 @@ describe('screens — account gate & paywall contracts', () => {
378
365
  });
379
366
  });
380
367
 
381
- // TEST_SCENARIOS.md §10
382
368
  describe('permission priming screen — funnel events', () => {
383
369
  const priming: OnboardingScreen = {
384
370
  id: 'perm',
@@ -423,12 +409,10 @@ describe('permission priming screen — funnel events', () => {
423
409
  await waitFor(() =>
424
410
  expect(screen.getByRole('alert').textContent).toContain('user cancelled'),
425
411
  );
426
- expect(screen.getByText('Stay on track')).toBeTruthy(); // still here
427
- expect(tracker.mock.calls.map((c) => c[0].name)).toContain('permission_denied');
412
+ expect(screen.getByText('Stay on track')).toBeTruthy(); expect(tracker.mock.calls.map((c) => c[0].name)).toContain('permission_denied');
428
413
  });
429
414
  });
430
415
 
431
- // TEST_SCENARIOS.md §11
432
416
  describe('processing screen — auto-advance', () => {
433
417
  it('completes each step on its timer, then advances the flow (§11.1)', async () => {
434
418
  vi.useFakeTimers();
@@ -438,30 +422,24 @@ describe('processing screen — auto-advance', () => {
438
422
  type: 'processing',
439
423
  title: 'Building your plan',
440
424
  steps: ['A', 'B', 'C'],
441
- durationMs: 300, // 100ms per step
442
- onComplete: () => undefined,
425
+ durationMs: 300, onComplete: () => undefined,
443
426
  };
444
427
  renderFlow(minimalConfig({ screens: [welcome, processing, accountGate] }));
445
428
  fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
446
429
  expect(screen.getByText('Building your plan')).toBeTruthy();
447
430
 
448
- // Advance one step-timer at a time: each state update schedules the next
449
- // timeout inside an effect, which only runs once act flushes the update.
450
- for (let i = 0; i < 3; i++) {
431
+ for (let i = 0; i < 3; i++) {
451
432
  await act(async () => {
452
433
  vi.advanceTimersByTime(100);
453
434
  });
454
435
  }
455
- // All three steps completed -> the flow advanced to the account gate.
456
- expect(screen.getByText('Create your account')).toBeTruthy();
436
+ expect(screen.getByText('Create your account')).toBeTruthy();
457
437
  } finally {
458
438
  vi.useRealTimers();
459
439
  }
460
440
  });
461
441
  });
462
442
 
463
- // TEST_SCENARIOS.md §19 — template regression: every template must mount
464
- // without an analytics crash (the old `provider: 'custom'` with no tracker).
465
443
  describe('templates — mount regression (§19)', () => {
466
444
  const cases: Array<[string, OnboardingConfig]> = [
467
445
  ['health', healthOnboardingTemplate],
@@ -1,13 +1,13 @@
1
1
  import { useCallback, useEffect, useMemo, useRef } from 'react';
2
2
  import type { OnboardingScreen } from '../types/screens';
3
3
  import type { OnboardingConfig } from '../types/config';
4
- import { useOnboardingProgress } from './hooks/useOnboardingProgress';
5
- import { useOnboardingAnalytics } from './hooks/useOnboardingAnalytics';
4
+ import { useOnboardingProgress } from '../hooks/useOnboardingProgress';
5
+ import { useOnboardingAnalytics } from '../hooks/useOnboardingAnalytics';
6
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';
7
+ import { ProgressBar } from './ProgressBar';
8
+ import { ScreenContainer } from './ScreenContainer';
9
+ import { AnimationWrapper, type ScreenTransition } from './AnimationWrapper';
10
+ import { OnboardingErrorBoundary } from './OnboardingErrorBoundary';
11
11
  import { useOnboardingIntl } from './OnboardingIntl';
12
12
  import { checkFlowStructure } from '../validation/flow-structure-check';
13
13
  import {
@@ -29,26 +29,15 @@ import {
29
29
 
30
30
  export interface OnboardingFlowProps {
31
31
  config: OnboardingConfig;
32
- /** Test/Storybook hook: skip persistence. */
33
32
  ephemeral?: boolean;
34
- /** Show progress bar — defaults to true. */
35
33
  showProgress?: boolean;
36
- /** Show back button on non-first screens — defaults to true. */
37
34
  allowBack?: boolean;
38
- /** Show skip button — defaults to true. Calls config.onSkip on click. */
39
35
  allowSkip?: boolean;
40
- /** Custom label for the skip button (overrides `labels.skip`). */
41
36
  skipLabel?: string;
42
- /** Transition style between screens — defaults to 'slide'. */
43
37
  transition?: ScreenTransition;
44
- /** Override the default error UI from inside the boundary. */
45
38
  renderError?: (error: Error, retry: () => void) => React.ReactNode;
46
39
  }
47
40
 
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
41
  type ProgressReturn = ReturnType<typeof useOnboardingProgress>;
53
42
 
54
43
  function renderScreen(
@@ -85,9 +74,6 @@ function renderScreen(
85
74
  case 'tinder-cards':
86
75
  return <TinderCardsScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
87
76
  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
77
  const painScreen = config.screens.find((s) => s.type === 'pain-points');
92
78
  const painIds = painScreen
93
79
  ? ((progress.collected[painScreen.id] as { selected?: string[] } | undefined)
@@ -129,14 +115,12 @@ function renderScreen(
129
115
  />
130
116
  );
131
117
  default: {
132
- // Exhaustive check — TypeScript will flag missing cases at compile time.
133
118
  const _exhaustive: never = screen;
134
119
  return _exhaustive;
135
120
  }
136
121
  }
137
122
  }
138
123
 
139
- /** Back/skip affordances. Rendered inside the intl provider so label overrides apply. */
140
124
  function FlowFooterNavigation({
141
125
  allowBack,
142
126
  allowSkip,
@@ -157,24 +141,16 @@ function FlowFooterNavigation({
157
141
  const { labels } = useOnboardingIntl();
158
142
  if (!visible) return null;
159
143
  return (
160
- <div className="flex items-center justify-between">
144
+ <div className="udb-onb-nav">
161
145
  {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
- >
146
+ <button type="button" onClick={onBack} className="udb-onb-link">
167
147
  {labels.back}
168
148
  </button>
169
149
  ) : (
170
150
  <span />
171
151
  )}
172
152
  {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
- >
153
+ <button type="button" onClick={onSkip} className="udb-onb-link">
178
154
  {skipLabel ?? labels.skip}
179
155
  </button>
180
156
  ) : (
@@ -184,22 +160,6 @@ function FlowFooterNavigation({
184
160
  );
185
161
  }
186
162
 
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
163
  export function OnboardingFlow({
204
164
  config,
205
165
  ephemeral = false,
@@ -217,16 +177,13 @@ export function OnboardingFlow({
217
177
  });
218
178
  const analytics = useOnboardingAnalytics(config.analytics);
219
179
 
220
- // Validate configuration once at mount. Surface errors rather than render broken UI.
221
180
  const validation = useMemo(() => checkFlowStructure(config.screens), [config.screens]);
222
181
  useEffect(() => {
223
182
  if (!validation.ok) {
224
- throw new Error(`[web-onboarding] invalid flow: ${validation.reason}`);
183
+ throw new Error(`[onboarding] invalid flow: ${validation.reason}`);
225
184
  }
226
185
  }, [validation]);
227
186
 
228
- // Lifecycle analytics — started fires exactly once, even if the consumer
229
- // recreates the config object on every render.
230
187
  const startedRef = useRef(false);
231
188
  useEffect(() => {
232
189
  if (startedRef.current) return;
@@ -238,7 +195,6 @@ export function OnboardingFlow({
238
195
  });
239
196
  }, [analytics, config.category]);
240
197
 
241
- // Completion — fires exactly once per finished run.
242
198
  const completedRef = useRef(false);
243
199
  useEffect(() => {
244
200
  if (!progress.isFinished || !progress.result) return;
@@ -252,10 +208,8 @@ export function OnboardingFlow({
252
208
  config.onComplete?.(progress.result);
253
209
  }, [progress.isFinished, progress.result, analytics, config]);
254
210
 
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.
211
+ // Abandonment: unmount without finishing AND after engaging at least one step.
212
+ // StrictMode dev double-mounts happen before engagement, so no spurious events.
259
213
  const abandonSnapshotRef = useRef({ engaged: false, finished: false, currentStep: 0, startedAt: new Date() });
260
214
  useEffect(() => {
261
215
  abandonSnapshotRef.current = {
@@ -276,8 +230,6 @@ export function OnboardingFlow({
276
230
  timestamp: Date.now(),
277
231
  });
278
232
  };
279
- // Config/analytics are read via closure on unmount; the effect intentionally
280
- // installs once for the lifetime of the flow.
281
233
  }, []);
282
234
 
283
235
  const handleStepCompleted = useCallback(
@@ -321,7 +273,7 @@ export function OnboardingFlow({
321
273
  return (
322
274
  <OnboardingIntlProvider labels={config.labels} locale={config.locale}>
323
275
  <OnboardingErrorBoundary {...(renderError ? { renderError } : {})}>
324
- <div className="uob-flow">
276
+ <div className="udb-onb">
325
277
  <ScreenContainer
326
278
  header={
327
279
  <>
@@ -1,7 +1,6 @@
1
1
  import { createContext, useContext, useMemo, type ReactNode } from 'react';
2
2
  import type { OnboardingLabels } from '../types/labels';
3
3
 
4
- /** English source strings. Override any subset via `OnboardingConfig.labels`. */
5
4
  export const defaultOnboardingLabels: Readonly<OnboardingLabels> = {
6
5
  continueAction: 'Continue',
7
6
  back: 'Back',
@@ -52,7 +51,6 @@ export const defaultOnboardingLabels: Readonly<OnboardingLabels> = {
52
51
  tryAgain: 'Try again',
53
52
  };
54
53
 
55
- /** Replace `{placeholder}` tokens in a pattern label. */
56
54
  export function formatLabel(
57
55
  pattern: string,
58
56
  vars: Record<string, string | number>,
@@ -62,14 +60,11 @@ export function formatLabel(
62
60
  );
63
61
  }
64
62
 
65
- /** Labels + locale bundled for React context. */
66
63
  export interface OnboardingIntl {
67
64
  labels: Readonly<OnboardingLabels>;
68
- /** BCP-47 tag used for locale-aware formatting (e.g. large counts). */
69
- locale: string | undefined;
65
+ locale: string | undefined;
70
66
  }
71
67
 
72
- /** Exported for class-based consumers (error boundaries) that cannot call hooks. */
73
68
  export const OnboardingIntlContext = createContext<OnboardingIntl>({
74
69
  labels: defaultOnboardingLabels,
75
70
  locale: undefined,
@@ -81,10 +76,6 @@ export interface OnboardingIntlProviderProps {
81
76
  children: ReactNode;
82
77
  }
83
78
 
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
79
  export function OnboardingIntlProvider({
89
80
  labels,
90
81
  locale,
@@ -102,7 +93,6 @@ export function OnboardingIntlProvider({
102
93
  );
103
94
  }
104
95
 
105
- /** Access the active labels and locale. English defaults when no provider. */
106
96
  export function useOnboardingIntl(): OnboardingIntl {
107
97
  return useContext(OnboardingIntlContext);
108
98
  }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Button, Spinner, type ButtonProps } from '@umituz/web-design-system/atoms';
3
+ import { useOnboardingIntl } from './OnboardingIntl';
4
+
5
+ export interface PrimaryButtonProps extends ButtonProps {
6
+ children: ReactNode;
7
+ loading?: boolean;
8
+ fullWidth?: boolean;
9
+ }
10
+
11
+ export function PrimaryButton({
12
+ children,
13
+ loading = false,
14
+ fullWidth = true,
15
+ disabled,
16
+ type,
17
+ block,
18
+ ...rest
19
+ }: PrimaryButtonProps) {
20
+ const { labels } = useOnboardingIntl();
21
+ return (
22
+ <Button
23
+ {...rest}
24
+ type={type ?? 'button'}
25
+ variant="primary"
26
+ size="lg"
27
+ block={block ?? fullWidth}
28
+ disabled={disabled || loading}
29
+ >
30
+ {loading ? (
31
+ <>
32
+ <Spinner size="sm" />
33
+ <span>{labels.loading}</span>
34
+ </>
35
+ ) : (
36
+ children
37
+ )}
38
+ </Button>
39
+ );
40
+ }
@@ -0,0 +1,32 @@
1
+ import { Progress } from '@umituz/web-design-system/atoms';
2
+ import { cn } from '@umituz/web-design-system/utils/cn';
3
+ import { computeProgressPercentage } from '../calc/progress-percentage';
4
+ import { formatLabel, useOnboardingIntl } from './OnboardingIntl';
5
+
6
+ export interface ProgressBarProps {
7
+ current: number;
8
+ total: number;
9
+ className?: string;
10
+ showLabel?: boolean;
11
+ }
12
+
13
+ export function ProgressBar({ current, total, className, showLabel = false }: ProgressBarProps) {
14
+ const { labels } = useOnboardingIntl();
15
+ const safeTotal = Math.max(1, total);
16
+ const safeCurrent = Math.max(0, Math.min(current, safeTotal));
17
+ const percentage = Math.round(computeProgressPercentage(safeCurrent, safeTotal));
18
+
19
+ return (
20
+ <div className={cn('udb-onb-progress', className)}>
21
+ {showLabel && (
22
+ <div className="udb-onb-progress__meta">
23
+ <span aria-live="polite">
24
+ {formatLabel(labels.stepOfPattern, { current: safeCurrent, total: safeTotal })}
25
+ </span>
26
+ <span aria-hidden="true">{percentage}%</span>
27
+ </div>
28
+ )}
29
+ <Progress value={percentage} label="Onboarding progress" />
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { cn } from '@umituz/web-design-system/utils/cn';
4
+
5
+ export interface QuestionWrapperProps {
6
+ title: string;
7
+ subtitle?: string;
8
+ children: ReactNode;
9
+ className?: string;
10
+ }
11
+
12
+ export function QuestionWrapper({ title, subtitle, children, className }: QuestionWrapperProps) {
13
+ return (
14
+ <motion.div
15
+ initial={{ opacity: 0, y: 8 }}
16
+ animate={{ opacity: 1, y: 0 }}
17
+ exit={{ opacity: 0, y: -8 }}
18
+ transition={{ duration: 0.25, ease: 'easeOut' }}
19
+ className={cn('udb-onb-question', className)}
20
+ >
21
+ <header className="udb-onb-question__head">
22
+ <h2 className="udb-onb-title">{title}</h2>
23
+ {subtitle && <p className="udb-onb-lead">{subtitle}</p>}
24
+ </header>
25
+ <div className="udb-onb-question__body">{children}</div>
26
+ </motion.div>
27
+ );
28
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '@umituz/web-design-system/utils/cn';
3
+
4
+ export interface ScreenContainerProps {
5
+ children: ReactNode;
6
+ header?: ReactNode;
7
+ footer?: ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ export function ScreenContainer({ children, header, footer, className }: ScreenContainerProps) {
12
+ return (
13
+ <div className={cn('udb-onb-screen', className)}>
14
+ {header && <div className="udb-onb-screen__header">{header}</div>}
15
+ <main className="udb-onb-screen__main">{children}</main>
16
+ {footer && <div className="udb-onb-screen__footer">{footer}</div>}
17
+ </div>
18
+ );
19
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { fireEvent, render, screen } from '@testing-library/react';
3
3
  import { SwipeCard } from './SwipeCard';
4
- import { OnboardingIntlProvider } from '../OnboardingIntl';
4
+ import { OnboardingIntlProvider } from './OnboardingIntl';
5
5
 
6
6
  function renderCard(props: Partial<React.ComponentProps<typeof SwipeCard>> = {}) {
7
7
  return render(
@@ -17,8 +17,6 @@ function renderCard(props: Partial<React.ComponentProps<typeof SwipeCard>> = {})
17
17
  );
18
18
  }
19
19
 
20
- // TEST_SCENARIOS.md §6.4 — the gesture math itself is covered in calc tests;
21
- // here we verify the keyboard contract and label rendering.
22
20
  describe('SwipeCard', () => {
23
21
  it('triggers onSwipeRight on ArrowRight and onSwipeLeft on ArrowLeft', () => {
24
22
  const onSwipeLeft = vi.fn();
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { motion, useMotionValue, useTransform, type PanInfo } from 'framer-motion';
3
- import { cn } from '../../theme/cn';
4
3
  import {
5
4
  didCrossLeftSwipeThreshold,
6
5
  didCrossRightSwipeThreshold,
@@ -10,8 +9,8 @@ import {
10
9
  SWIPE_RIGHT_OPACITY_VALUES,
11
10
  SWIPE_ROTATION_RANGE,
12
11
  SWIPE_ROTATION_VALUES,
13
- } from '../../calc/swipe-gesture-curve';
14
- import { useOnboardingIntl } from '../OnboardingIntl';
12
+ } from '../calc/swipe-gesture-curve';
13
+ import { useOnboardingIntl } from './OnboardingIntl';
15
14
 
16
15
  export interface SwipeCardProps {
17
16
  text: string;
@@ -21,13 +20,7 @@ export interface SwipeCardProps {
21
20
  className?: string;
22
21
  }
23
22
 
24
- interface GestureOptions {
25
- onSwipeLeft: () => void;
26
- onSwipeRight: () => void;
27
- }
28
-
29
- function useSwipeGestures({ onSwipeLeft, onSwipeRight }: GestureOptions) {
30
- // Refs avoid stale closures — the gesture handlers always see the latest callbacks.
23
+ function useSwipeGestures({ onSwipeLeft, onSwipeRight }: { onSwipeLeft: () => void; onSwipeRight: () => void }) {
31
24
  const leftRef = useRef(onSwipeLeft);
32
25
  const rightRef = useRef(onSwipeRight);
33
26
  useEffect(() => {
@@ -43,13 +36,6 @@ function useSwipeGestures({ onSwipeLeft, onSwipeRight }: GestureOptions) {
43
36
  };
44
37
  }
45
38
 
46
- /**
47
- * Tinder-style swipeable card.
48
- * Single responsibility: gesture handling and visual feedback for one statement.
49
- * Keyboard-accessible: ← / → keys trigger the same actions as swipe gestures.
50
- *
51
- * All numeric thresholds and curve points live in `calc/swipe-gesture-curve`.
52
- */
53
39
  export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className }: SwipeCardProps) {
54
40
  const { labels } = useOnboardingIntl();
55
41
  const x = useMotionValue(0);
@@ -60,7 +46,7 @@ export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className
60
46
 
61
47
  return (
62
48
  <div
63
- className={cn('relative h-72 w-full select-none focus:outline-none', className)}
49
+ className={className ? `udb-onb-swipe ${className}` : 'udb-onb-swipe'}
64
50
  role="group"
65
51
  aria-label={`${text} — ${labels.swipeHint}`}
66
52
  tabIndex={0}
@@ -79,25 +65,21 @@ export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className
79
65
  drag="x"
80
66
  dragConstraints={{ left: 0, right: 0 }}
81
67
  onDragEnd={onDragEnd}
82
- className="flex h-full w-full cursor-grab flex-col items-center justify-center rounded-2xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-8 text-center shadow-lg active:cursor-grabbing"
68
+ className="udb-onb-swipe__card"
83
69
  >
84
- {category && (
85
- <span className="mb-4 text-xs font-semibold uppercase tracking-wide text-[rgb(var(--uob-primary-500))]">
86
- {category}
87
- </span>
88
- )}
89
- <p className="text-xl font-medium text-[rgb(var(--uob-text-primary))]">{text}</p>
90
- <p className="mt-6 text-sm text-[rgb(var(--uob-text-muted))]">{labels.swipeHint}</p>
70
+ {category && <span className="udb-onb-swipe__category">{category}</span>}
71
+ <p className="udb-onb-swipe__text">{text}</p>
72
+ <p className="udb-onb-swipe__hint">{labels.swipeHint}</p>
91
73
  </motion.div>
92
74
  <motion.div
93
75
  style={{ opacity: leftOpacity }}
94
- className="pointer-events-none absolute left-4 top-4 rounded-md border-2 border-[rgb(var(--uob-error))] px-3 py-1 text-sm font-bold uppercase text-[rgb(var(--uob-error))]"
76
+ className="udb-onb-swipe__stamp udb-onb-swipe__stamp--no"
95
77
  >
96
78
  {labels.disagree}
97
79
  </motion.div>
98
80
  <motion.div
99
81
  style={{ opacity: rightOpacity }}
100
- className="pointer-events-none absolute right-4 top-4 rounded-md border-2 border-[rgb(var(--uob-success))] px-3 py-1 text-sm font-bold uppercase text-[rgb(var(--uob-success))]"
82
+ className="udb-onb-swipe__stamp udb-onb-swipe__stamp--yes"
101
83
  >
102
84
  {labels.agree}
103
85
  </motion.div>
@@ -0,0 +1,7 @@
1
+ import { resolveIconByName } from '../predicates/icon-name-resolves';
2
+
3
+ export function renderBundledIcon(name: string | undefined, className?: string) {
4
+ const Icon = resolveIconByName(name);
5
+ if (!Icon) return null;
6
+ return <Icon className={className} aria-hidden="true" />;
7
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { PrimaryButton } from '../shared/PrimaryButton';
2
+ import { PrimaryButton } from '../PrimaryButton';
3
3
  import {
4
4
  accountGateAllowsSkip,
5
5
  hasOnSignIn,
@@ -31,9 +31,6 @@ export function AccountGateScreen({ config, onNext }: AccountGateScreenProps) {
31
31
  const [busy, setBusy] = useState(false);
32
32
  const [error, setError] = useState<string | null>(null);
33
33
 
34
- // Step lifecycle analytics (`onboarding_step_completed`) are owned by the
35
- // orchestrator — the screen stays presentation-only.
36
-
37
34
  async function handleSignIn(method: SignInMethod) {
38
35
  setBusy(true);
39
36
  setError(null);
@@ -49,22 +46,15 @@ export function AccountGateScreen({ config, onNext }: AccountGateScreenProps) {
49
46
  }
50
47
 
51
48
  return (
52
- <div className="flex flex-1 flex-col items-center justify-center gap-6 text-center">
53
- {config.title && (
54
- <h2 className="text-3xl font-bold text-[rgb(var(--uob-text-primary))]">{config.title}</h2>
55
- )}
56
- {config.subtitle && (
57
- <p className="text-base text-[rgb(var(--uob-text-muted))]">{config.subtitle}</p>
58
- )}
49
+ <div className="udb-onb-stage">
50
+ {config.title && <h2 className="udb-onb-title udb-onb-title--display">{config.title}</h2>}
51
+ {config.subtitle && <p className="udb-onb-lead">{config.subtitle}</p>}
59
52
  {error && (
60
- <p
61
- role="alert"
62
- className="w-full rounded-md border border-[rgb(var(--uob-error))] bg-[rgb(var(--uob-error))]/10 p-3 text-sm text-[rgb(var(--uob-error))]"
63
- >
53
+ <p role="alert" className="udb-onb-error">
64
54
  {error}
65
55
  </p>
66
56
  )}
67
- <div className="flex w-full flex-col gap-3">
57
+ <div className="udb-onb-question__body">
68
58
  {config.signInMethod && hasOnSignIn(config) && (
69
59
  <PrimaryButton
70
60
  onClick={() => handleSignIn(config.signInMethod as SignInMethod)}
@@ -78,7 +68,7 @@ export function AccountGateScreen({ config, onNext }: AccountGateScreenProps) {
78
68
  type="button"
79
69
  disabled={busy}
80
70
  onClick={() => onNext({ signedIn: false })}
81
- className="text-sm font-semibold text-[rgb(var(--uob-text-muted))] underline-offset-4 hover:underline disabled:opacity-50"
71
+ className="udb-onb-link"
82
72
  >
83
73
  {config.skipText ?? labels.skipForNow}
84
74
  </button>