@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.
Files changed (120) hide show
  1. package/package.json +19 -17
  2. package/src/domains/onboarding/calc/calc.test.ts +181 -0
  3. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +33 -0
  4. package/src/domains/onboarding/calc/count-with-locale.ts +16 -0
  5. package/src/domains/onboarding/calc/duration-equal-split.ts +24 -0
  6. package/src/domains/onboarding/calc/elapsed-millis.ts +12 -0
  7. package/src/domains/onboarding/calc/index.ts +36 -0
  8. package/src/domains/onboarding/calc/progress-percentage.ts +37 -0
  9. package/src/domains/onboarding/calc/slider-value-bounds.ts +44 -0
  10. package/src/domains/onboarding/calc/step-index-clamp.ts +28 -0
  11. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +67 -0
  12. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +481 -0
  13. package/src/domains/onboarding/components/OnboardingFlow.tsx +361 -0
  14. package/src/domains/onboarding/components/OnboardingIntl.tsx +108 -0
  15. package/src/domains/onboarding/components/hooks/index.ts +7 -0
  16. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.test.tsx +74 -0
  17. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.ts +62 -0
  18. package/src/domains/onboarding/components/hooks/useOnboardingProgress.test.tsx +150 -0
  19. package/src/domains/onboarding/components/hooks/useOnboardingProgress.ts +260 -0
  20. package/src/domains/onboarding/components/hooks/usePermissionPriming.ts +52 -0
  21. package/src/domains/onboarding/components/hooks/useReducedMotion.ts +24 -0
  22. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +89 -0
  23. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +39 -0
  24. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +59 -0
  25. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +56 -0
  26. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  27. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +138 -0
  28. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +113 -0
  29. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +53 -0
  30. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +194 -0
  31. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +79 -0
  32. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +116 -0
  33. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +108 -0
  34. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +85 -0
  35. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +52 -0
  36. package/src/domains/onboarding/components/screens/index.ts +28 -0
  37. package/src/domains/onboarding/components/shared/AnimationWrapper.tsx +73 -0
  38. package/src/domains/onboarding/components/shared/OnboardingErrorBoundary.tsx +81 -0
  39. package/src/domains/onboarding/components/shared/OptionCard.tsx +73 -0
  40. package/src/domains/onboarding/components/shared/PrimaryButton.tsx +58 -0
  41. package/src/domains/onboarding/components/shared/ProgressBar.tsx +51 -0
  42. package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +36 -0
  43. package/src/domains/onboarding/components/shared/ScreenContainer.tsx +29 -0
  44. package/src/domains/onboarding/components/shared/SwipeCard.test.tsx +51 -0
  45. package/src/domains/onboarding/components/shared/SwipeCard.tsx +106 -0
  46. package/src/domains/onboarding/components/shared/index.ts +16 -0
  47. package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +20 -0
  48. package/src/domains/onboarding/index.ts +182 -0
  49. package/src/domains/onboarding/onboarding.css +1 -0
  50. package/src/domains/onboarding/predicates/has-handler.ts +25 -0
  51. package/src/domains/onboarding/predicates/icon-name-resolves.ts +113 -0
  52. package/src/domains/onboarding/predicates/index.ts +26 -0
  53. package/src/domains/onboarding/predicates/permission-granted.ts +26 -0
  54. package/src/domains/onboarding/predicates/permission-supports.ts +66 -0
  55. package/src/domains/onboarding/predicates/predicates.test.ts +202 -0
  56. package/src/domains/onboarding/predicates/screen-emits-data.ts +17 -0
  57. package/src/domains/onboarding/predicates/storage-available.ts +22 -0
  58. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +112 -0
  59. package/src/domains/onboarding/templates/art-onboarding.ts +81 -0
  60. package/src/domains/onboarding/templates/health-onboarding.ts +97 -0
  61. package/src/domains/onboarding/templates/index.ts +6 -0
  62. package/src/domains/onboarding/templates/platform-onboarding.ts +210 -0
  63. package/src/domains/onboarding/templates/video-onboarding.ts +100 -0
  64. package/src/domains/onboarding/test/setup.ts +30 -0
  65. package/src/domains/onboarding/theme/cn.ts +10 -0
  66. package/src/domains/onboarding/theme/index.css +131 -0
  67. package/src/domains/onboarding/theme/index.ts +12 -0
  68. package/src/domains/onboarding/theme/tokens.ts +122 -0
  69. package/src/domains/onboarding/types/config.ts +68 -0
  70. package/src/domains/onboarding/types/index.ts +10 -0
  71. package/src/domains/onboarding/types/labels.ts +83 -0
  72. package/src/domains/onboarding/types/screens.ts +193 -0
  73. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  74. package/src/domains/onboarding/utils/analytics-dispatch.ts +92 -0
  75. package/src/domains/onboarding/utils/default-storage-key.ts +2 -0
  76. package/src/domains/onboarding/utils/index.ts +13 -0
  77. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  78. package/src/domains/onboarding/utils/local-storage-bridge.ts +104 -0
  79. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  80. package/src/domains/onboarding/utils/permission-bridge.ts +95 -0
  81. package/src/domains/onboarding/validation/flow-structure-check.ts +28 -0
  82. package/src/domains/onboarding/validation/index.ts +9 -0
  83. package/src/domains/onboarding/validation/permission-status-check.ts +23 -0
  84. package/src/domains/onboarding/validation/screen-config-check.ts +105 -0
  85. package/src/domains/onboarding/validation/stored-progress-check.ts +68 -0
  86. package/src/domains/onboarding/validation/validation.test.ts +175 -0
  87. package/src/index.ts +0 -10
  88. package/src/styles/dashboard.contract.test.ts +1 -0
  89. package/src/styles/dashboard.css +0 -460
  90. package/src/domains/auth/components/AuthLayout.tsx +0 -99
  91. package/src/domains/auth/components/ForgotPasswordForm.tsx +0 -167
  92. package/src/domains/auth/components/LoginForm.tsx +0 -222
  93. package/src/domains/auth/components/RegisterForm.tsx +0 -281
  94. package/src/domains/auth/components/ResetPasswordForm.tsx +0 -173
  95. package/src/domains/auth/components/index.ts +0 -11
  96. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +0 -25
  97. package/src/domains/auth/components/shared/EmailInput.tsx +0 -73
  98. package/src/domains/auth/components/shared/PasswordInput.tsx +0 -100
  99. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +0 -63
  100. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +0 -44
  101. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +0 -48
  102. package/src/domains/auth/components/shared/index.ts +0 -20
  103. package/src/domains/auth/hooks/index.ts +0 -8
  104. package/src/domains/auth/hooks/useAuth.ts +0 -230
  105. package/src/domains/auth/index.ts +0 -62
  106. package/src/domains/auth/types/auth.ts +0 -302
  107. package/src/domains/auth/types/index.ts +0 -25
  108. package/src/domains/auth/utils/auth.test.ts +0 -188
  109. package/src/domains/auth/utils/auth.ts +0 -221
  110. package/src/domains/auth/utils/i18nKeys.ts +0 -109
  111. package/src/domains/auth/utils/index.ts +0 -29
  112. package/src/domains/auth/utils/secureToken.ts +0 -33
  113. package/src/domains/auth/utils/validationKeys.ts +0 -23
  114. package/src/domains/session/DemoLogin.tsx +0 -42
  115. package/src/domains/session/OrgSwitcher.tsx +0 -102
  116. package/src/domains/session/SessionContext.tsx +0 -150
  117. package/src/domains/session/inMemoryAuthAdapter.ts +0 -53
  118. package/src/domains/session/index.ts +0 -18
  119. package/src/domains/session/types.ts +0 -47
  120. package/src/domains/session/utils/i18nKeys.ts +0 -12
@@ -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
+ }