@umituz/web-dashboard 6.0.0 → 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 -7
- 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/styles/dashboard.contract.test.ts +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import { OptionCard } from '../shared/OptionCard';
|
|
3
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
4
|
+
import { QuestionWrapper } from '../shared/QuestionWrapper';
|
|
5
|
+
import { useOnboardingIntl } from '../OnboardingIntl';
|
|
6
|
+
import type { GoalQuestionScreenConfig } from '../../types/screens';
|
|
7
|
+
|
|
8
|
+
export interface GoalQuestionScreenProps {
|
|
9
|
+
config: GoalQuestionScreenConfig;
|
|
10
|
+
onNext: (data: { selected: string[] }) => void;
|
|
11
|
+
initialSelected?: ReadonlyArray<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function GoalQuestionScreen({ config, onNext, initialSelected }: GoalQuestionScreenProps) {
|
|
15
|
+
const { labels } = useOnboardingIntl();
|
|
16
|
+
const [selected, setSelected] = useState<string[]>([...(initialSelected ?? [])]);
|
|
17
|
+
const multiSelect = config.multiSelect ?? false;
|
|
18
|
+
|
|
19
|
+
const toggle = useCallback(
|
|
20
|
+
(id: string) => {
|
|
21
|
+
setSelected((prev) => {
|
|
22
|
+
if (multiSelect) {
|
|
23
|
+
return prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id];
|
|
24
|
+
}
|
|
25
|
+
return prev.includes(id) ? [] : [id];
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
[multiSelect],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<QuestionWrapper
|
|
33
|
+
title={config.question}
|
|
34
|
+
subtitle={multiSelect ? labels.selectAllThatApply : labels.selectOne}
|
|
35
|
+
>
|
|
36
|
+
<div
|
|
37
|
+
className="flex flex-col gap-2"
|
|
38
|
+
role={multiSelect ? 'group' : 'radiogroup'}
|
|
39
|
+
aria-label={config.question}
|
|
40
|
+
>
|
|
41
|
+
{config.options.map((option) => (
|
|
42
|
+
<OptionCard
|
|
43
|
+
key={option.id}
|
|
44
|
+
option={option}
|
|
45
|
+
selected={selected.includes(option.id)}
|
|
46
|
+
onToggle={toggle}
|
|
47
|
+
multiSelect={multiSelect}
|
|
48
|
+
/>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
<PrimaryButton onClick={() => onNext({ selected })} disabled={selected.length === 0}>
|
|
52
|
+
{config.cta ?? labels.continueAction}
|
|
53
|
+
</PrimaryButton>
|
|
54
|
+
</QuestionWrapper>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import { OptionCard } from '../shared/OptionCard';
|
|
3
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
4
|
+
import { QuestionWrapper } from '../shared/QuestionWrapper';
|
|
5
|
+
import { formatLabel, useOnboardingIntl } from '../OnboardingIntl';
|
|
6
|
+
import type { PainPointsScreenConfig } from '../../types/screens';
|
|
7
|
+
|
|
8
|
+
export interface PainPointsScreenProps {
|
|
9
|
+
config: PainPointsScreenConfig;
|
|
10
|
+
onNext: (data: { selected: string[] }) => void;
|
|
11
|
+
initialSelected?: ReadonlyArray<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PainPointsScreen({ config, onNext, initialSelected }: PainPointsScreenProps) {
|
|
15
|
+
const { labels } = useOnboardingIntl();
|
|
16
|
+
const [selected, setSelected] = useState<string[]>([...(initialSelected ?? [])]);
|
|
17
|
+
const multiSelect = config.multiSelect ?? true;
|
|
18
|
+
const max = config.maxSelect;
|
|
19
|
+
|
|
20
|
+
const subtitle = multiSelect
|
|
21
|
+
? max !== undefined
|
|
22
|
+
? formatLabel(labels.pickUpToPattern, { max })
|
|
23
|
+
: labels.selectAllThatApply
|
|
24
|
+
: labels.pickTheOne;
|
|
25
|
+
|
|
26
|
+
const toggle = useCallback(
|
|
27
|
+
(id: string) => {
|
|
28
|
+
setSelected((prev) => {
|
|
29
|
+
if (multiSelect) {
|
|
30
|
+
if (prev.includes(id)) return prev.filter((p) => p !== id);
|
|
31
|
+
if (max !== undefined && prev.length >= max) return prev;
|
|
32
|
+
return [...prev, id];
|
|
33
|
+
}
|
|
34
|
+
return prev.includes(id) ? [] : [id];
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
[multiSelect, max],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<QuestionWrapper
|
|
42
|
+
title={config.title}
|
|
43
|
+
subtitle={subtitle}
|
|
44
|
+
>
|
|
45
|
+
<div
|
|
46
|
+
className="flex flex-col gap-2"
|
|
47
|
+
role={multiSelect ? 'group' : 'radiogroup'}
|
|
48
|
+
aria-label={config.title}
|
|
49
|
+
>
|
|
50
|
+
{config.painPoints.map((point) => (
|
|
51
|
+
<OptionCard
|
|
52
|
+
key={point.id}
|
|
53
|
+
option={point}
|
|
54
|
+
selected={selected.includes(point.id)}
|
|
55
|
+
onToggle={toggle}
|
|
56
|
+
multiSelect={multiSelect}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
<PrimaryButton onClick={() => onNext({ selected })} disabled={selected.length === 0}>
|
|
61
|
+
{config.cta ?? labels.continueAction}
|
|
62
|
+
</PrimaryButton>
|
|
63
|
+
</QuestionWrapper>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { ShieldCheck } from 'lucide-react';
|
|
4
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
5
|
+
import { renderBundledIcon } from '../shared/renderBundledIcon';
|
|
6
|
+
import { useOnboardingAnalytics } from '../hooks/useOnboardingAnalytics';
|
|
7
|
+
import { doesIconNameResolve } from '../../predicates/icon-name-resolves';
|
|
8
|
+
import { formatCountWithLocale } from '../../calc/count-with-locale';
|
|
9
|
+
import { formatLabel, useOnboardingIntl } from '../OnboardingIntl';
|
|
10
|
+
import type { PaywallScreenConfig } from '../../types/screens';
|
|
11
|
+
import type { AnalyticsConfig } from '../../types/config';
|
|
12
|
+
|
|
13
|
+
export interface PaywallScreenProps {
|
|
14
|
+
config: PaywallScreenConfig;
|
|
15
|
+
onNext: (data: { accepted: boolean; trialStarted: boolean }) => void;
|
|
16
|
+
analytics?: AnalyticsConfig;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hard paywall with trial.
|
|
21
|
+
*
|
|
22
|
+
* Error contract: if `onAccept` throws (network failure, provider outage), the
|
|
23
|
+
* user STAYS on this screen with a retryable error. A failed purchase attempt
|
|
24
|
+
* must never silently advance the user past the paywall.
|
|
25
|
+
*/
|
|
26
|
+
export function PaywallScreen({ config, onNext, analytics }: PaywallScreenProps) {
|
|
27
|
+
const { labels, locale } = useOnboardingIntl();
|
|
28
|
+
const { trackEvent } = useOnboardingAnalytics(analytics);
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
// "Viewed" is a view event — fire when the paywall is shown, not on interaction.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
trackEvent({ name: 'paywall_viewed', stepId: config.id, timestamp: Date.now() });
|
|
35
|
+
}, [trackEvent, config.id]);
|
|
36
|
+
|
|
37
|
+
async function handleAccept() {
|
|
38
|
+
setBusy(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
const result = await config.onAccept();
|
|
42
|
+
trackEvent({
|
|
43
|
+
name: result.accepted ? 'paywall_accepted' : 'paywall_declined',
|
|
44
|
+
stepId: config.id,
|
|
45
|
+
price: config.price,
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
});
|
|
48
|
+
onNext(result);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
const message = e instanceof Error ? e.message : labels.couldNotStartTrial;
|
|
51
|
+
setError(message);
|
|
52
|
+
} finally {
|
|
53
|
+
setBusy(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex flex-1 flex-col gap-6">
|
|
59
|
+
<div className="flex flex-col gap-2 text-center">
|
|
60
|
+
<h2 className="text-3xl font-bold text-[rgb(var(--uob-text-primary))] sm:text-4xl">
|
|
61
|
+
{config.title}
|
|
62
|
+
</h2>
|
|
63
|
+
<p className="text-base text-[rgb(var(--uob-text-muted))]">{config.subtitle}</p>
|
|
64
|
+
</div>
|
|
65
|
+
<motion.div
|
|
66
|
+
initial={{ scale: 0.95, opacity: 0 }}
|
|
67
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
68
|
+
transition={{ duration: 0.3 }}
|
|
69
|
+
className="rounded-2xl border-2 border-[rgb(var(--uob-primary-500))] bg-[rgb(var(--uob-primary-50))] p-6 text-center"
|
|
70
|
+
>
|
|
71
|
+
<div className="text-3xl font-bold text-[rgb(var(--uob-primary-700))]">{config.price}</div>
|
|
72
|
+
<div className="mt-1 text-sm text-[rgb(var(--uob-text-muted))]">
|
|
73
|
+
{config.trialPrice !== undefined
|
|
74
|
+
? formatLabel(labels.trialIntroPattern, {
|
|
75
|
+
price: config.trialPrice,
|
|
76
|
+
days: config.trialDays,
|
|
77
|
+
})
|
|
78
|
+
: formatLabel(labels.trialDaysPattern, { count: config.trialDays })}
|
|
79
|
+
</div>
|
|
80
|
+
</motion.div>
|
|
81
|
+
<ul className="flex flex-col gap-2">
|
|
82
|
+
{config.features.map((feature, i) => (
|
|
83
|
+
<li
|
|
84
|
+
key={`${feature.text}-${i}`}
|
|
85
|
+
className={
|
|
86
|
+
feature.highlighted
|
|
87
|
+
? 'flex items-start gap-3 rounded-lg border border-[rgb(var(--uob-primary-500))] bg-[rgb(var(--uob-primary-50))] p-3'
|
|
88
|
+
: 'flex items-start gap-3 rounded-lg border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-3'
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
{doesIconNameResolve(feature.iconName) && (
|
|
92
|
+
<span className="mt-0.5 text-[rgb(var(--uob-primary-500))]">
|
|
93
|
+
{renderBundledIcon(feature.iconName, 'h-5 w-5')}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
<span className="text-sm text-[rgb(var(--uob-text-primary))]">{feature.text}</span>
|
|
97
|
+
</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
{config.socialProof && (
|
|
101
|
+
<p className="text-center text-sm text-[rgb(var(--uob-text-muted))]">
|
|
102
|
+
{config.socialProof.text} ({formatCountWithLocale(config.socialProof.count, locale)}+ users)
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
{error && (
|
|
106
|
+
<p
|
|
107
|
+
role="alert"
|
|
108
|
+
className="rounded-md border border-[rgb(var(--uob-error))] bg-[rgb(var(--uob-error))]/10 p-3 text-sm text-[rgb(var(--uob-error))]"
|
|
109
|
+
>
|
|
110
|
+
{error}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
<div className="flex flex-col gap-2">
|
|
114
|
+
<PrimaryButton onClick={handleAccept} loading={busy}>
|
|
115
|
+
{config.cta}
|
|
116
|
+
</PrimaryButton>
|
|
117
|
+
{config.secondaryCta && (
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
disabled={busy}
|
|
121
|
+
onClick={() => {
|
|
122
|
+
trackEvent({ name: 'paywall_declined', stepId: config.id, timestamp: Date.now() });
|
|
123
|
+
config.onSecondaryCta?.();
|
|
124
|
+
onNext({ accepted: false, trialStarted: false });
|
|
125
|
+
}}
|
|
126
|
+
className="text-sm font-semibold text-[rgb(var(--uob-text-muted))] underline-offset-4 hover:underline disabled:opacity-50"
|
|
127
|
+
>
|
|
128
|
+
{config.secondaryCta}
|
|
129
|
+
</button>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
<p className="flex items-center justify-center gap-1 text-xs text-[rgb(var(--uob-text-muted))]">
|
|
133
|
+
<ShieldCheck className="h-3.5 w-3.5" />
|
|
134
|
+
{labels.cancelAnytimeNote}
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
3
|
+
import { renderBundledIcon } from '../shared/renderBundledIcon';
|
|
4
|
+
import { usePermissionPriming } from '../hooks/usePermissionPriming';
|
|
5
|
+
import { useOnboardingAnalytics } from '../hooks/useOnboardingAnalytics';
|
|
6
|
+
import { doesIconNameResolve } from '../../predicates/icon-name-resolves';
|
|
7
|
+
import { isPermissionGranted } from '../../predicates/permission-granted';
|
|
8
|
+
import { useOnboardingIntl } from '../OnboardingIntl';
|
|
9
|
+
import type { PermissionPrimingScreenConfig, PermissionStatus } from '../../types/screens';
|
|
10
|
+
import type { AnalyticsConfig } from '../../types/config';
|
|
11
|
+
|
|
12
|
+
const PERMISSION_DEFAULT_ICON: Record<string, string> = {
|
|
13
|
+
camera: 'Camera',
|
|
14
|
+
microphone: 'Mic',
|
|
15
|
+
location: 'MapPin',
|
|
16
|
+
notifications: 'Bell',
|
|
17
|
+
photos: 'Image',
|
|
18
|
+
contacts: 'Users',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface PermissionPrimingScreenProps {
|
|
22
|
+
config: PermissionPrimingScreenConfig;
|
|
23
|
+
onNext: (data: { status: PermissionStatus }) => void;
|
|
24
|
+
analytics?: AnalyticsConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function PermissionPrimingScreen({ config, onNext, analytics }: PermissionPrimingScreenProps) {
|
|
28
|
+
const { labels } = useOnboardingIntl();
|
|
29
|
+
const { trackEvent } = useOnboardingAnalytics(analytics);
|
|
30
|
+
// Hook tracks the live permission state for UI feedback; the screen invokes the
|
|
31
|
+
// consumer-provided `config.onRequest` so the consumer controls the actual mechanism.
|
|
32
|
+
const { status } = usePermissionPriming(config.permission);
|
|
33
|
+
const [isRequesting, setIsRequesting] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
// Configured icon wins; fall back to the permission's default glyph when the
|
|
36
|
+
// configured name is missing or not in the bundled registry.
|
|
37
|
+
const iconName = doesIconNameResolve(config.iconName)
|
|
38
|
+
? config.iconName
|
|
39
|
+
: PERMISSION_DEFAULT_ICON[config.permission];
|
|
40
|
+
|
|
41
|
+
async function handleRequest() {
|
|
42
|
+
setError(null);
|
|
43
|
+
setIsRequesting(true);
|
|
44
|
+
trackEvent({
|
|
45
|
+
name: 'permission_requested',
|
|
46
|
+
permission: config.permission,
|
|
47
|
+
stepId: config.id,
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
const next = await config.onRequest();
|
|
52
|
+
trackEvent({
|
|
53
|
+
name: next === 'granted' ? 'permission_granted' : 'permission_denied',
|
|
54
|
+
permission: config.permission,
|
|
55
|
+
stepId: config.id,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
});
|
|
58
|
+
onNext({ status: next });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
trackEvent({
|
|
61
|
+
name: 'permission_denied',
|
|
62
|
+
permission: config.permission,
|
|
63
|
+
stepId: config.id,
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
});
|
|
66
|
+
const message = e instanceof Error ? e.message : labels.permissionRequestFailed;
|
|
67
|
+
setError(message);
|
|
68
|
+
} finally {
|
|
69
|
+
setIsRequesting(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex flex-1 flex-col gap-6">
|
|
75
|
+
{doesIconNameResolve(iconName) && (
|
|
76
|
+
<div
|
|
77
|
+
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-[rgb(var(--uob-primary-50))] text-[rgb(var(--uob-primary-500))]"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
>
|
|
80
|
+
{renderBundledIcon(iconName, 'h-10 w-10')}
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
<div className="flex flex-col gap-2">
|
|
84
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
|
|
85
|
+
{config.title}
|
|
86
|
+
</h2>
|
|
87
|
+
<p className="text-base text-[rgb(var(--uob-text-muted))]">{config.description}</p>
|
|
88
|
+
</div>
|
|
89
|
+
<ul className="flex flex-col gap-2">
|
|
90
|
+
{config.benefits.map((benefit) => (
|
|
91
|
+
<li
|
|
92
|
+
key={benefit}
|
|
93
|
+
className="flex items-start gap-2 rounded-lg border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-3 text-sm text-[rgb(var(--uob-text-primary))]"
|
|
94
|
+
>
|
|
95
|
+
<span className="mt-1 h-2 w-2 shrink-0 rounded-full bg-[rgb(var(--uob-primary-500))]" />
|
|
96
|
+
<span>{benefit}</span>
|
|
97
|
+
</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
{error && (
|
|
101
|
+
<p
|
|
102
|
+
role="alert"
|
|
103
|
+
className="rounded-md border border-[rgb(var(--uob-error))] bg-[rgb(var(--uob-error))]/10 p-3 text-sm text-[rgb(var(--uob-error))]"
|
|
104
|
+
>
|
|
105
|
+
{error}
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
108
|
+
<PrimaryButton onClick={handleRequest} loading={isRequesting}>
|
|
109
|
+
{isPermissionGranted(status) ? labels.continueAction : config.cta ?? labels.allowAccess}
|
|
110
|
+
</PrimaryButton>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { motion } from 'framer-motion';
|
|
2
|
+
import { CheckCircle2 } from 'lucide-react';
|
|
3
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
4
|
+
import { useOnboardingIntl } from '../OnboardingIntl';
|
|
5
|
+
import type { PersonalizedSolutionScreenConfig } from '../../types/screens';
|
|
6
|
+
|
|
7
|
+
export interface PersonalizedSolutionScreenProps {
|
|
8
|
+
config: PersonalizedSolutionScreenConfig;
|
|
9
|
+
onNext: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function PersonalizedSolutionScreen({ config, onNext }: PersonalizedSolutionScreenProps) {
|
|
13
|
+
const { labels } = useOnboardingIntl();
|
|
14
|
+
const visible = config.solutions.filter((s) => config.selectedPainPointIds.includes(s.painPointId));
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-1 flex-col gap-6">
|
|
18
|
+
{config.title && (
|
|
19
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
|
|
20
|
+
{config.title}
|
|
21
|
+
</h2>
|
|
22
|
+
)}
|
|
23
|
+
{config.subtitle && (
|
|
24
|
+
<p className="text-base text-[rgb(var(--uob-text-muted))]">{config.subtitle}</p>
|
|
25
|
+
)}
|
|
26
|
+
<ul className="flex flex-col gap-3">
|
|
27
|
+
{visible.map((solution, i) => (
|
|
28
|
+
<motion.li
|
|
29
|
+
key={solution.painPointId}
|
|
30
|
+
initial={{ opacity: 0, x: -12 }}
|
|
31
|
+
animate={{ opacity: 1, x: 0 }}
|
|
32
|
+
transition={{ delay: i * 0.1, duration: 0.3 }}
|
|
33
|
+
className="flex items-start gap-3 rounded-xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-4"
|
|
34
|
+
>
|
|
35
|
+
<CheckCircle2
|
|
36
|
+
className="mt-0.5 h-5 w-5 shrink-0 text-[rgb(var(--uob-success))]"
|
|
37
|
+
aria-hidden="true"
|
|
38
|
+
/>
|
|
39
|
+
<div className="flex flex-1 flex-col gap-1">
|
|
40
|
+
<p className="text-base text-[rgb(var(--uob-text-primary))]">{solution.solution}</p>
|
|
41
|
+
{solution.statistic && (
|
|
42
|
+
<p className="text-sm font-semibold text-[rgb(var(--uob-primary-500))]">
|
|
43
|
+
{solution.statistic}
|
|
44
|
+
</p>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
</motion.li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
<PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useId, useState } from 'react';
|
|
2
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
3
|
+
import { QuestionWrapper } from '../shared/QuestionWrapper';
|
|
4
|
+
import { useOnboardingIntl } from '../OnboardingIntl';
|
|
5
|
+
import {
|
|
6
|
+
clampValueToBounds,
|
|
7
|
+
resolveSliderBounds,
|
|
8
|
+
} from '../../calc/slider-value-bounds';
|
|
9
|
+
import type {
|
|
10
|
+
OnboardingOption,
|
|
11
|
+
PreferenceItem,
|
|
12
|
+
PreferencesScreenConfig,
|
|
13
|
+
} from '../../types/screens';
|
|
14
|
+
|
|
15
|
+
type PrefValue = string | number | string[] | boolean;
|
|
16
|
+
|
|
17
|
+
function defaultFor(item: PreferenceItem): PrefValue {
|
|
18
|
+
if (item.defaultValue !== undefined) {
|
|
19
|
+
if (item.type === 'slider' && typeof item.defaultValue === 'number') {
|
|
20
|
+
return clampValueToBounds(item.defaultValue, item.min ?? 0, item.max ?? 0, item.id);
|
|
21
|
+
}
|
|
22
|
+
return item.defaultValue;
|
|
23
|
+
}
|
|
24
|
+
switch (item.type) {
|
|
25
|
+
case 'multi-select':
|
|
26
|
+
return [];
|
|
27
|
+
case 'toggle':
|
|
28
|
+
return false;
|
|
29
|
+
case 'slider':
|
|
30
|
+
return resolveSliderBounds(item.min, item.max).min;
|
|
31
|
+
case 'select':
|
|
32
|
+
default:
|
|
33
|
+
return item.options?.[0]?.id ?? '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toggleMulti(values: string[], id: string): string[] {
|
|
38
|
+
return values.includes(id) ? values.filter((v) => v !== id) : [...values, id];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PreferencesScreenProps {
|
|
42
|
+
config: PreferencesScreenConfig;
|
|
43
|
+
onNext: (data: { values: Record<string, PrefValue> }) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function PreferencesScreen({ config, onNext }: PreferencesScreenProps) {
|
|
47
|
+
const { labels } = useOnboardingIntl();
|
|
48
|
+
const [values, setValues] = useState<Record<string, PrefValue>>(() => {
|
|
49
|
+
const initial: Record<string, PrefValue> = {};
|
|
50
|
+
for (const item of config.preferences) initial[item.id] = defaultFor(item);
|
|
51
|
+
return initial;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function update(id: string, value: PrefValue) {
|
|
55
|
+
setValues((prev) => ({ ...prev, [id]: value }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const canContinue = config.preferences.every((item) => {
|
|
59
|
+
const v = values[item.id];
|
|
60
|
+
if (item.type === 'select') return typeof v === 'string' && v.length > 0;
|
|
61
|
+
if (item.type === 'multi-select') return Array.isArray(v);
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<QuestionWrapper title={config.title} subtitle={labels.tailorExperience}>
|
|
67
|
+
<div className="flex flex-col gap-5">
|
|
68
|
+
{config.preferences.map((item) => (
|
|
69
|
+
<PreferenceRow key={item.id} item={item} value={values[item.id]} onChange={(v) => update(item.id, v)} />
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
<PrimaryButton onClick={() => onNext({ values })} disabled={!canContinue}>
|
|
73
|
+
{config.cta ?? labels.continueAction}
|
|
74
|
+
</PrimaryButton>
|
|
75
|
+
</QuestionWrapper>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface PreferenceRowProps {
|
|
80
|
+
item: PreferenceItem;
|
|
81
|
+
value: PrefValue;
|
|
82
|
+
onChange: (value: PrefValue) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function PreferenceRow({ item, value, onChange }: PreferenceRowProps) {
|
|
86
|
+
const fieldId = useId();
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
<label className="text-sm font-semibold text-[rgb(var(--uob-text-primary))]" htmlFor={fieldId}>
|
|
90
|
+
{item.label}
|
|
91
|
+
</label>
|
|
92
|
+
<PreferenceControl item={item} value={value} onChange={onChange} fieldId={fieldId} />
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface PreferenceControlProps extends PreferenceRowProps {
|
|
98
|
+
fieldId: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function PreferenceControl({ item, value, onChange, fieldId }: PreferenceControlProps) {
|
|
102
|
+
switch (item.type) {
|
|
103
|
+
case 'select':
|
|
104
|
+
return (
|
|
105
|
+
<select
|
|
106
|
+
id={fieldId}
|
|
107
|
+
className="rounded-md border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-2 text-sm"
|
|
108
|
+
value={typeof value === 'string' ? value : ''}
|
|
109
|
+
onChange={(e) => onChange(e.target.value)}
|
|
110
|
+
>
|
|
111
|
+
{item.options?.map((opt: OnboardingOption) => (
|
|
112
|
+
<option key={opt.id} value={opt.id}>
|
|
113
|
+
{opt.label}
|
|
114
|
+
</option>
|
|
115
|
+
))}
|
|
116
|
+
</select>
|
|
117
|
+
);
|
|
118
|
+
case 'multi-select': {
|
|
119
|
+
const list = Array.isArray(value) ? value : [];
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex flex-wrap gap-2" role="group" aria-label={item.label}>
|
|
122
|
+
{item.options?.map((opt) => {
|
|
123
|
+
const isOn = list.includes(opt.id);
|
|
124
|
+
return (
|
|
125
|
+
<button
|
|
126
|
+
key={opt.id}
|
|
127
|
+
type="button"
|
|
128
|
+
aria-pressed={isOn}
|
|
129
|
+
onClick={() => onChange(toggleMulti(list, opt.id))}
|
|
130
|
+
className={
|
|
131
|
+
isOn
|
|
132
|
+
? 'rounded-full bg-[rgb(var(--uob-primary-500))] px-3 py-1 text-sm text-white'
|
|
133
|
+
: 'rounded-full border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] px-3 py-1 text-sm hover:border-[rgb(var(--uob-primary-500))]'
|
|
134
|
+
}
|
|
135
|
+
>
|
|
136
|
+
{opt.label}
|
|
137
|
+
</button>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
case 'toggle':
|
|
144
|
+
return (
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
id={fieldId}
|
|
148
|
+
role="switch"
|
|
149
|
+
aria-checked={value === true}
|
|
150
|
+
onClick={() => onChange(!value)}
|
|
151
|
+
className={
|
|
152
|
+
value === true
|
|
153
|
+
? 'h-6 w-11 rounded-full bg-[rgb(var(--uob-primary-500))] p-0.5'
|
|
154
|
+
: 'h-6 w-11 rounded-full bg-[rgb(var(--uob-border))] p-0.5'
|
|
155
|
+
}
|
|
156
|
+
>
|
|
157
|
+
<span
|
|
158
|
+
className={
|
|
159
|
+
value === true
|
|
160
|
+
? 'block h-5 w-5 translate-x-5 rounded-full bg-white transition-transform'
|
|
161
|
+
: 'block h-5 w-5 translate-x-0 rounded-full bg-white transition-transform'
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
case 'slider': {
|
|
167
|
+
const { min, max } = resolveSliderBounds(item.min, item.max);
|
|
168
|
+
const numeric = typeof value === 'number' ? value : min;
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex flex-col gap-1">
|
|
171
|
+
<input
|
|
172
|
+
type="range"
|
|
173
|
+
id={fieldId}
|
|
174
|
+
min={min}
|
|
175
|
+
max={max}
|
|
176
|
+
value={numeric}
|
|
177
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
178
|
+
aria-valuemin={min}
|
|
179
|
+
aria-valuemax={max}
|
|
180
|
+
aria-valuenow={numeric}
|
|
181
|
+
className="w-full"
|
|
182
|
+
/>
|
|
183
|
+
<div className="flex justify-between text-xs text-[rgb(var(--uob-text-muted))]">
|
|
184
|
+
<span>{min}</span>
|
|
185
|
+
<span>{numeric}</span>
|
|
186
|
+
<span>{max}</span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
default:
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|