@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,313 @@
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 './ProgressBar';
8
+ import { ScreenContainer } from './ScreenContainer';
9
+ import { AnimationWrapper, type ScreenTransition } from './AnimationWrapper';
10
+ import { OnboardingErrorBoundary } from './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
+ ephemeral?: boolean;
33
+ showProgress?: boolean;
34
+ allowBack?: boolean;
35
+ allowSkip?: boolean;
36
+ skipLabel?: string;
37
+ transition?: ScreenTransition;
38
+ renderError?: (error: Error, retry: () => void) => React.ReactNode;
39
+ }
40
+
41
+ type ProgressReturn = ReturnType<typeof useOnboardingProgress>;
42
+
43
+ function renderScreen(
44
+ screen: OnboardingScreen,
45
+ progress: ProgressReturn,
46
+ config: OnboardingConfig,
47
+ onStepCompleted: (data: unknown) => void,
48
+ ) {
49
+ switch (screen.type) {
50
+ case 'welcome':
51
+ return <WelcomeScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
52
+ case 'goal-question':
53
+ return (
54
+ <GoalQuestionScreen
55
+ config={screen}
56
+ onNext={(data) => onStepCompleted(data)}
57
+ initialSelected={
58
+ (progress.collected[screen.id] as { selected?: string[] } | undefined)?.selected
59
+ }
60
+ />
61
+ );
62
+ case 'pain-points':
63
+ return (
64
+ <PainPointsScreen
65
+ config={screen}
66
+ onNext={(data) => onStepCompleted(data)}
67
+ initialSelected={
68
+ (progress.collected[screen.id] as { selected?: string[] } | undefined)?.selected
69
+ }
70
+ />
71
+ );
72
+ case 'social-proof':
73
+ return <SocialProofScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
74
+ case 'tinder-cards':
75
+ return <TinderCardsScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
76
+ case 'personalized-solution': {
77
+ const painScreen = config.screens.find((s) => s.type === 'pain-points');
78
+ const painIds = painScreen
79
+ ? ((progress.collected[painScreen.id] as { selected?: string[] } | undefined)
80
+ ?.selected ?? [])
81
+ : [];
82
+ return (
83
+ <PersonalizedSolutionScreen
84
+ config={{ ...screen, selectedPainPointIds: painIds }}
85
+ onNext={() => onStepCompleted(undefined)}
86
+ />
87
+ );
88
+ }
89
+ case 'comparison':
90
+ return <ComparisonScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
91
+ case 'preferences':
92
+ return <PreferencesScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
93
+ case 'permission-priming':
94
+ return (
95
+ <PermissionPrimingScreen
96
+ config={screen}
97
+ onNext={(data) => onStepCompleted({ status: data.status })}
98
+ analytics={config.analytics}
99
+ />
100
+ );
101
+ case 'processing':
102
+ return <ProcessingScreen config={screen} onComplete={(data) => onStepCompleted(data)} />;
103
+ case 'app-demo':
104
+ return <AppDemoScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
105
+ case 'value-delivery':
106
+ return <ValueDeliveryScreen config={screen} onNext={() => onStepCompleted(undefined)} />;
107
+ case 'account-gate':
108
+ return <AccountGateScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
109
+ case 'paywall':
110
+ return (
111
+ <PaywallScreen
112
+ config={screen}
113
+ onNext={(data) => onStepCompleted(data)}
114
+ analytics={config.analytics}
115
+ />
116
+ );
117
+ default: {
118
+ const _exhaustive: never = screen;
119
+ return _exhaustive;
120
+ }
121
+ }
122
+ }
123
+
124
+ function FlowFooterNavigation({
125
+ allowBack,
126
+ allowSkip,
127
+ skipLabel,
128
+ isFirst,
129
+ onBack,
130
+ onSkip,
131
+ visible,
132
+ }: {
133
+ allowBack: boolean;
134
+ allowSkip: boolean;
135
+ skipLabel?: string;
136
+ isFirst: boolean;
137
+ onBack: () => void;
138
+ onSkip: () => void;
139
+ visible: boolean;
140
+ }) {
141
+ const { labels } = useOnboardingIntl();
142
+ if (!visible) return null;
143
+ return (
144
+ <div className="udb-onb-nav">
145
+ {allowBack && !isFirst ? (
146
+ <button type="button" onClick={onBack} className="udb-onb-link">
147
+ {labels.back}
148
+ </button>
149
+ ) : (
150
+ <span />
151
+ )}
152
+ {allowSkip ? (
153
+ <button type="button" onClick={onSkip} className="udb-onb-link">
154
+ {skipLabel ?? labels.skip}
155
+ </button>
156
+ ) : (
157
+ <span />
158
+ )}
159
+ </div>
160
+ );
161
+ }
162
+
163
+ export function OnboardingFlow({
164
+ config,
165
+ ephemeral = false,
166
+ showProgress = true,
167
+ allowBack = true,
168
+ allowSkip = true,
169
+ skipLabel,
170
+ transition = 'slide',
171
+ renderError,
172
+ }: OnboardingFlowProps) {
173
+ const progress = useOnboardingProgress({
174
+ screens: config.screens,
175
+ storageKey: config.storageKey,
176
+ ephemeral,
177
+ });
178
+ const analytics = useOnboardingAnalytics(config.analytics);
179
+
180
+ const validation = useMemo(() => checkFlowStructure(config.screens), [config.screens]);
181
+ useEffect(() => {
182
+ if (!validation.ok) {
183
+ throw new Error(`[onboarding] invalid flow: ${validation.reason}`);
184
+ }
185
+ }, [validation]);
186
+
187
+ const startedRef = useRef(false);
188
+ useEffect(() => {
189
+ if (startedRef.current) return;
190
+ startedRef.current = true;
191
+ analytics.trackEvent({
192
+ name: 'onboarding_started',
193
+ category: config.category,
194
+ timestamp: Date.now(),
195
+ });
196
+ }, [analytics, config.category]);
197
+
198
+ const completedRef = useRef(false);
199
+ useEffect(() => {
200
+ if (!progress.isFinished || !progress.result) return;
201
+ if (completedRef.current) return;
202
+ completedRef.current = true;
203
+ analytics.trackEvent({
204
+ name: 'onboarding_completed',
205
+ durationMs: progress.result.durationMs,
206
+ timestamp: Date.now(),
207
+ });
208
+ config.onComplete?.(progress.result);
209
+ }, [progress.isFinished, progress.result, analytics, config]);
210
+
211
+ // Abandonment: unmount without finishing AND after engaging at least one step.
212
+ // StrictMode dev double-mounts happen before engagement, so no spurious events.
213
+ const abandonSnapshotRef = useRef({ engaged: false, finished: false, currentStep: 0, startedAt: new Date() });
214
+ useEffect(() => {
215
+ abandonSnapshotRef.current = {
216
+ engaged: progress.completedSteps.length > 0,
217
+ finished: progress.isFinished,
218
+ currentStep: progress.currentStep,
219
+ startedAt: progress.startedAt,
220
+ };
221
+ }, [progress.completedSteps.length, progress.isFinished, progress.currentStep, progress.startedAt]);
222
+ useEffect(() => {
223
+ return () => {
224
+ const snapshot = abandonSnapshotRef.current;
225
+ if (snapshot.finished || !snapshot.engaged) return;
226
+ analytics.trackEvent({
227
+ name: 'onboarding_abandoned',
228
+ lastStepId: config.screens[Math.min(snapshot.currentStep, config.screens.length - 1)]?.id ?? '',
229
+ durationMs: Math.max(0, Date.now() - snapshot.startedAt.getTime()),
230
+ timestamp: Date.now(),
231
+ });
232
+ };
233
+ }, []);
234
+
235
+ const handleStepCompleted = useCallback(
236
+ (data: unknown) => {
237
+ const current = config.screens[progress.currentStep];
238
+ if (!current) return;
239
+ analytics.trackEvent({
240
+ name: 'onboarding_step_completed',
241
+ stepId: current.id,
242
+ stepType: current.type,
243
+ index: progress.currentStep,
244
+ timestamp: Date.now(),
245
+ });
246
+ progress.next(data);
247
+ },
248
+ [analytics, config.screens, progress],
249
+ );
250
+
251
+ const handleBack = useCallback(() => {
252
+ if (progress.isFirst) return;
253
+ progress.prev();
254
+ }, [progress]);
255
+
256
+ const handleSkip = useCallback(() => {
257
+ const current = config.screens[progress.currentStep];
258
+ if (!current) return;
259
+ analytics.trackEvent({
260
+ name: 'onboarding_step_skipped',
261
+ stepId: current.id,
262
+ index: progress.currentStep,
263
+ timestamp: Date.now(),
264
+ });
265
+ config.onSkip?.();
266
+ }, [analytics, config, progress.currentStep]);
267
+
268
+ const active = config.screens[progress.currentStep];
269
+ if (!active) {
270
+ return null;
271
+ }
272
+
273
+ return (
274
+ <OnboardingIntlProvider labels={config.labels} locale={config.locale}>
275
+ <OnboardingErrorBoundary {...(renderError ? { renderError } : {})}>
276
+ <div className="udb-onb">
277
+ <ScreenContainer
278
+ header={
279
+ <>
280
+ {config.headerSlot}
281
+ {showProgress && (
282
+ <ProgressBar
283
+ current={progress.currentStep + 1}
284
+ total={progress.totalSteps}
285
+ showLabel
286
+ />
287
+ )}
288
+ </>
289
+ }
290
+ footer={
291
+ <>
292
+ <FlowFooterNavigation
293
+ allowBack={allowBack}
294
+ allowSkip={allowSkip && Boolean(config.onSkip)}
295
+ skipLabel={skipLabel}
296
+ isFirst={progress.isFirst}
297
+ onBack={handleBack}
298
+ onSkip={handleSkip}
299
+ visible={allowBack || allowSkip}
300
+ />
301
+ {config.footerSlot}
302
+ </>
303
+ }
304
+ >
305
+ <AnimationWrapper stepKey={active.id} transition={transition}>
306
+ {renderScreen(active, progress, config, handleStepCompleted)}
307
+ </AnimationWrapper>
308
+ </ScreenContainer>
309
+ </div>
310
+ </OnboardingErrorBoundary>
311
+ </OnboardingIntlProvider>
312
+ );
313
+ }
@@ -0,0 +1,98 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react';
2
+ import type { OnboardingLabels } from '../types/labels';
3
+
4
+ export const defaultOnboardingLabels: Readonly<OnboardingLabels> = {
5
+ continueAction: 'Continue',
6
+ back: 'Back',
7
+ skip: 'Skip',
8
+ loading: 'Loading…',
9
+ stepOfPattern: 'Step {current} of {total}',
10
+ countOfPattern: '{current} of {total}',
11
+
12
+ selectAllThatApply: 'Select all that apply',
13
+ selectOne: 'Select one',
14
+ pickUpToPattern: 'Pick up to {max}',
15
+ pickTheOne: 'Pick the one that bothers you most',
16
+ tailorExperience: 'Tailor the experience to your needs',
17
+
18
+ agree: 'Agree',
19
+ disagree: 'Disagree',
20
+ swipeHint: 'Swipe right if you agree, left if you don’t. Or use the arrow keys.',
21
+ allDone: 'All done — that was easy.',
22
+
23
+ withoutUs: 'Without us',
24
+ withUs: 'With us',
25
+
26
+ previous: 'Previous',
27
+ nextAction: 'Next',
28
+
29
+ continueWithResult: 'Continue with my result',
30
+
31
+ allowAccess: 'Allow access',
32
+ permissionRequestFailed: 'Permission request failed',
33
+
34
+ continueWithEmail: 'Continue with email',
35
+ continueWithGoogle: 'Continue with Google',
36
+ continueWithApple: 'Continue with Apple',
37
+ continueWithGitHub: 'Continue with GitHub',
38
+ skipForNow: 'Skip for now',
39
+ signInFailed: 'Sign-in failed',
40
+
41
+ couldNotStartTrial: 'Could not start trial',
42
+ trialDaysPattern: '{count}-day free trial',
43
+ trialIntroPattern: '{price} for the first {days} days',
44
+ cancelAnytimeNote: 'Cancel anytime. No hidden fees.',
45
+
46
+ share: 'Share',
47
+ copied: 'Copied!',
48
+
49
+ somethingWentWrong: 'Something went wrong',
50
+ unexpectedError: 'An unexpected error occurred during onboarding.',
51
+ tryAgain: 'Try again',
52
+ };
53
+
54
+ export function formatLabel(
55
+ pattern: string,
56
+ vars: Record<string, string | number>,
57
+ ): string {
58
+ return pattern.replace(/\{(\w+)\}/g, (match, key: string) =>
59
+ key in vars ? String(vars[key]) : match,
60
+ );
61
+ }
62
+
63
+ export interface OnboardingIntl {
64
+ labels: Readonly<OnboardingLabels>;
65
+ locale: string | undefined;
66
+ }
67
+
68
+ export const OnboardingIntlContext = createContext<OnboardingIntl>({
69
+ labels: defaultOnboardingLabels,
70
+ locale: undefined,
71
+ });
72
+
73
+ export interface OnboardingIntlProviderProps {
74
+ labels?: Partial<OnboardingLabels>;
75
+ locale?: string;
76
+ children: ReactNode;
77
+ }
78
+
79
+ export function OnboardingIntlProvider({
80
+ labels,
81
+ locale,
82
+ children,
83
+ }: OnboardingIntlProviderProps) {
84
+ const value = useMemo<OnboardingIntl>(
85
+ () => ({
86
+ labels: labels ? { ...defaultOnboardingLabels, ...labels } : defaultOnboardingLabels,
87
+ locale,
88
+ }),
89
+ [labels, locale],
90
+ );
91
+ return (
92
+ <OnboardingIntlContext.Provider value={value}>{children}</OnboardingIntlContext.Provider>
93
+ );
94
+ }
95
+
96
+ export function useOnboardingIntl(): OnboardingIntl {
97
+ return useContext(OnboardingIntlContext);
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
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { SwipeCard } from './SwipeCard';
4
+ import { OnboardingIntlProvider } from './OnboardingIntl';
5
+
6
+ function renderCard(props: Partial<React.ComponentProps<typeof SwipeCard>> = {}) {
7
+ return render(
8
+ <OnboardingIntlProvider>
9
+ <SwipeCard
10
+ text="I want to feel better"
11
+ category="Mood"
12
+ onSwipeLeft={vi.fn()}
13
+ onSwipeRight={vi.fn()}
14
+ {...props}
15
+ />
16
+ </OnboardingIntlProvider>,
17
+ );
18
+ }
19
+
20
+ describe('SwipeCard', () => {
21
+ it('triggers onSwipeRight on ArrowRight and onSwipeLeft on ArrowLeft', () => {
22
+ const onSwipeLeft = vi.fn();
23
+ const onSwipeRight = vi.fn();
24
+ renderCard({ onSwipeLeft, onSwipeRight });
25
+ const card = screen.getByRole('group');
26
+ card.focus();
27
+ fireEvent.keyDown(card, { key: 'ArrowRight' });
28
+ expect(onSwipeRight).toHaveBeenCalledTimes(1);
29
+ expect(onSwipeLeft).not.toHaveBeenCalled();
30
+ fireEvent.keyDown(card, { key: 'ArrowLeft' });
31
+ expect(onSwipeLeft).toHaveBeenCalledTimes(1);
32
+ });
33
+
34
+ it('renders the AGREE / DISAGREE badges and the swipe hint', () => {
35
+ renderCard();
36
+ expect(screen.getByText('Agree')).toBeTruthy();
37
+ expect(screen.getByText('Disagree')).toBeTruthy();
38
+ expect(screen.getByText(/arrow keys/)).toBeTruthy();
39
+ });
40
+
41
+ it('ignores unrelated keys', () => {
42
+ const onSwipeLeft = vi.fn();
43
+ const onSwipeRight = vi.fn();
44
+ renderCard({ onSwipeLeft, onSwipeRight });
45
+ fireEvent.keyDown(screen.getByRole('group'), { key: 'Enter' });
46
+ expect(onSwipeLeft).not.toHaveBeenCalled();
47
+ expect(onSwipeRight).not.toHaveBeenCalled();
48
+ });
49
+ });
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { motion, useMotionValue, useTransform, type PanInfo } from 'framer-motion';
3
+ import {
4
+ didCrossLeftSwipeThreshold,
5
+ didCrossRightSwipeThreshold,
6
+ SWIPE_LEFT_OPACITY_INPUT,
7
+ SWIPE_LEFT_OPACITY_VALUES,
8
+ SWIPE_RIGHT_OPACITY_INPUT,
9
+ SWIPE_RIGHT_OPACITY_VALUES,
10
+ SWIPE_ROTATION_RANGE,
11
+ SWIPE_ROTATION_VALUES,
12
+ } from '../calc/swipe-gesture-curve';
13
+ import { useOnboardingIntl } from './OnboardingIntl';
14
+
15
+ export interface SwipeCardProps {
16
+ text: string;
17
+ category?: string;
18
+ onSwipeLeft: () => void;
19
+ onSwipeRight: () => void;
20
+ className?: string;
21
+ }
22
+
23
+ function useSwipeGestures({ onSwipeLeft, onSwipeRight }: { onSwipeLeft: () => void; onSwipeRight: () => void }) {
24
+ const leftRef = useRef(onSwipeLeft);
25
+ const rightRef = useRef(onSwipeRight);
26
+ useEffect(() => {
27
+ leftRef.current = onSwipeLeft;
28
+ rightRef.current = onSwipeRight;
29
+ }, [onSwipeLeft, onSwipeRight]);
30
+
31
+ return {
32
+ onDragEnd(_: unknown, info: PanInfo) {
33
+ if (didCrossRightSwipeThreshold(info.offset.x)) rightRef.current();
34
+ else if (didCrossLeftSwipeThreshold(info.offset.x)) leftRef.current();
35
+ },
36
+ };
37
+ }
38
+
39
+ export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className }: SwipeCardProps) {
40
+ const { labels } = useOnboardingIntl();
41
+ const x = useMotionValue(0);
42
+ const rotate = useTransform(x, SWIPE_ROTATION_RANGE, SWIPE_ROTATION_VALUES);
43
+ const leftOpacity = useTransform(x, SWIPE_LEFT_OPACITY_INPUT, SWIPE_LEFT_OPACITY_VALUES);
44
+ const rightOpacity = useTransform(x, SWIPE_RIGHT_OPACITY_INPUT, SWIPE_RIGHT_OPACITY_VALUES);
45
+ const { onDragEnd } = useSwipeGestures({ onSwipeLeft, onSwipeRight });
46
+
47
+ return (
48
+ <div
49
+ className={className ? `udb-onb-swipe ${className}` : 'udb-onb-swipe'}
50
+ role="group"
51
+ aria-label={`${text} — ${labels.swipeHint}`}
52
+ tabIndex={0}
53
+ onKeyDown={(e) => {
54
+ if (e.key === 'ArrowLeft') {
55
+ e.preventDefault();
56
+ onSwipeLeft();
57
+ } else if (e.key === 'ArrowRight') {
58
+ e.preventDefault();
59
+ onSwipeRight();
60
+ }
61
+ }}
62
+ >
63
+ <motion.div
64
+ style={{ x, rotate }}
65
+ drag="x"
66
+ dragConstraints={{ left: 0, right: 0 }}
67
+ onDragEnd={onDragEnd}
68
+ className="udb-onb-swipe__card"
69
+ >
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>
73
+ </motion.div>
74
+ <motion.div
75
+ style={{ opacity: leftOpacity }}
76
+ className="udb-onb-swipe__stamp udb-onb-swipe__stamp--no"
77
+ >
78
+ {labels.disagree}
79
+ </motion.div>
80
+ <motion.div
81
+ style={{ opacity: rightOpacity }}
82
+ className="udb-onb-swipe__stamp udb-onb-swipe__stamp--yes"
83
+ >
84
+ {labels.agree}
85
+ </motion.div>
86
+ </div>
87
+ );
88
+ }
@@ -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
+ }