@umituz/web-dashboard 5.0.1 → 6.1.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/package.json +19 -17
- package/src/domains/onboarding/calc/calc.test.ts +181 -0
- package/src/domains/onboarding/calc/card-stack-motion-curve.ts +33 -0
- package/src/domains/onboarding/calc/count-with-locale.ts +16 -0
- package/src/domains/onboarding/calc/duration-equal-split.ts +24 -0
- package/src/domains/onboarding/calc/elapsed-millis.ts +12 -0
- package/src/domains/onboarding/calc/index.ts +36 -0
- package/src/domains/onboarding/calc/progress-percentage.ts +37 -0
- package/src/domains/onboarding/calc/slider-value-bounds.ts +44 -0
- package/src/domains/onboarding/calc/step-index-clamp.ts +28 -0
- package/src/domains/onboarding/calc/swipe-gesture-curve.ts +67 -0
- package/src/domains/onboarding/components/OnboardingFlow.test.tsx +481 -0
- package/src/domains/onboarding/components/OnboardingFlow.tsx +361 -0
- package/src/domains/onboarding/components/OnboardingIntl.tsx +108 -0
- package/src/domains/onboarding/components/hooks/index.ts +7 -0
- package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.test.tsx +74 -0
- package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.ts +62 -0
- package/src/domains/onboarding/components/hooks/useOnboardingProgress.test.tsx +150 -0
- package/src/domains/onboarding/components/hooks/useOnboardingProgress.ts +260 -0
- package/src/domains/onboarding/components/hooks/usePermissionPriming.ts +52 -0
- package/src/domains/onboarding/components/hooks/useReducedMotion.ts +24 -0
- package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +89 -0
- package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +39 -0
- package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +59 -0
- package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +56 -0
- package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
- package/src/domains/onboarding/components/screens/PaywallScreen.tsx +138 -0
- package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +113 -0
- package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +53 -0
- package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +194 -0
- package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +79 -0
- package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +116 -0
- package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +108 -0
- package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +85 -0
- package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +52 -0
- package/src/domains/onboarding/components/screens/index.ts +28 -0
- package/src/domains/onboarding/components/shared/AnimationWrapper.tsx +73 -0
- package/src/domains/onboarding/components/shared/OnboardingErrorBoundary.tsx +81 -0
- package/src/domains/onboarding/components/shared/OptionCard.tsx +73 -0
- package/src/domains/onboarding/components/shared/PrimaryButton.tsx +58 -0
- package/src/domains/onboarding/components/shared/ProgressBar.tsx +51 -0
- package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +36 -0
- package/src/domains/onboarding/components/shared/ScreenContainer.tsx +29 -0
- package/src/domains/onboarding/components/shared/SwipeCard.test.tsx +51 -0
- package/src/domains/onboarding/components/shared/SwipeCard.tsx +106 -0
- package/src/domains/onboarding/components/shared/index.ts +16 -0
- package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +20 -0
- package/src/domains/onboarding/index.ts +182 -0
- package/src/domains/onboarding/onboarding.css +1 -0
- package/src/domains/onboarding/predicates/has-handler.ts +25 -0
- package/src/domains/onboarding/predicates/icon-name-resolves.ts +113 -0
- package/src/domains/onboarding/predicates/index.ts +26 -0
- package/src/domains/onboarding/predicates/permission-granted.ts +26 -0
- package/src/domains/onboarding/predicates/permission-supports.ts +66 -0
- package/src/domains/onboarding/predicates/predicates.test.ts +202 -0
- package/src/domains/onboarding/predicates/screen-emits-data.ts +17 -0
- package/src/domains/onboarding/predicates/storage-available.ts +22 -0
- package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +112 -0
- package/src/domains/onboarding/templates/art-onboarding.ts +81 -0
- package/src/domains/onboarding/templates/health-onboarding.ts +97 -0
- package/src/domains/onboarding/templates/index.ts +6 -0
- package/src/domains/onboarding/templates/platform-onboarding.ts +210 -0
- package/src/domains/onboarding/templates/video-onboarding.ts +100 -0
- package/src/domains/onboarding/test/setup.ts +30 -0
- package/src/domains/onboarding/theme/cn.ts +10 -0
- package/src/domains/onboarding/theme/index.css +131 -0
- package/src/domains/onboarding/theme/index.ts +12 -0
- package/src/domains/onboarding/theme/tokens.ts +122 -0
- package/src/domains/onboarding/types/config.ts +68 -0
- package/src/domains/onboarding/types/index.ts +10 -0
- package/src/domains/onboarding/types/labels.ts +83 -0
- package/src/domains/onboarding/types/screens.ts +193 -0
- package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
- package/src/domains/onboarding/utils/analytics-dispatch.ts +92 -0
- package/src/domains/onboarding/utils/default-storage-key.ts +2 -0
- package/src/domains/onboarding/utils/index.ts +13 -0
- package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
- package/src/domains/onboarding/utils/local-storage-bridge.ts +104 -0
- package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
- package/src/domains/onboarding/utils/permission-bridge.ts +95 -0
- package/src/domains/onboarding/validation/flow-structure-check.ts +28 -0
- package/src/domains/onboarding/validation/index.ts +9 -0
- package/src/domains/onboarding/validation/permission-status-check.ts +23 -0
- package/src/domains/onboarding/validation/screen-config-check.ts +105 -0
- package/src/domains/onboarding/validation/stored-progress-check.ts +68 -0
- package/src/domains/onboarding/validation/validation.test.ts +175 -0
- package/src/index.ts +0 -10
- package/src/styles/dashboard.contract.test.ts +1 -0
- package/src/styles/dashboard.css +0 -460
- package/src/domains/auth/components/AuthLayout.tsx +0 -99
- package/src/domains/auth/components/ForgotPasswordForm.tsx +0 -167
- package/src/domains/auth/components/LoginForm.tsx +0 -222
- package/src/domains/auth/components/RegisterForm.tsx +0 -281
- package/src/domains/auth/components/ResetPasswordForm.tsx +0 -173
- package/src/domains/auth/components/index.ts +0 -11
- package/src/domains/auth/components/shared/AuthErrorBanner.tsx +0 -25
- package/src/domains/auth/components/shared/EmailInput.tsx +0 -73
- package/src/domains/auth/components/shared/PasswordInput.tsx +0 -100
- package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +0 -63
- package/src/domains/auth/components/shared/SocialLoginButtons.tsx +0 -44
- package/src/domains/auth/components/shared/SocialProviderIcons.tsx +0 -48
- package/src/domains/auth/components/shared/index.ts +0 -20
- package/src/domains/auth/hooks/index.ts +0 -8
- package/src/domains/auth/hooks/useAuth.ts +0 -230
- package/src/domains/auth/index.ts +0 -62
- package/src/domains/auth/types/auth.ts +0 -302
- package/src/domains/auth/types/index.ts +0 -25
- package/src/domains/auth/utils/auth.test.ts +0 -188
- package/src/domains/auth/utils/auth.ts +0 -221
- package/src/domains/auth/utils/i18nKeys.ts +0 -109
- package/src/domains/auth/utils/index.ts +0 -29
- package/src/domains/auth/utils/secureToken.ts +0 -33
- package/src/domains/auth/utils/validationKeys.ts +0 -23
- package/src/domains/session/DemoLogin.tsx +0 -42
- package/src/domains/session/OrgSwitcher.tsx +0 -102
- package/src/domains/session/SessionContext.tsx +0 -150
- package/src/domains/session/inMemoryAuthAdapter.ts +0 -53
- package/src/domains/session/index.ts +0 -18
- package/src/domains/session/types.ts +0 -47
- package/src/domains/session/utils/i18nKeys.ts +0 -12
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../theme/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="flex flex-1 flex-col items-center justify-center gap-8 text-center">
|
|
28
|
+
{config.title && (
|
|
29
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
|
|
30
|
+
{config.title}
|
|
31
|
+
</h2>
|
|
32
|
+
)}
|
|
33
|
+
<ul className="flex w-full max-w-md flex-col gap-3" aria-live="polite">
|
|
34
|
+
{config.steps.map((step, i) => {
|
|
35
|
+
const isDone = i < currentStep;
|
|
36
|
+
const isActive = i === currentStep;
|
|
37
|
+
return (
|
|
38
|
+
<li
|
|
39
|
+
key={`${step}-${i}`}
|
|
40
|
+
className={cn(
|
|
41
|
+
'flex items-center gap-3 rounded-lg border p-3 text-left text-sm',
|
|
42
|
+
isDone
|
|
43
|
+
? 'border-[rgb(var(--uob-success))] bg-[rgb(var(--uob-success))]/10'
|
|
44
|
+
: isActive
|
|
45
|
+
? 'border-[rgb(var(--uob-primary-500))] bg-[rgb(var(--uob-primary-50))]'
|
|
46
|
+
: 'border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))]',
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
<span
|
|
50
|
+
className={cn(
|
|
51
|
+
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full',
|
|
52
|
+
isDone
|
|
53
|
+
? 'bg-[rgb(var(--uob-success))] text-white'
|
|
54
|
+
: isActive
|
|
55
|
+
? 'bg-[rgb(var(--uob-primary-500))] text-white'
|
|
56
|
+
: 'bg-[rgb(var(--uob-border))] text-[rgb(var(--uob-text-muted))]',
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{isDone ? <Check className="h-4 w-4" /> : <span className="text-xs">{i + 1}</span>}
|
|
60
|
+
</span>
|
|
61
|
+
<span className="text-[rgb(var(--uob-text-primary))]">{step}</span>
|
|
62
|
+
<AnimatePresence>
|
|
63
|
+
{isActive && (
|
|
64
|
+
<motion.span
|
|
65
|
+
initial={{ opacity: 0 }}
|
|
66
|
+
animate={{ opacity: 1 }}
|
|
67
|
+
exit={{ opacity: 0 }}
|
|
68
|
+
className="ml-auto h-2 w-2 animate-pulse rounded-full bg-[rgb(var(--uob-primary-500))]"
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
</AnimatePresence>
|
|
73
|
+
</li>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</ul>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { Star } from 'lucide-react';
|
|
4
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
5
|
+
import { cn } from '../../theme/cn';
|
|
6
|
+
import { useOnboardingIntl } from '../OnboardingIntl';
|
|
7
|
+
import type { SocialProofScreenConfig } from '../../types/screens';
|
|
8
|
+
|
|
9
|
+
export interface SocialProofScreenProps {
|
|
10
|
+
config: SocialProofScreenConfig;
|
|
11
|
+
onNext: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SocialProofScreen({ config, onNext }: SocialProofScreenProps) {
|
|
15
|
+
const { labels } = useOnboardingIntl();
|
|
16
|
+
const [index, setIndex] = useState(0);
|
|
17
|
+
const total = config.testimonials.length;
|
|
18
|
+
const current = config.testimonials[index];
|
|
19
|
+
|
|
20
|
+
function go(delta: number) {
|
|
21
|
+
setIndex((i) => (i + delta + total) % total);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex flex-1 flex-col gap-8">
|
|
26
|
+
{config.title && (
|
|
27
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
|
|
28
|
+
{config.title}
|
|
29
|
+
</h2>
|
|
30
|
+
)}
|
|
31
|
+
<div className="flex flex-1 flex-col items-center justify-center">
|
|
32
|
+
<AnimatePresence mode="wait">
|
|
33
|
+
<motion.blockquote
|
|
34
|
+
key={`${current.name}-${index}`}
|
|
35
|
+
initial={{ opacity: 0, y: 12 }}
|
|
36
|
+
animate={{ opacity: 1, y: 0 }}
|
|
37
|
+
exit={{ opacity: 0, y: -12 }}
|
|
38
|
+
transition={{ duration: 0.3 }}
|
|
39
|
+
className="flex max-w-xl flex-col items-center gap-4 rounded-2xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-8 text-center"
|
|
40
|
+
>
|
|
41
|
+
{current.rating !== undefined && (
|
|
42
|
+
<div className="flex items-center gap-1" aria-label={`Rated ${current.rating} out of 5`}>
|
|
43
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
44
|
+
<Star
|
|
45
|
+
key={i}
|
|
46
|
+
className={cn(
|
|
47
|
+
'h-5 w-5',
|
|
48
|
+
i < current.rating!
|
|
49
|
+
? 'fill-current text-[rgb(var(--uob-warning))]'
|
|
50
|
+
: 'text-[rgb(var(--uob-border))]',
|
|
51
|
+
)}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
<p className="text-lg text-[rgb(var(--uob-text-primary))]">“{current.content}”</p>
|
|
57
|
+
<footer className="flex flex-col items-center gap-1">
|
|
58
|
+
{current.avatar && (
|
|
59
|
+
<img
|
|
60
|
+
src={current.avatar}
|
|
61
|
+
alt=""
|
|
62
|
+
className="h-12 w-12 rounded-full object-cover"
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
<cite className="text-sm font-semibold not-italic text-[rgb(var(--uob-text-primary))]">
|
|
66
|
+
{current.name}
|
|
67
|
+
</cite>
|
|
68
|
+
<span className="text-sm text-[rgb(var(--uob-text-muted))]">{current.role}</span>
|
|
69
|
+
</footer>
|
|
70
|
+
</motion.blockquote>
|
|
71
|
+
</AnimatePresence>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex items-center justify-between gap-4">
|
|
74
|
+
<div className="flex gap-1.5" role="tablist" aria-label="Testimonials">
|
|
75
|
+
{config.testimonials.map((t, i) => (
|
|
76
|
+
<button
|
|
77
|
+
key={`${t.name}-${i}`}
|
|
78
|
+
type="button"
|
|
79
|
+
role="tab"
|
|
80
|
+
aria-selected={i === index}
|
|
81
|
+
aria-label={`${t.name}: ${t.content}`}
|
|
82
|
+
onClick={() => setIndex(i)}
|
|
83
|
+
className={cn(
|
|
84
|
+
'h-2 w-2 rounded-full transition-all',
|
|
85
|
+
i === index
|
|
86
|
+
? 'w-6 bg-[rgb(var(--uob-primary-500))]'
|
|
87
|
+
: 'bg-[rgb(var(--uob-border))]',
|
|
88
|
+
)}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex gap-2">
|
|
93
|
+
{total > 1 && (
|
|
94
|
+
<>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => go(-1)}
|
|
98
|
+
className="rounded-md border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] px-3 py-1 text-sm hover:bg-[rgb(var(--uob-surface-muted))]"
|
|
99
|
+
>
|
|
100
|
+
{labels.previous}
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => go(1)}
|
|
105
|
+
className="rounded-md border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] px-3 py-1 text-sm hover:bg-[rgb(var(--uob-surface-muted))]"
|
|
106
|
+
>
|
|
107
|
+
{labels.nextAction}
|
|
108
|
+
</button>
|
|
109
|
+
</>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
3
|
+
import { SwipeCard } from '../shared/SwipeCard';
|
|
4
|
+
import { PrimaryButton } from '../shared/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
|
+
// Refs make decision handlers always see the latest state without recreating callbacks
|
|
26
|
+
// on every index change — which would have remounted SwipeCard and lost gestures.
|
|
27
|
+
const indexRef = useRef(index);
|
|
28
|
+
const likedRef = useRef(liked);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
indexRef.current = index;
|
|
31
|
+
likedRef.current = liked;
|
|
32
|
+
}, [index, liked]);
|
|
33
|
+
|
|
34
|
+
const handleDecision = useCallback(
|
|
35
|
+
(likedStatement: boolean) => {
|
|
36
|
+
if (likedStatement) {
|
|
37
|
+
setLiked((prev) => [...prev, config.statements[indexRef.current].id]);
|
|
38
|
+
}
|
|
39
|
+
setIndex((i) => i + 1);
|
|
40
|
+
},
|
|
41
|
+
[config.statements],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const onSwipeLeft = useCallback(() => handleDecision(false), [handleDecision]);
|
|
45
|
+
const onSwipeRight = useCallback(() => handleDecision(true), [handleDecision]);
|
|
46
|
+
|
|
47
|
+
if (!current) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-6">
|
|
50
|
+
<p className="text-lg text-[rgb(var(--uob-text-primary))]">{labels.allDone}</p>
|
|
51
|
+
<PrimaryButton onClick={() => onNext({ liked: likedRef.current })}>
|
|
52
|
+
{config.cta ?? labels.continueAction}
|
|
53
|
+
</PrimaryButton>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex flex-1 flex-col gap-6">
|
|
60
|
+
{config.title && (
|
|
61
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
|
|
62
|
+
{config.title}
|
|
63
|
+
</h2>
|
|
64
|
+
)}
|
|
65
|
+
<div className="flex flex-1 items-center justify-center">
|
|
66
|
+
<AnimatePresence mode="wait">
|
|
67
|
+
<motion.div
|
|
68
|
+
key={current.id}
|
|
69
|
+
initial={{ opacity: getCardEnterInitial().opacity, scale: getCardEnterInitial().scale }}
|
|
70
|
+
animate={{ opacity: getCardEnterAnimate().opacity, scale: getCardEnterAnimate().scale }}
|
|
71
|
+
exit={{ opacity: getCardEnterExit().opacity, scale: getCardEnterExit().scale }}
|
|
72
|
+
transition={{ duration: getCardTransitionDurationMs() / 1000 }}
|
|
73
|
+
className="w-full"
|
|
74
|
+
>
|
|
75
|
+
<SwipeCard
|
|
76
|
+
text={current.text}
|
|
77
|
+
category={current.category}
|
|
78
|
+
onSwipeLeft={onSwipeLeft}
|
|
79
|
+
onSwipeRight={onSwipeRight}
|
|
80
|
+
/>
|
|
81
|
+
</motion.div>
|
|
82
|
+
</AnimatePresence>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex items-center justify-between gap-2">
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={onSwipeLeft}
|
|
88
|
+
className="flex-1 rounded-xl border-2 border-[rgb(var(--uob-error))] py-3 font-semibold text-[rgb(var(--uob-error))] transition-colors hover:bg-[rgb(var(--uob-error))] hover:text-white"
|
|
89
|
+
>
|
|
90
|
+
{labels.disagree}
|
|
91
|
+
</button>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={onSwipeRight}
|
|
95
|
+
className="flex-1 rounded-xl border-2 border-[rgb(var(--uob-success))] py-3 font-semibold text-[rgb(var(--uob-success))] transition-colors hover:bg-[rgb(var(--uob-success))] hover:text-white"
|
|
96
|
+
>
|
|
97
|
+
{labels.agree}
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
<p
|
|
101
|
+
className="text-center text-sm text-[rgb(var(--uob-text-muted))]"
|
|
102
|
+
aria-live="polite"
|
|
103
|
+
>
|
|
104
|
+
{formatLabel(labels.countOfPattern, { current: index + 1, total })}
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Sparkles } from 'lucide-react';
|
|
3
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
4
|
+
import { useOnboardingIntl } from '../OnboardingIntl';
|
|
5
|
+
import type { ValueDeliveryScreenConfig } from '../../types/screens';
|
|
6
|
+
|
|
7
|
+
export interface ValueDeliveryScreenProps<TResult = unknown> {
|
|
8
|
+
config: ValueDeliveryScreenConfig<TResult>;
|
|
9
|
+
onNext: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shows the artifact produced earlier in the flow.
|
|
14
|
+
* The default body is a JSON dump of `result` — deliberately generic. Screens are
|
|
15
|
+
* expected to be customized per product; this default keeps templates runnable.
|
|
16
|
+
*/
|
|
17
|
+
export function ValueDeliveryScreen<TResult = unknown>({
|
|
18
|
+
config,
|
|
19
|
+
onNext,
|
|
20
|
+
}: ValueDeliveryScreenProps<TResult>) {
|
|
21
|
+
const { labels } = useOnboardingIntl();
|
|
22
|
+
const [copied, setCopied] = useState(false);
|
|
23
|
+
const copiedTimer = useRef<number | null>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(
|
|
26
|
+
() => () => {
|
|
27
|
+
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
|
|
28
|
+
},
|
|
29
|
+
[],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
async function handleShare() {
|
|
33
|
+
if (!config.shareConfig) return;
|
|
34
|
+
const { text, url } = config.shareConfig;
|
|
35
|
+
try {
|
|
36
|
+
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
|
37
|
+
await navigator.share({ text, url });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
41
|
+
await navigator.clipboard.writeText(url ?? text);
|
|
42
|
+
setCopied(true);
|
|
43
|
+
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
|
|
44
|
+
copiedTimer.current = window.setTimeout(() => setCopied(false), 2000);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// navigator.share rejects with AbortError when the user dismisses the sheet —
|
|
48
|
+
// that is an expected outcome, not a failure worth logging.
|
|
49
|
+
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
|
50
|
+
|
|
51
|
+
console.error('[web-onboarding] share failed', error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
|
58
|
+
<div
|
|
59
|
+
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-[rgb(var(--uob-primary-50))] text-[rgb(var(--uob-primary-500))]"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
>
|
|
62
|
+
<Sparkles className="h-10 w-10" />
|
|
63
|
+
</div>
|
|
64
|
+
<h2 className="text-3xl font-bold text-[rgb(var(--uob-text-primary))]">{config.title}</h2>
|
|
65
|
+
<div className="w-full max-w-md rounded-2xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-6 text-left">
|
|
66
|
+
<pre className="overflow-x-auto whitespace-pre-wrap text-sm text-[rgb(var(--uob-text-primary))]">
|
|
67
|
+
{JSON.stringify(config.result, null, 2)}
|
|
68
|
+
</pre>
|
|
69
|
+
</div>
|
|
70
|
+
{config.shareConfig && (
|
|
71
|
+
<div className="flex flex-col items-center gap-1 text-sm text-[rgb(var(--uob-text-muted))]">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={handleShare}
|
|
75
|
+
className="rounded-xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] px-4 py-2 text-sm font-semibold text-[rgb(var(--uob-text-primary))] transition-colors hover:border-[rgb(var(--uob-primary-500))]"
|
|
76
|
+
>
|
|
77
|
+
{copied ? labels.copied : labels.share}
|
|
78
|
+
</button>
|
|
79
|
+
<span>{config.shareConfig.text}</span>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
2
|
+
import { renderBundledIcon } from '../shared/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="flex flex-1 flex-col items-center justify-center gap-8 text-center">
|
|
17
|
+
{doesIconNameResolve(config.heroIconName) && (
|
|
18
|
+
<div
|
|
19
|
+
className="flex h-24 w-24 items-center justify-center rounded-3xl bg-[rgb(var(--uob-primary-50))] text-[rgb(var(--uob-primary-500))]"
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
>
|
|
22
|
+
{renderBundledIcon(config.heroIconName, 'h-12 w-12')}
|
|
23
|
+
</div>
|
|
24
|
+
)}
|
|
25
|
+
<div className="flex flex-col gap-3">
|
|
26
|
+
{config.title && (
|
|
27
|
+
<h1 className="text-4xl font-bold text-[rgb(var(--uob-text-primary))] sm:text-5xl">
|
|
28
|
+
{config.title}
|
|
29
|
+
</h1>
|
|
30
|
+
)}
|
|
31
|
+
{config.subtitle && (
|
|
32
|
+
<p className="text-lg text-[rgb(var(--uob-text-muted))]">{config.subtitle}</p>
|
|
33
|
+
)}
|
|
34
|
+
{config.highlight && (
|
|
35
|
+
<p className="text-sm font-semibold uppercase tracking-wide text-[rgb(var(--uob-primary-500))]">
|
|
36
|
+
{config.highlight}
|
|
37
|
+
</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
{config.cta && (
|
|
41
|
+
<PrimaryButton onClick={config.onCta ?? onNext} loading={isLoading}>
|
|
42
|
+
{config.cta}
|
|
43
|
+
</PrimaryButton>
|
|
44
|
+
)}
|
|
45
|
+
{!config.cta && (
|
|
46
|
+
<PrimaryButton onClick={config.onCta ?? onNext} loading={isLoading}>
|
|
47
|
+
{labels.continueAction}
|
|
48
|
+
</PrimaryButton>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -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,73 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
3
|
+
import { useReducedMotion } from '../hooks/useReducedMotion';
|
|
4
|
+
|
|
5
|
+
export type ScreenTransition = 'slide' | 'fade' | 'scale' | 'none';
|
|
6
|
+
|
|
7
|
+
export interface AnimationWrapperProps {
|
|
8
|
+
stepKey: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
transition?: ScreenTransition;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const variantsByTransition: Record<
|
|
15
|
+
Exclude<ScreenTransition, 'none'>,
|
|
16
|
+
{
|
|
17
|
+
initial: { opacity: number; x?: number; y?: number; scale?: number };
|
|
18
|
+
animate: { opacity: number; x?: number; y?: number; scale?: number };
|
|
19
|
+
exit: { opacity: number; x?: number; y?: number; scale?: number };
|
|
20
|
+
}
|
|
21
|
+
> = {
|
|
22
|
+
slide: {
|
|
23
|
+
initial: { opacity: 0, x: 40 },
|
|
24
|
+
animate: { opacity: 1, x: 0 },
|
|
25
|
+
exit: { opacity: 0, x: -40 },
|
|
26
|
+
},
|
|
27
|
+
fade: {
|
|
28
|
+
initial: { opacity: 0 },
|
|
29
|
+
animate: { opacity: 1 },
|
|
30
|
+
exit: { opacity: 0 },
|
|
31
|
+
},
|
|
32
|
+
scale: {
|
|
33
|
+
initial: { opacity: 0, scale: 0.96 },
|
|
34
|
+
animate: { opacity: 1, scale: 1 },
|
|
35
|
+
exit: { opacity: 0, scale: 1.04 },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wraps children with a transition keyed on stepKey.
|
|
41
|
+
* Respects `prefers-reduced-motion` by disabling transitions.
|
|
42
|
+
*/
|
|
43
|
+
export function AnimationWrapper({
|
|
44
|
+
stepKey,
|
|
45
|
+
children,
|
|
46
|
+
transition = 'slide',
|
|
47
|
+
className,
|
|
48
|
+
}: AnimationWrapperProps) {
|
|
49
|
+
const reduced = useReducedMotion();
|
|
50
|
+
const effective: ScreenTransition = reduced ? 'none' : transition;
|
|
51
|
+
if (effective === 'none') {
|
|
52
|
+
return (
|
|
53
|
+
<div key={stepKey} className={className}>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const variants = variantsByTransition[effective];
|
|
59
|
+
return (
|
|
60
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
61
|
+
<motion.div
|
|
62
|
+
key={stepKey}
|
|
63
|
+
initial={variants.initial}
|
|
64
|
+
animate={variants.animate}
|
|
65
|
+
exit={variants.exit}
|
|
66
|
+
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
67
|
+
className={className}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</motion.div>
|
|
71
|
+
</AnimatePresence>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Component, Fragment, type ContextType, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
import { AlertTriangle } from 'lucide-react';
|
|
3
|
+
import { PrimaryButton } from './PrimaryButton';
|
|
4
|
+
import { OnboardingIntlContext, type OnboardingIntl } from '../OnboardingIntl';
|
|
5
|
+
|
|
6
|
+
export interface OnboardingErrorBoundaryProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Called when the boundary trips — useful for analytics. */
|
|
9
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
10
|
+
/** Render a custom error UI. */
|
|
11
|
+
renderError?: (error: Error, retry: () => void) => ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface OnboardingErrorBoundaryState {
|
|
15
|
+
error: Error | null;
|
|
16
|
+
/** Bumped on retry so the children subtree fully remounts, not just re-renders. */
|
|
17
|
+
attempt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Catches render-time errors inside the onboarding flow.
|
|
22
|
+
* Critical path rule: a broken screen must never produce a blank white page.
|
|
23
|
+
* Retry clears the error AND remounts the children (a fresh subtree gets a
|
|
24
|
+
* fresh chance at rendering — deterministic throws aside).
|
|
25
|
+
*/
|
|
26
|
+
export class OnboardingErrorBoundary extends Component<
|
|
27
|
+
OnboardingErrorBoundaryProps,
|
|
28
|
+
OnboardingErrorBoundaryState
|
|
29
|
+
> {
|
|
30
|
+
static contextType = OnboardingIntlContext;
|
|
31
|
+
declare context: ContextType<typeof OnboardingIntlContext>;
|
|
32
|
+
|
|
33
|
+
state: OnboardingErrorBoundaryState = { error: null, attempt: 0 };
|
|
34
|
+
|
|
35
|
+
static getDerivedStateFromError(error: Error): OnboardingErrorBoundaryState {
|
|
36
|
+
return { error, attempt: 0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
40
|
+
this.props.onError?.(error, info);
|
|
41
|
+
|
|
42
|
+
console.error('[web-onboarding] render error', error, info.componentStack);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
retry = (): void => {
|
|
46
|
+
this.setState((prev) => ({ error: null, attempt: prev.attempt + 1 }));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
render(): ReactNode {
|
|
50
|
+
const { error, attempt } = this.state;
|
|
51
|
+
const intl: OnboardingIntl = this.context;
|
|
52
|
+
|
|
53
|
+
if (!error) {
|
|
54
|
+
return <Fragment key={attempt}>{this.props.children}</Fragment>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (this.props.renderError) return this.props.renderError(error, this.retry);
|
|
58
|
+
|
|
59
|
+
const { labels } = intl;
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
role="alert"
|
|
63
|
+
className="uob-flow mx-auto flex min-h-screen w-full max-w-md flex-col items-center justify-center gap-4 px-6 text-center"
|
|
64
|
+
>
|
|
65
|
+
<div
|
|
66
|
+
className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[rgb(var(--uob-error))]/10 text-[rgb(var(--uob-error))]"
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
>
|
|
69
|
+
<AlertTriangle className="h-8 w-8" />
|
|
70
|
+
</div>
|
|
71
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))]">
|
|
72
|
+
{labels.somethingWentWrong}
|
|
73
|
+
</h2>
|
|
74
|
+
<p className="text-sm text-[rgb(var(--uob-text-muted))]">
|
|
75
|
+
{error.message || labels.unexpectedError}
|
|
76
|
+
</p>
|
|
77
|
+
<PrimaryButton onClick={this.retry}>{labels.tryAgain}</PrimaryButton>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|