@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,180 @@
1
+ import { useId, useState } from 'react';
2
+ import { PrimaryButton } from '../PrimaryButton';
3
+ import { QuestionWrapper } from '../QuestionWrapper';
4
+ import { useOnboardingIntl } from '../OnboardingIntl';
5
+ import { cn } from '@umituz/web-design-system/utils/cn';
6
+ import {
7
+ clampValueToBounds,
8
+ resolveSliderBounds,
9
+ } from '../../calc/slider-value-bounds';
10
+ import type {
11
+ OnboardingOption,
12
+ PreferenceItem,
13
+ PreferencesScreenConfig,
14
+ } from '../../types/screens';
15
+
16
+ type PrefValue = string | number | string[] | boolean;
17
+
18
+ function defaultFor(item: PreferenceItem): PrefValue {
19
+ if (item.defaultValue !== undefined) {
20
+ if (item.type === 'slider' && typeof item.defaultValue === 'number') {
21
+ return clampValueToBounds(item.defaultValue, item.min ?? 0, item.max ?? 0, item.id);
22
+ }
23
+ return item.defaultValue;
24
+ }
25
+ switch (item.type) {
26
+ case 'multi-select':
27
+ return [];
28
+ case 'toggle':
29
+ return false;
30
+ case 'slider':
31
+ return resolveSliderBounds(item.min, item.max).min;
32
+ case 'select':
33
+ default:
34
+ return item.options?.[0]?.id ?? '';
35
+ }
36
+ }
37
+
38
+ function toggleMulti(values: string[], id: string): string[] {
39
+ return values.includes(id) ? values.filter((v) => v !== id) : [...values, id];
40
+ }
41
+
42
+ export interface PreferencesScreenProps {
43
+ config: PreferencesScreenConfig;
44
+ onNext: (data: { values: Record<string, PrefValue> }) => void;
45
+ }
46
+
47
+ export function PreferencesScreen({ config, onNext }: PreferencesScreenProps) {
48
+ const { labels } = useOnboardingIntl();
49
+ const [values, setValues] = useState<Record<string, PrefValue>>(() => {
50
+ const initial: Record<string, PrefValue> = {};
51
+ for (const item of config.preferences) initial[item.id] = defaultFor(item);
52
+ return initial;
53
+ });
54
+
55
+ function update(id: string, value: PrefValue) {
56
+ setValues((prev) => ({ ...prev, [id]: value }));
57
+ }
58
+
59
+ const canContinue = config.preferences.every((item) => {
60
+ const v = values[item.id];
61
+ if (item.type === 'select') return typeof v === 'string' && v.length > 0;
62
+ if (item.type === 'multi-select') return Array.isArray(v);
63
+ return true;
64
+ });
65
+
66
+ return (
67
+ <QuestionWrapper title={config.title} subtitle={labels.tailorExperience}>
68
+ <div className="udb-onb-prefs">
69
+ {config.preferences.map((item) => (
70
+ <PreferenceRow key={item.id} item={item} value={values[item.id]} onChange={(v) => update(item.id, v)} />
71
+ ))}
72
+ </div>
73
+ <PrimaryButton onClick={() => onNext({ values })} disabled={!canContinue}>
74
+ {config.cta ?? labels.continueAction}
75
+ </PrimaryButton>
76
+ </QuestionWrapper>
77
+ );
78
+ }
79
+
80
+ interface PreferenceRowProps {
81
+ item: PreferenceItem;
82
+ value: PrefValue;
83
+ onChange: (value: PrefValue) => void;
84
+ }
85
+
86
+ function PreferenceRow({ item, value, onChange }: PreferenceRowProps) {
87
+ const fieldId = useId();
88
+ return (
89
+ <div className="udb-onb-field">
90
+ <label className="udb-onb-field__label" htmlFor={fieldId}>
91
+ {item.label}
92
+ </label>
93
+ <PreferenceControl item={item} value={value} onChange={onChange} fieldId={fieldId} />
94
+ </div>
95
+ );
96
+ }
97
+
98
+ interface PreferenceControlProps extends PreferenceRowProps {
99
+ fieldId: string;
100
+ }
101
+
102
+ function PreferenceControl({ item, value, onChange, fieldId }: PreferenceControlProps) {
103
+ switch (item.type) {
104
+ case 'select':
105
+ return (
106
+ <select
107
+ id={fieldId}
108
+ className="udb-onb-select"
109
+ value={typeof value === 'string' ? value : ''}
110
+ onChange={(e) => onChange(e.target.value)}
111
+ >
112
+ {item.options?.map((opt: OnboardingOption) => (
113
+ <option key={opt.id} value={opt.id}>
114
+ {opt.label}
115
+ </option>
116
+ ))}
117
+ </select>
118
+ );
119
+ case 'multi-select': {
120
+ const list = Array.isArray(value) ? value : [];
121
+ return (
122
+ <div className="udb-onb-chips" role="group" aria-label={item.label}>
123
+ {item.options?.map((opt) => {
124
+ const isOn = list.includes(opt.id);
125
+ return (
126
+ <button
127
+ key={opt.id}
128
+ type="button"
129
+ aria-pressed={isOn}
130
+ onClick={() => onChange(toggleMulti(list, opt.id))}
131
+ className={cn('udb-onb-chip', isOn && 'udb-onb-chip--on')}
132
+ >
133
+ {opt.label}
134
+ </button>
135
+ );
136
+ })}
137
+ </div>
138
+ );
139
+ }
140
+ case 'toggle':
141
+ return (
142
+ <button
143
+ type="button"
144
+ id={fieldId}
145
+ role="switch"
146
+ aria-checked={value === true}
147
+ onClick={() => onChange(!value)}
148
+ className={cn('udb-onb-switch', value === true && 'udb-onb-switch--on')}
149
+ >
150
+ <span className="udb-onb-switch__thumb" />
151
+ </button>
152
+ );
153
+ case 'slider': {
154
+ const { min, max } = resolveSliderBounds(item.min, item.max);
155
+ const numeric = typeof value === 'number' ? value : min;
156
+ return (
157
+ <div className="udb-onb-slider">
158
+ <input
159
+ type="range"
160
+ id={fieldId}
161
+ min={min}
162
+ max={max}
163
+ value={numeric}
164
+ onChange={(e) => onChange(Number(e.target.value))}
165
+ aria-valuemin={min}
166
+ aria-valuemax={max}
167
+ aria-valuenow={numeric}
168
+ />
169
+ <div className="udb-onb-slider__scale">
170
+ <span>{min}</span>
171
+ <span>{numeric}</span>
172
+ <span>{max}</span>
173
+ </div>
174
+ </div>
175
+ );
176
+ }
177
+ default:
178
+ return null;
179
+ }
180
+ }
@@ -0,0 +1,63 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { Check } from 'lucide-react';
4
+ import { cn } from '@umituz/web-design-system/utils/cn';
5
+ import { splitTotalDuration } from '../../calc/duration-equal-split';
6
+ import type { ProcessingScreenConfig } from '../../types/screens';
7
+
8
+ export interface ProcessingScreenProps {
9
+ config: ProcessingScreenConfig;
10
+ onComplete: (data: { completed: true }) => void;
11
+ }
12
+
13
+ export function ProcessingScreen({ config, onComplete }: ProcessingScreenProps) {
14
+ const stepDuration = splitTotalDuration(config.durationMs, config.steps.length);
15
+ const [currentStep, setCurrentStep] = useState(0);
16
+
17
+ useEffect(() => {
18
+ if (currentStep >= config.steps.length) {
19
+ onComplete({ completed: true });
20
+ return;
21
+ }
22
+ const timer = window.setTimeout(() => setCurrentStep((s) => s + 1), stepDuration);
23
+ return () => window.clearTimeout(timer);
24
+ }, [currentStep, config.steps.length, stepDuration, onComplete]);
25
+
26
+ return (
27
+ <div className="udb-onb-stage">
28
+ {config.title && <h2 className="udb-onb-title">{config.title}</h2>}
29
+ <ul className="udb-onb-steps" aria-live="polite">
30
+ {config.steps.map((step, i) => {
31
+ const isDone = i < currentStep;
32
+ const isActive = i === currentStep;
33
+ return (
34
+ <li
35
+ key={`${step}-${i}`}
36
+ className={cn(
37
+ 'udb-onb-step',
38
+ isDone && 'udb-onb-step--done',
39
+ isActive && 'udb-onb-step--active',
40
+ )}
41
+ >
42
+ <span className="udb-onb-step__badge">
43
+ {isDone ? <Check className="udb-onb-icon--sm" /> : <span>{i + 1}</span>}
44
+ </span>
45
+ <span>{step}</span>
46
+ <AnimatePresence>
47
+ {isActive && (
48
+ <motion.span
49
+ initial={{ opacity: 0 }}
50
+ animate={{ opacity: 1 }}
51
+ exit={{ opacity: 0 }}
52
+ className="udb-onb-step__pulse"
53
+ aria-hidden="true"
54
+ />
55
+ )}
56
+ </AnimatePresence>
57
+ </li>
58
+ );
59
+ })}
60
+ </ul>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,87 @@
1
+ import { useState } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { Star } from 'lucide-react';
4
+ import { PrimaryButton } from '../PrimaryButton';
5
+ import { Button } from '@umituz/web-design-system/atoms';
6
+ import { cn } from '@umituz/web-design-system/utils/cn';
7
+ import { useOnboardingIntl } from '../OnboardingIntl';
8
+ import type { SocialProofScreenConfig } from '../../types/screens';
9
+
10
+ export interface SocialProofScreenProps {
11
+ config: SocialProofScreenConfig;
12
+ onNext: () => void;
13
+ }
14
+
15
+ export function SocialProofScreen({ config, onNext }: SocialProofScreenProps) {
16
+ const { labels } = useOnboardingIntl();
17
+ const [index, setIndex] = useState(0);
18
+ const total = config.testimonials.length;
19
+ const current = config.testimonials[index];
20
+
21
+ function go(delta: number) {
22
+ setIndex((i) => (i + delta + total) % total);
23
+ }
24
+
25
+ return (
26
+ <div className="udb-onb-proof">
27
+ {config.title && <h2 className="udb-onb-title">{config.title}</h2>}
28
+ <div className="udb-onb-proof__stage">
29
+ <AnimatePresence mode="wait">
30
+ <motion.blockquote
31
+ key={`${current.name}-${index}`}
32
+ initial={{ opacity: 0, y: 12 }}
33
+ animate={{ opacity: 1, y: 0 }}
34
+ exit={{ opacity: 0, y: -12 }}
35
+ transition={{ duration: 0.3 }}
36
+ className="udb-onb-quote"
37
+ >
38
+ {current.rating !== undefined && (
39
+ <div className="udb-onb-quote__stars" aria-label={`Rated ${current.rating} out of 5`}>
40
+ {Array.from({ length: 5 }).map((_, i) => (
41
+ <Star
42
+ key={i}
43
+ className={cn('udb-onb-quote__star', i < current.rating! && 'udb-onb-quote__star--filled')}
44
+ />
45
+ ))}
46
+ </div>
47
+ )}
48
+ <p className="udb-onb-quote__content">“{current.content}”</p>
49
+ <footer className="udb-onb-quote__footer">
50
+ {current.avatar && <img src={current.avatar} alt="" className="udb-onb-quote__avatar" />}
51
+ <cite className="udb-onb-quote__cite">{current.name}</cite>
52
+ <span className="udb-onb-quote__role">{current.role}</span>
53
+ </footer>
54
+ </motion.blockquote>
55
+ </AnimatePresence>
56
+ </div>
57
+ <div className="udb-onb-proof__bar">
58
+ <div className="udb-onb-dots" role="tablist" aria-label="Testimonials">
59
+ {config.testimonials.map((t, i) => (
60
+ <button
61
+ key={`${t.name}-${i}`}
62
+ type="button"
63
+ role="tab"
64
+ aria-selected={i === index}
65
+ aria-label={`${t.name}: ${t.content}`}
66
+ onClick={() => setIndex(i)}
67
+ className={cn('udb-onb-dot', i === index && 'udb-onb-dot--active')}
68
+ />
69
+ ))}
70
+ </div>
71
+ <div className="udb-onb-proof__nav">
72
+ {total > 1 && (
73
+ <>
74
+ <Button variant="outline" size="sm" onClick={() => go(-1)}>
75
+ {labels.previous}
76
+ </Button>
77
+ <Button variant="outline" size="sm" onClick={() => go(1)}>
78
+ {labels.nextAction}
79
+ </Button>
80
+ </>
81
+ )}
82
+ </div>
83
+ </div>
84
+ <PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,91 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { AnimatePresence, motion } from 'framer-motion';
3
+ import { SwipeCard } from '../SwipeCard';
4
+ import { PrimaryButton } from '../PrimaryButton';
5
+ import {
6
+ getCardEnterAnimate,
7
+ getCardEnterExit,
8
+ getCardEnterInitial,
9
+ getCardTransitionDurationMs,
10
+ } from '../../calc/card-stack-motion-curve';
11
+ import { formatLabel, useOnboardingIntl } from '../OnboardingIntl';
12
+ import type { TinderCardsScreenConfig } from '../../types/screens';
13
+
14
+ export interface TinderCardsScreenProps {
15
+ config: TinderCardsScreenConfig;
16
+ onNext: (data: { liked: string[] }) => void;
17
+ }
18
+
19
+ export function TinderCardsScreen({ config, onNext }: TinderCardsScreenProps) {
20
+ const { labels } = useOnboardingIntl();
21
+ const [index, setIndex] = useState(0);
22
+ const [liked, setLiked] = useState<string[]>([]);
23
+ const total = config.statements.length;
24
+ const current = config.statements[index];
25
+ const indexRef = useRef(index);
26
+ const likedRef = useRef(liked);
27
+ useEffect(() => {
28
+ indexRef.current = index;
29
+ likedRef.current = liked;
30
+ }, [index, liked]);
31
+
32
+ const handleDecision = useCallback(
33
+ (likedStatement: boolean) => {
34
+ if (likedStatement) {
35
+ setLiked((prev) => [...prev, config.statements[indexRef.current].id]);
36
+ }
37
+ setIndex((i) => i + 1);
38
+ },
39
+ [config.statements],
40
+ );
41
+
42
+ const onSwipeLeft = useCallback(() => handleDecision(false), [handleDecision]);
43
+ const onSwipeRight = useCallback(() => handleDecision(true), [handleDecision]);
44
+
45
+ if (!current) {
46
+ return (
47
+ <div className="udb-onb-stage">
48
+ <p className="udb-onb-lead">{labels.allDone}</p>
49
+ <PrimaryButton onClick={() => onNext({ liked: likedRef.current })}>
50
+ {config.cta ?? labels.continueAction}
51
+ </PrimaryButton>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <div className="udb-onb-proof">
58
+ {config.title && <h2 className="udb-onb-title">{config.title}</h2>}
59
+ <div className="udb-onb-proof__stage">
60
+ <AnimatePresence mode="wait">
61
+ <motion.div
62
+ key={current.id}
63
+ initial={{ opacity: getCardEnterInitial().opacity, scale: getCardEnterInitial().scale }}
64
+ animate={{ opacity: getCardEnterAnimate().opacity, scale: getCardEnterAnimate().scale }}
65
+ exit={{ opacity: getCardEnterExit().opacity, scale: getCardEnterExit().scale }}
66
+ transition={{ duration: getCardTransitionDurationMs() / 1000 }}
67
+ className="udb-onb-stage"
68
+ >
69
+ <SwipeCard
70
+ text={current.text}
71
+ category={current.category}
72
+ onSwipeLeft={onSwipeLeft}
73
+ onSwipeRight={onSwipeRight}
74
+ />
75
+ </motion.div>
76
+ </AnimatePresence>
77
+ </div>
78
+ <div className="udb-onb-proof__nav">
79
+ <button type="button" onClick={onSwipeLeft} className="udb-onb-choice udb-onb-choice--no">
80
+ {labels.disagree}
81
+ </button>
82
+ <button type="button" onClick={onSwipeRight} className="udb-onb-choice udb-onb-choice--yes">
83
+ {labels.agree}
84
+ </button>
85
+ </div>
86
+ <p className="udb-onb-note" aria-live="polite">
87
+ {formatLabel(labels.countOfPattern, { current: index + 1, total })}
88
+ </p>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,69 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Button } from '@umituz/web-design-system/atoms';
3
+ import { Sparkles } from 'lucide-react';
4
+ import { PrimaryButton } from '../PrimaryButton';
5
+ import { useOnboardingIntl } from '../OnboardingIntl';
6
+ import type { ValueDeliveryScreenConfig } from '../../types/screens';
7
+
8
+ export interface ValueDeliveryScreenProps<TResult = unknown> {
9
+ config: ValueDeliveryScreenConfig<TResult>;
10
+ onNext: () => void;
11
+ }
12
+
13
+ export function ValueDeliveryScreen<TResult = unknown>({
14
+ config,
15
+ onNext,
16
+ }: ValueDeliveryScreenProps<TResult>) {
17
+ const { labels } = useOnboardingIntl();
18
+ const [copied, setCopied] = useState(false);
19
+ const copiedTimer = useRef<number | null>(null);
20
+
21
+ useEffect(
22
+ () => () => {
23
+ if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
24
+ },
25
+ [],
26
+ );
27
+
28
+ async function handleShare() {
29
+ if (!config.shareConfig) return;
30
+ const { text, url } = config.shareConfig;
31
+ try {
32
+ if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
33
+ await navigator.share({ text, url });
34
+ return;
35
+ }
36
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
37
+ await navigator.clipboard.writeText(url ?? text);
38
+ setCopied(true);
39
+ if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
40
+ copiedTimer.current = window.setTimeout(() => setCopied(false), 2000);
41
+ }
42
+ } catch (error) {
43
+ if (!(error instanceof DOMException && error.name === 'AbortError')) {
44
+ console.error('[onboarding] share failed', error);
45
+ }
46
+ }
47
+ }
48
+
49
+ return (
50
+ <div className="udb-onb-stage">
51
+ <div className="udb-onb-hero udb-onb-hero--lg" aria-hidden="true">
52
+ <Sparkles className="udb-onb-icon--lg" />
53
+ </div>
54
+ <h2 className="udb-onb-title udb-onb-title--display">{config.title}</h2>
55
+ <div className="udb-onb-result">
56
+ <pre className="udb-onb-result__pre">{JSON.stringify(config.result, null, 2)}</pre>
57
+ </div>
58
+ {config.shareConfig && (
59
+ <div className="udb-onb-share">
60
+ <Button variant="outline" size="sm" onClick={handleShare}>
61
+ {copied ? labels.copied : labels.share}
62
+ </Button>
63
+ <span>{config.shareConfig.text}</span>
64
+ </div>
65
+ )}
66
+ <PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,32 @@
1
+ import { PrimaryButton } from '../PrimaryButton';
2
+ import { renderBundledIcon } from '../renderBundledIcon';
3
+ import { doesIconNameResolve } from '../../predicates/icon-name-resolves';
4
+ import { useOnboardingIntl } from '../OnboardingIntl';
5
+ import type { WelcomeScreenConfig } from '../../types/screens';
6
+
7
+ export interface WelcomeScreenProps {
8
+ config: WelcomeScreenConfig;
9
+ onNext: () => void;
10
+ isLoading?: boolean;
11
+ }
12
+
13
+ export function WelcomeScreen({ config, onNext, isLoading }: WelcomeScreenProps) {
14
+ const { labels } = useOnboardingIntl();
15
+ return (
16
+ <div className="udb-onb-stage udb-onb-stage--wide">
17
+ {doesIconNameResolve(config.heroIconName) && (
18
+ <div className="udb-onb-hero udb-onb-hero--xl" aria-hidden="true">
19
+ {renderBundledIcon(config.heroIconName, 'udb-onb-icon--xl')}
20
+ </div>
21
+ )}
22
+ <div className="udb-onb-welcome__group">
23
+ {config.title && <h1 className="udb-onb-welcome__title">{config.title}</h1>}
24
+ {config.subtitle && <p className="udb-onb-welcome__subtitle">{config.subtitle}</p>}
25
+ {config.highlight && <p className="udb-onb-welcome__highlight">{config.highlight}</p>}
26
+ </div>
27
+ <PrimaryButton onClick={config.onCta ?? onNext} loading={isLoading}>
28
+ {config.cta ?? labels.continueAction}
29
+ </PrimaryButton>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,28 @@
1
+ export { WelcomeScreen } from './WelcomeScreen';
2
+ export type { WelcomeScreenProps } from './WelcomeScreen';
3
+ export { GoalQuestionScreen } from './GoalQuestionScreen';
4
+ export type { GoalQuestionScreenProps } from './GoalQuestionScreen';
5
+ export { PainPointsScreen } from './PainPointsScreen';
6
+ export type { PainPointsScreenProps } from './PainPointsScreen';
7
+ export { SocialProofScreen } from './SocialProofScreen';
8
+ export type { SocialProofScreenProps } from './SocialProofScreen';
9
+ export { TinderCardsScreen } from './TinderCardsScreen';
10
+ export type { TinderCardsScreenProps } from './TinderCardsScreen';
11
+ export { PersonalizedSolutionScreen } from './PersonalizedSolutionScreen';
12
+ export type { PersonalizedSolutionScreenProps } from './PersonalizedSolutionScreen';
13
+ export { ComparisonScreen } from './ComparisonScreen';
14
+ export type { ComparisonScreenProps } from './ComparisonScreen';
15
+ export { PreferencesScreen } from './PreferencesScreen';
16
+ export type { PreferencesScreenProps } from './PreferencesScreen';
17
+ export { PermissionPrimingScreen } from './PermissionPrimingScreen';
18
+ export type { PermissionPrimingScreenProps } from './PermissionPrimingScreen';
19
+ export { ProcessingScreen } from './ProcessingScreen';
20
+ export type { ProcessingScreenProps } from './ProcessingScreen';
21
+ export { AppDemoScreen } from './AppDemoScreen';
22
+ export type { AppDemoScreenProps } from './AppDemoScreen';
23
+ export { ValueDeliveryScreen } from './ValueDeliveryScreen';
24
+ export type { ValueDeliveryScreenProps } from './ValueDeliveryScreen';
25
+ export { AccountGateScreen } from './AccountGateScreen';
26
+ export type { AccountGateScreenProps } from './AccountGateScreen';
27
+ export { PaywallScreen } from './PaywallScreen';
28
+ export type { PaywallScreenProps } from './PaywallScreen';
@@ -0,0 +1,7 @@
1
+ export { useOnboardingProgress } from './useOnboardingProgress';
2
+ export type { UseOnboardingProgressReturn } from './useOnboardingProgress';
3
+ export { usePermissionPriming } from './usePermissionPriming';
4
+ export type { UsePermissionPrimingReturn } from './usePermissionPriming';
5
+ export { useOnboardingAnalytics } from './useOnboardingAnalytics';
6
+ export type { UseOnboardingAnalyticsReturn } from './useOnboardingAnalytics';
7
+ export { useReducedMotion } from './useReducedMotion';
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useOnboardingAnalytics } from './useOnboardingAnalytics';
4
+ import type { OnboardingAnalyticsEvent } from '../types/config';
5
+
6
+ const event: OnboardingAnalyticsEvent = {
7
+ name: 'onboarding_step_completed',
8
+ stepId: 'goal',
9
+ stepType: 'goal-question',
10
+ index: 1,
11
+ timestamp: 0,
12
+ };
13
+
14
+ describe('useOnboardingAnalytics', () => {
15
+ it('fires events through the custom tracker when enabled', () => {
16
+ const tracker = vi.fn();
17
+ const { result } = renderHook(() =>
18
+ useOnboardingAnalytics({ trackEvents: true, provider: 'custom', customTracker: tracker }),
19
+ );
20
+ result.current.trackEvent(event);
21
+ expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
22
+ });
23
+
24
+ it('trackFunnel is an alias of trackEvent (same dispatch)', () => {
25
+ const tracker = vi.fn();
26
+ const { result } = renderHook(() =>
27
+ useOnboardingAnalytics({ trackEvents: true, provider: 'custom', customTracker: tracker }),
28
+ );
29
+ result.current.trackFunnel(event);
30
+ expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
31
+ });
32
+
33
+ it('never dispatches when trackEvents is false', () => {
34
+ const tracker = vi.fn();
35
+ const { result } = renderHook(() =>
36
+ useOnboardingAnalytics({ trackEvents: false, provider: 'custom', customTracker: tracker }),
37
+ );
38
+ result.current.trackEvent(event);
39
+ expect(tracker).not.toHaveBeenCalled();
40
+ expect(result.current.enabled).toBe(false);
41
+ });
42
+
43
+ it('never dispatches without a config at all', () => {
44
+ const { result } = renderHook(() => useOnboardingAnalytics(undefined));
45
+ expect(() => result.current.trackEvent(event)).not.toThrow();
46
+ expect(result.current.enabled).toBe(false);
47
+ });
48
+
49
+ it('keeps callback identity stable across re-renders', () => {
50
+ const tracker = vi.fn();
51
+ const { result, rerender } = renderHook(() =>
52
+ useOnboardingAnalytics({ trackEvents: true, provider: 'custom', customTracker: tracker }),
53
+ );
54
+ const first = result.current.trackEvent;
55
+ rerender();
56
+ expect(result.current.trackEvent).toBe(first);
57
+ });
58
+
59
+ it('survives a throwing tracker (analytics is an observation channel)', () => {
60
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
61
+ const { result } = renderHook(() =>
62
+ useOnboardingAnalytics({
63
+ trackEvents: true,
64
+ provider: 'custom',
65
+ customTracker: () => {
66
+ throw new Error('boom');
67
+ },
68
+ }),
69
+ );
70
+ expect(() => result.current.trackEvent(event)).not.toThrow();
71
+ expect(error).toHaveBeenCalled();
72
+ error.mockRestore();
73
+ });
74
+ });