@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.
- package/README.md +47 -73
- package/package.json +15 -7
- package/src/config/DashboardConfig.ts +19 -68
- package/src/domains/analytics/components/index.ts +0 -6
- package/src/domains/analytics/hooks/index.ts +0 -6
- package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
- package/src/domains/analytics/index.ts +0 -10
- package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
- package/src/domains/analytics/services/PerformanceService.ts +27 -138
- package/src/domains/analytics/services/index.ts +0 -4
- package/src/domains/analytics/types/analytics.ts +100 -270
- package/src/domains/analytics/types/index.ts +0 -6
- package/src/domains/analytics/utils/analytics.test.ts +2 -9
- package/src/domains/analytics/utils/analytics.ts +1 -22
- package/src/domains/analytics/utils/index.ts +0 -6
- package/src/domains/calendar/config.ts +0 -7
- package/src/domains/calendar/hooks/index.ts +0 -4
- package/src/domains/calendar/hooks/useCalendar.ts +7 -27
- package/src/domains/calendar/index.ts +0 -4
- package/src/domains/calendar/services/CalendarService.ts +0 -21
- package/src/domains/calendar/services/index.ts +0 -4
- package/src/domains/calendar/types/calendar.types.ts +9 -53
- package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
- package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
- package/src/domains/calendar/utils/index.ts +0 -26
- package/src/domains/layouts/components/index.ts +0 -4
- package/src/domains/layouts/hooks/dashboard.ts +0 -21
- package/src/domains/layouts/hooks/index.ts +0 -4
- package/src/domains/layouts/index.ts +0 -6
- package/src/domains/layouts/theme/default.ts +0 -12
- package/src/domains/layouts/theme/index.ts +0 -4
- package/src/domains/layouts/theme/presets.ts +0 -9
- package/src/domains/layouts/theme/utils.ts +0 -15
- package/src/domains/layouts/types/index.ts +0 -4
- package/src/domains/layouts/types/layout.ts +18 -56
- package/src/domains/layouts/types/notification.ts +4 -17
- package/src/domains/layouts/types/sidebar.ts +8 -28
- package/src/domains/layouts/types/theme.ts +23 -59
- package/src/domains/layouts/types/user.ts +9 -30
- package/src/domains/layouts/utils/dashboard.ts +4 -48
- package/src/domains/layouts/utils/index.ts +0 -4
- package/src/domains/modules/build.tsx +2 -16
- package/src/domains/modules/types.ts +8 -23
- package/src/domains/onboarding/calc/calc.test.ts +173 -0
- package/src/domains/onboarding/calc/card-stack-motion-curve.ts +27 -0
- package/src/domains/onboarding/calc/count-with-locale.ts +8 -0
- package/src/domains/onboarding/calc/duration-equal-split.ts +12 -0
- package/src/domains/onboarding/calc/elapsed-millis.ts +6 -0
- package/src/domains/onboarding/calc/index.ts +36 -0
- package/src/domains/onboarding/calc/progress-percentage.ts +27 -0
- package/src/domains/onboarding/calc/slider-value-bounds.ts +34 -0
- package/src/domains/onboarding/calc/step-index-clamp.ts +19 -0
- package/src/domains/onboarding/calc/swipe-gesture-curve.ts +54 -0
- package/src/domains/onboarding/components/AnimationWrapper.tsx +69 -0
- package/src/domains/onboarding/components/OnboardingErrorBoundary.tsx +61 -0
- package/src/domains/onboarding/components/OnboardingFlow.test.tsx +459 -0
- package/src/domains/onboarding/components/OnboardingFlow.tsx +313 -0
- package/src/domains/onboarding/components/OnboardingIntl.tsx +98 -0
- package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
- package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
- package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
- package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
- package/src/domains/onboarding/components/SwipeCard.test.tsx +49 -0
- package/src/domains/onboarding/components/SwipeCard.tsx +88 -0
- package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
- package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +79 -0
- package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +33 -0
- package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +49 -0
- package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +59 -0
- package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
- package/src/domains/onboarding/components/screens/PaywallScreen.tsx +122 -0
- package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +98 -0
- package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +40 -0
- package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +180 -0
- package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +63 -0
- package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +87 -0
- package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +91 -0
- package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +69 -0
- package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +32 -0
- package/src/domains/onboarding/components/screens/index.ts +28 -0
- package/src/domains/onboarding/hooks/index.ts +7 -0
- package/src/domains/onboarding/hooks/useOnboardingAnalytics.test.tsx +74 -0
- package/src/domains/onboarding/hooks/useOnboardingAnalytics.ts +49 -0
- package/src/domains/onboarding/hooks/useOnboardingProgress.test.tsx +150 -0
- package/src/domains/onboarding/hooks/useOnboardingProgress.ts +249 -0
- package/src/domains/onboarding/hooks/usePermissionPriming.ts +45 -0
- package/src/domains/onboarding/hooks/useReducedMotion.ts +19 -0
- package/src/domains/onboarding/index.ts +153 -0
- package/src/domains/onboarding/predicates/has-handler.ts +21 -0
- package/src/domains/onboarding/predicates/icon-name-resolves.ts +97 -0
- package/src/domains/onboarding/predicates/index.ts +26 -0
- package/src/domains/onboarding/predicates/permission-granted.ts +21 -0
- package/src/domains/onboarding/predicates/permission-supports.ts +56 -0
- package/src/domains/onboarding/predicates/predicates.test.ts +188 -0
- package/src/domains/onboarding/predicates/screen-emits-data.ts +13 -0
- package/src/domains/onboarding/predicates/storage-available.ts +14 -0
- package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +92 -0
- package/src/domains/onboarding/templates/art-onboarding.ts +71 -0
- package/src/domains/onboarding/templates/health-onboarding.ts +92 -0
- package/src/domains/onboarding/templates/index.ts +6 -0
- package/src/domains/onboarding/templates/platform-onboarding.ts +201 -0
- package/src/domains/onboarding/templates/video-onboarding.ts +95 -0
- package/src/domains/onboarding/test/setup.ts +23 -0
- package/src/domains/onboarding/types/config.ts +57 -0
- package/src/domains/onboarding/types/index.ts +10 -0
- package/src/domains/onboarding/types/labels.ts +49 -0
- package/src/domains/onboarding/types/screens.ts +185 -0
- package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
- package/src/domains/onboarding/utils/analytics-dispatch.ts +73 -0
- package/src/domains/onboarding/utils/default-storage-key.ts +1 -0
- package/src/domains/onboarding/utils/index.ts +12 -0
- package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
- package/src/domains/onboarding/utils/local-storage-bridge.ts +84 -0
- package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
- package/src/domains/onboarding/utils/permission-bridge.ts +87 -0
- package/src/domains/onboarding/validation/flow-structure-check.ts +22 -0
- package/src/domains/onboarding/validation/index.ts +9 -0
- package/src/domains/onboarding/validation/permission-status-check.ts +18 -0
- package/src/domains/onboarding/validation/screen-config-check.ts +100 -0
- package/src/domains/onboarding/validation/stored-progress-check.ts +59 -0
- package/src/domains/onboarding/validation/validation.test.ts +170 -0
- package/src/domains/permissions/hasPermission.ts +0 -8
- package/src/domains/permissions/types.ts +0 -20
- package/src/domains/settings/components/index.ts +0 -4
- package/src/domains/settings/hooks/index.ts +0 -4
- package/src/domains/settings/hooks/useSettings.ts +1 -26
- package/src/domains/settings/index.ts +0 -6
- package/src/domains/settings/types/index.ts +0 -4
- package/src/domains/settings/types/settings.ts +23 -67
- package/src/domains/settings/utils/index.ts +0 -4
- package/src/domains/settings/utils/settings.ts +0 -45
- package/src/styles/dashboard.contract.test.ts +1 -0
- 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
|
+
});
|