@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,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
+ }