@umituz/web-dashboard 6.0.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +47 -73
  2. package/package.json +15 -7
  3. package/src/config/DashboardConfig.ts +19 -68
  4. package/src/domains/analytics/components/index.ts +0 -6
  5. package/src/domains/analytics/hooks/index.ts +0 -6
  6. package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
  7. package/src/domains/analytics/index.ts +0 -10
  8. package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
  9. package/src/domains/analytics/services/PerformanceService.ts +27 -138
  10. package/src/domains/analytics/services/index.ts +0 -4
  11. package/src/domains/analytics/types/analytics.ts +100 -270
  12. package/src/domains/analytics/types/index.ts +0 -6
  13. package/src/domains/analytics/utils/analytics.test.ts +2 -9
  14. package/src/domains/analytics/utils/analytics.ts +1 -22
  15. package/src/domains/analytics/utils/index.ts +0 -6
  16. package/src/domains/calendar/config.ts +0 -7
  17. package/src/domains/calendar/hooks/index.ts +0 -4
  18. package/src/domains/calendar/hooks/useCalendar.ts +7 -27
  19. package/src/domains/calendar/index.ts +0 -4
  20. package/src/domains/calendar/services/CalendarService.ts +0 -21
  21. package/src/domains/calendar/services/index.ts +0 -4
  22. package/src/domains/calendar/types/calendar.types.ts +9 -53
  23. package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
  24. package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
  25. package/src/domains/calendar/utils/index.ts +0 -26
  26. package/src/domains/layouts/components/index.ts +0 -4
  27. package/src/domains/layouts/hooks/dashboard.ts +0 -21
  28. package/src/domains/layouts/hooks/index.ts +0 -4
  29. package/src/domains/layouts/index.ts +0 -6
  30. package/src/domains/layouts/theme/default.ts +0 -12
  31. package/src/domains/layouts/theme/index.ts +0 -4
  32. package/src/domains/layouts/theme/presets.ts +0 -9
  33. package/src/domains/layouts/theme/utils.ts +0 -15
  34. package/src/domains/layouts/types/index.ts +0 -4
  35. package/src/domains/layouts/types/layout.ts +18 -56
  36. package/src/domains/layouts/types/notification.ts +4 -17
  37. package/src/domains/layouts/types/sidebar.ts +8 -28
  38. package/src/domains/layouts/types/theme.ts +23 -59
  39. package/src/domains/layouts/types/user.ts +9 -30
  40. package/src/domains/layouts/utils/dashboard.ts +4 -48
  41. package/src/domains/layouts/utils/index.ts +0 -4
  42. package/src/domains/modules/build.tsx +2 -16
  43. package/src/domains/modules/types.ts +8 -23
  44. package/src/domains/onboarding/calc/calc.test.ts +173 -0
  45. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +27 -0
  46. package/src/domains/onboarding/calc/count-with-locale.ts +8 -0
  47. package/src/domains/onboarding/calc/duration-equal-split.ts +12 -0
  48. package/src/domains/onboarding/calc/elapsed-millis.ts +6 -0
  49. package/src/domains/onboarding/calc/index.ts +36 -0
  50. package/src/domains/onboarding/calc/progress-percentage.ts +27 -0
  51. package/src/domains/onboarding/calc/slider-value-bounds.ts +34 -0
  52. package/src/domains/onboarding/calc/step-index-clamp.ts +19 -0
  53. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +54 -0
  54. package/src/domains/onboarding/components/AnimationWrapper.tsx +69 -0
  55. package/src/domains/onboarding/components/OnboardingErrorBoundary.tsx +61 -0
  56. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +459 -0
  57. package/src/domains/onboarding/components/OnboardingFlow.tsx +313 -0
  58. package/src/domains/onboarding/components/OnboardingIntl.tsx +98 -0
  59. package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
  60. package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
  61. package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
  62. package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
  63. package/src/domains/onboarding/components/SwipeCard.test.tsx +49 -0
  64. package/src/domains/onboarding/components/SwipeCard.tsx +88 -0
  65. package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
  66. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +79 -0
  67. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +33 -0
  68. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +49 -0
  69. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +59 -0
  70. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  71. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +122 -0
  72. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +98 -0
  73. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +40 -0
  74. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +180 -0
  75. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +63 -0
  76. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +87 -0
  77. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +91 -0
  78. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +69 -0
  79. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +32 -0
  80. package/src/domains/onboarding/components/screens/index.ts +28 -0
  81. package/src/domains/onboarding/hooks/index.ts +7 -0
  82. package/src/domains/onboarding/hooks/useOnboardingAnalytics.test.tsx +74 -0
  83. package/src/domains/onboarding/hooks/useOnboardingAnalytics.ts +49 -0
  84. package/src/domains/onboarding/hooks/useOnboardingProgress.test.tsx +150 -0
  85. package/src/domains/onboarding/hooks/useOnboardingProgress.ts +249 -0
  86. package/src/domains/onboarding/hooks/usePermissionPriming.ts +45 -0
  87. package/src/domains/onboarding/hooks/useReducedMotion.ts +19 -0
  88. package/src/domains/onboarding/index.ts +153 -0
  89. package/src/domains/onboarding/predicates/has-handler.ts +21 -0
  90. package/src/domains/onboarding/predicates/icon-name-resolves.ts +97 -0
  91. package/src/domains/onboarding/predicates/index.ts +26 -0
  92. package/src/domains/onboarding/predicates/permission-granted.ts +21 -0
  93. package/src/domains/onboarding/predicates/permission-supports.ts +56 -0
  94. package/src/domains/onboarding/predicates/predicates.test.ts +188 -0
  95. package/src/domains/onboarding/predicates/screen-emits-data.ts +13 -0
  96. package/src/domains/onboarding/predicates/storage-available.ts +14 -0
  97. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +92 -0
  98. package/src/domains/onboarding/templates/art-onboarding.ts +71 -0
  99. package/src/domains/onboarding/templates/health-onboarding.ts +92 -0
  100. package/src/domains/onboarding/templates/index.ts +6 -0
  101. package/src/domains/onboarding/templates/platform-onboarding.ts +201 -0
  102. package/src/domains/onboarding/templates/video-onboarding.ts +95 -0
  103. package/src/domains/onboarding/test/setup.ts +23 -0
  104. package/src/domains/onboarding/types/config.ts +57 -0
  105. package/src/domains/onboarding/types/index.ts +10 -0
  106. package/src/domains/onboarding/types/labels.ts +49 -0
  107. package/src/domains/onboarding/types/screens.ts +185 -0
  108. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  109. package/src/domains/onboarding/utils/analytics-dispatch.ts +73 -0
  110. package/src/domains/onboarding/utils/default-storage-key.ts +1 -0
  111. package/src/domains/onboarding/utils/index.ts +12 -0
  112. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  113. package/src/domains/onboarding/utils/local-storage-bridge.ts +84 -0
  114. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  115. package/src/domains/onboarding/utils/permission-bridge.ts +87 -0
  116. package/src/domains/onboarding/validation/flow-structure-check.ts +22 -0
  117. package/src/domains/onboarding/validation/index.ts +9 -0
  118. package/src/domains/onboarding/validation/permission-status-check.ts +18 -0
  119. package/src/domains/onboarding/validation/screen-config-check.ts +100 -0
  120. package/src/domains/onboarding/validation/stored-progress-check.ts +59 -0
  121. package/src/domains/onboarding/validation/validation.test.ts +170 -0
  122. package/src/domains/permissions/hasPermission.ts +0 -8
  123. package/src/domains/permissions/types.ts +0 -20
  124. package/src/domains/settings/components/index.ts +0 -4
  125. package/src/domains/settings/hooks/index.ts +0 -4
  126. package/src/domains/settings/hooks/useSettings.ts +1 -26
  127. package/src/domains/settings/index.ts +0 -6
  128. package/src/domains/settings/types/index.ts +0 -4
  129. package/src/domains/settings/types/settings.ts +23 -67
  130. package/src/domains/settings/utils/index.ts +0 -4
  131. package/src/domains/settings/utils/settings.ts +0 -45
  132. package/src/styles/dashboard.contract.test.ts +1 -0
  133. package/src/styles/dashboard.css +818 -1
@@ -0,0 +1,79 @@
1
+ import { useState } from 'react';
2
+ import { PrimaryButton } from '../PrimaryButton';
3
+ import {
4
+ accountGateAllowsSkip,
5
+ hasOnSignIn,
6
+ } from '../../predicates/has-handler';
7
+ import { useOnboardingIntl } from '../OnboardingIntl';
8
+ import type { AccountGateScreenConfig, SignInMethod } from '../../types/screens';
9
+ import type { OnboardingLabels } from '../../types/config';
10
+
11
+ export interface AccountGateScreenProps {
12
+ config: AccountGateScreenConfig;
13
+ onNext: (data: { signedIn: boolean; method?: SignInMethod }) => void;
14
+ }
15
+
16
+ function methodLabel(method: SignInMethod, labels: OnboardingLabels): string {
17
+ switch (method) {
18
+ case 'email':
19
+ return labels.continueWithEmail;
20
+ case 'google':
21
+ return labels.continueWithGoogle;
22
+ case 'apple':
23
+ return labels.continueWithApple;
24
+ case 'github':
25
+ return labels.continueWithGitHub;
26
+ }
27
+ }
28
+
29
+ export function AccountGateScreen({ config, onNext }: AccountGateScreenProps) {
30
+ const { labels } = useOnboardingIntl();
31
+ const [busy, setBusy] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+
34
+ async function handleSignIn(method: SignInMethod) {
35
+ setBusy(true);
36
+ setError(null);
37
+ try {
38
+ const result = await config.onSignIn(method);
39
+ onNext(result);
40
+ } catch (e) {
41
+ const message = e instanceof Error ? e.message : labels.signInFailed;
42
+ setError(message);
43
+ } finally {
44
+ setBusy(false);
45
+ }
46
+ }
47
+
48
+ return (
49
+ <div className="udb-onb-stage">
50
+ {config.title && <h2 className="udb-onb-title udb-onb-title--display">{config.title}</h2>}
51
+ {config.subtitle && <p className="udb-onb-lead">{config.subtitle}</p>}
52
+ {error && (
53
+ <p role="alert" className="udb-onb-error">
54
+ {error}
55
+ </p>
56
+ )}
57
+ <div className="udb-onb-question__body">
58
+ {config.signInMethod && hasOnSignIn(config) && (
59
+ <PrimaryButton
60
+ onClick={() => handleSignIn(config.signInMethod as SignInMethod)}
61
+ loading={busy}
62
+ >
63
+ {methodLabel(config.signInMethod, labels)}
64
+ </PrimaryButton>
65
+ )}
66
+ {accountGateAllowsSkip(config) && (
67
+ <button
68
+ type="button"
69
+ disabled={busy}
70
+ onClick={() => onNext({ signedIn: false })}
71
+ className="udb-onb-link"
72
+ >
73
+ {config.skipText ?? labels.skipForNow}
74
+ </button>
75
+ )}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,33 @@
1
+ import { useState } from 'react';
2
+ import { PrimaryButton } from '../PrimaryButton';
3
+ import { useOnboardingIntl } from '../OnboardingIntl';
4
+ import type { AppDemoScreenConfig } from '../../types/screens';
5
+
6
+ export interface AppDemoScreenProps {
7
+ config: AppDemoScreenConfig;
8
+ onNext: (data: { result: unknown }) => void;
9
+ }
10
+
11
+ export function AppDemoScreen({ config, onNext }: AppDemoScreenProps) {
12
+ const { labels } = useOnboardingIntl();
13
+ const [result, setResult] = useState<unknown>(null);
14
+ const Demo = config.demo.component;
15
+
16
+ return (
17
+ <div className="udb-onb-proof">
18
+ {config.title && <h2 className="udb-onb-title">{config.title}</h2>}
19
+ <div className="udb-onb-demo">
20
+ <Demo
21
+ {...((config.demo.props ?? {}) as Record<string, unknown>)}
22
+ onSave={(value: unknown) => {
23
+ setResult(value);
24
+ config.demo.onSave(value);
25
+ }}
26
+ />
27
+ </div>
28
+ <PrimaryButton onClick={() => onNext({ result })} disabled={result === null}>
29
+ {config.cta ?? labels.continueWithResult}
30
+ </PrimaryButton>
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,49 @@
1
+ import { Check, X } from 'lucide-react';
2
+ import { PrimaryButton } from '../PrimaryButton';
3
+ import { useOnboardingIntl } from '../OnboardingIntl';
4
+ import type { ComparisonScreenConfig } from '../../types/screens';
5
+
6
+ export interface ComparisonScreenProps {
7
+ config: ComparisonScreenConfig;
8
+ onNext: () => void;
9
+ }
10
+
11
+ export function ComparisonScreen({ config, onNext }: ComparisonScreenProps) {
12
+ const { labels } = useOnboardingIntl();
13
+ return (
14
+ <div className="udb-onb-proof">
15
+ {config.title && <h2 className="udb-onb-title">{config.title}</h2>}
16
+ <div className="udb-onb-compare">
17
+ <ComparisonColumn heading={labels.withoutUs} items={config.before} tone="negative" />
18
+ <ComparisonColumn heading={labels.withUs} items={config.after} tone="positive" />
19
+ </div>
20
+ <PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
21
+ </div>
22
+ );
23
+ }
24
+
25
+ interface ComparisonColumnProps {
26
+ heading: string;
27
+ items: ReadonlyArray<string>;
28
+ tone: 'positive' | 'negative';
29
+ }
30
+
31
+ function ComparisonColumn({ heading, items, tone }: ComparisonColumnProps) {
32
+ const Icon = tone === 'positive' ? Check : X;
33
+ return (
34
+ <div className={`udb-onb-compare__col udb-onb-compare__col--${tone === 'positive' ? 'pos' : 'neg'}`}>
35
+ <h3 className="udb-onb-compare__heading">{heading}</h3>
36
+ <ul className="udb-onb-list">
37
+ {items.map((item) => (
38
+ <li key={item} className="udb-onb-compare__item">
39
+ <Icon
40
+ className={`udb-onb-icon--sm udb-onb-compare__icon udb-onb-compare__icon--${tone === 'positive' ? 'pos' : 'neg'}`}
41
+ aria-hidden="true"
42
+ />
43
+ <span>{item}</span>
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,59 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { OptionCard } from '@umituz/web-design-system/molecules';
3
+ import { PrimaryButton } from '../PrimaryButton';
4
+ import { QuestionWrapper } from '../QuestionWrapper';
5
+ import { renderBundledIcon } from '../renderBundledIcon';
6
+ import { useOnboardingIntl } from '../OnboardingIntl';
7
+ import type { GoalQuestionScreenConfig } from '../../types/screens';
8
+
9
+ export interface GoalQuestionScreenProps {
10
+ config: GoalQuestionScreenConfig;
11
+ onNext: (data: { selected: string[] }) => void;
12
+ initialSelected?: ReadonlyArray<string>;
13
+ }
14
+
15
+ export function GoalQuestionScreen({ config, onNext, initialSelected }: GoalQuestionScreenProps) {
16
+ const { labels } = useOnboardingIntl();
17
+ const [selected, setSelected] = useState<string[]>([...(initialSelected ?? [])]);
18
+ const multiSelect = config.multiSelect ?? false;
19
+
20
+ const toggle = useCallback(
21
+ (id: string) => {
22
+ setSelected((prev) => {
23
+ if (multiSelect) {
24
+ return prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id];
25
+ }
26
+ return prev.includes(id) ? [] : [id];
27
+ });
28
+ },
29
+ [multiSelect],
30
+ );
31
+
32
+ return (
33
+ <QuestionWrapper
34
+ title={config.question}
35
+ subtitle={multiSelect ? labels.selectAllThatApply : labels.selectOne}
36
+ >
37
+ <div
38
+ className="udb-onb-list"
39
+ role={multiSelect ? 'group' : 'radiogroup'}
40
+ aria-label={config.question}
41
+ >
42
+ {config.options.map((option) => (
43
+ <OptionCard
44
+ key={option.id}
45
+ label={option.label}
46
+ description={option.description}
47
+ icon={renderBundledIcon(option.iconName)}
48
+ selected={selected.includes(option.id)}
49
+ onSelect={() => toggle(option.id)}
50
+ multiSelect={multiSelect}
51
+ />
52
+ ))}
53
+ </div>
54
+ <PrimaryButton onClick={() => onNext({ selected })} disabled={selected.length === 0}>
55
+ {config.cta ?? labels.continueAction}
56
+ </PrimaryButton>
57
+ </QuestionWrapper>
58
+ );
59
+ }
@@ -0,0 +1,65 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { OptionCard } from '@umituz/web-design-system/molecules';
3
+ import { PrimaryButton } from '../PrimaryButton';
4
+ import { QuestionWrapper } from '../QuestionWrapper';
5
+ import { renderBundledIcon } from '../renderBundledIcon';
6
+ import { formatLabel, useOnboardingIntl } from '../OnboardingIntl';
7
+ import type { PainPointsScreenConfig } from '../../types/screens';
8
+
9
+ export interface PainPointsScreenProps {
10
+ config: PainPointsScreenConfig;
11
+ onNext: (data: { selected: string[] }) => void;
12
+ initialSelected?: ReadonlyArray<string>;
13
+ }
14
+
15
+ export function PainPointsScreen({ config, onNext, initialSelected }: PainPointsScreenProps) {
16
+ const { labels } = useOnboardingIntl();
17
+ const [selected, setSelected] = useState<string[]>([...(initialSelected ?? [])]);
18
+ const multiSelect = config.multiSelect ?? true;
19
+ const max = config.maxSelect;
20
+
21
+ const subtitle = multiSelect
22
+ ? max !== undefined
23
+ ? formatLabel(labels.pickUpToPattern, { max })
24
+ : labels.selectAllThatApply
25
+ : labels.pickTheOne;
26
+
27
+ const toggle = useCallback(
28
+ (id: string) => {
29
+ setSelected((prev) => {
30
+ if (multiSelect) {
31
+ if (prev.includes(id)) return prev.filter((p) => p !== id);
32
+ if (max !== undefined && prev.length >= max) return prev;
33
+ return [...prev, id];
34
+ }
35
+ return prev.includes(id) ? [] : [id];
36
+ });
37
+ },
38
+ [multiSelect, max],
39
+ );
40
+
41
+ return (
42
+ <QuestionWrapper title={config.title} subtitle={subtitle}>
43
+ <div
44
+ className="udb-onb-list"
45
+ role={multiSelect ? 'group' : 'radiogroup'}
46
+ aria-label={config.title}
47
+ >
48
+ {config.painPoints.map((point) => (
49
+ <OptionCard
50
+ key={point.id}
51
+ label={point.label}
52
+ description={point.description}
53
+ icon={renderBundledIcon(point.iconName)}
54
+ selected={selected.includes(point.id)}
55
+ onSelect={() => toggle(point.id)}
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,122 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { ShieldCheck } from 'lucide-react';
4
+ import { PrimaryButton } from '../PrimaryButton';
5
+ import { renderBundledIcon } from '../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 { cn } from '@umituz/web-design-system/utils/cn';
10
+ import { formatLabel, useOnboardingIntl } from '../OnboardingIntl';
11
+ import type { PaywallScreenConfig } from '../../types/screens';
12
+ import type { AnalyticsConfig } from '../../types/config';
13
+
14
+ export interface PaywallScreenProps {
15
+ config: PaywallScreenConfig;
16
+ onNext: (data: { accepted: boolean; trialStarted: boolean }) => void;
17
+ analytics?: AnalyticsConfig;
18
+ }
19
+
20
+ export function PaywallScreen({ config, onNext, analytics }: PaywallScreenProps) {
21
+ const { labels, locale } = useOnboardingIntl();
22
+ const { trackEvent } = useOnboardingAnalytics(analytics);
23
+ const [busy, setBusy] = useState(false);
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ useEffect(() => {
27
+ trackEvent({ name: 'paywall_viewed', stepId: config.id, timestamp: Date.now() });
28
+ }, [trackEvent, config.id]);
29
+
30
+ async function handleAccept() {
31
+ setBusy(true);
32
+ setError(null);
33
+ try {
34
+ const result = await config.onAccept();
35
+ trackEvent({
36
+ name: result.accepted ? 'paywall_accepted' : 'paywall_declined',
37
+ stepId: config.id,
38
+ price: config.price,
39
+ timestamp: Date.now(),
40
+ });
41
+ onNext(result);
42
+ } catch (e) {
43
+ const message = e instanceof Error ? e.message : labels.couldNotStartTrial;
44
+ setError(message);
45
+ } finally {
46
+ setBusy(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <div className="udb-onb-proof">
52
+ <div className="udb-onb-question__head udb-onb-question__head--center">
53
+ <h2 className="udb-onb-title udb-onb-title--display">{config.title}</h2>
54
+ <p className="udb-onb-lead">{config.subtitle}</p>
55
+ </div>
56
+ <motion.div
57
+ initial={{ scale: 0.95, opacity: 0 }}
58
+ animate={{ scale: 1, opacity: 1 }}
59
+ transition={{ duration: 0.3 }}
60
+ className="udb-onb-price"
61
+ >
62
+ <div className="udb-onb-price__amount">{config.price}</div>
63
+ <div className="udb-onb-price__terms">
64
+ {config.trialPrice !== undefined
65
+ ? formatLabel(labels.trialIntroPattern, {
66
+ price: config.trialPrice,
67
+ days: config.trialDays,
68
+ })
69
+ : formatLabel(labels.trialDaysPattern, { count: config.trialDays })}
70
+ </div>
71
+ </motion.div>
72
+ <ul className="udb-onb-list">
73
+ {config.features.map((feature, i) => (
74
+ <li
75
+ key={`${feature.text}-${i}`}
76
+ className={cn('udb-onb-feature', feature.highlighted && 'udb-onb-feature--hl')}
77
+ >
78
+ {doesIconNameResolve(feature.iconName) && (
79
+ <span className="udb-onb-feature__icon">
80
+ {renderBundledIcon(feature.iconName, 'udb-onb-icon')}
81
+ </span>
82
+ )}
83
+ <span>{feature.text}</span>
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ {config.socialProof && (
88
+ <p className="udb-onb-note">
89
+ {config.socialProof.text} ({formatCountWithLocale(config.socialProof.count, locale)}+ users)
90
+ </p>
91
+ )}
92
+ {error && (
93
+ <p role="alert" className="udb-onb-error">
94
+ {error}
95
+ </p>
96
+ )}
97
+ <div className="udb-onb-question__body">
98
+ <PrimaryButton onClick={handleAccept} loading={busy}>
99
+ {config.cta}
100
+ </PrimaryButton>
101
+ {config.secondaryCta && (
102
+ <button
103
+ type="button"
104
+ disabled={busy}
105
+ onClick={() => {
106
+ trackEvent({ name: 'paywall_declined', stepId: config.id, timestamp: Date.now() });
107
+ config.onSecondaryCta?.();
108
+ onNext({ accepted: false, trialStarted: false });
109
+ }}
110
+ className="udb-onb-link"
111
+ >
112
+ {config.secondaryCta}
113
+ </button>
114
+ )}
115
+ </div>
116
+ <p className="udb-onb-note">
117
+ <ShieldCheck className="udb-onb-icon--xs" />
118
+ {labels.cancelAnytimeNote}
119
+ </p>
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,98 @@
1
+ import { useState } from 'react';
2
+ import { PrimaryButton } from '../PrimaryButton';
3
+ import { renderBundledIcon } from '../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
+ const { status } = usePermissionPriming(config.permission);
31
+ const [isRequesting, setIsRequesting] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const iconName = doesIconNameResolve(config.iconName)
34
+ ? config.iconName
35
+ : PERMISSION_DEFAULT_ICON[config.permission];
36
+
37
+ async function handleRequest() {
38
+ setError(null);
39
+ setIsRequesting(true);
40
+ trackEvent({
41
+ name: 'permission_requested',
42
+ permission: config.permission,
43
+ stepId: config.id,
44
+ timestamp: Date.now(),
45
+ });
46
+ try {
47
+ const next = await config.onRequest();
48
+ trackEvent({
49
+ name: next === 'granted' ? 'permission_granted' : 'permission_denied',
50
+ permission: config.permission,
51
+ stepId: config.id,
52
+ timestamp: Date.now(),
53
+ });
54
+ onNext({ status: next });
55
+ } catch (e) {
56
+ trackEvent({
57
+ name: 'permission_denied',
58
+ permission: config.permission,
59
+ stepId: config.id,
60
+ timestamp: Date.now(),
61
+ });
62
+ const message = e instanceof Error ? e.message : labels.permissionRequestFailed;
63
+ setError(message);
64
+ } finally {
65
+ setIsRequesting(false);
66
+ }
67
+ }
68
+
69
+ return (
70
+ <div className="udb-onb-proof">
71
+ {doesIconNameResolve(iconName) && (
72
+ <div className="udb-onb-hero udb-onb-hero--lg" aria-hidden="true">
73
+ {renderBundledIcon(iconName, 'udb-onb-icon--lg')}
74
+ </div>
75
+ )}
76
+ <div className="udb-onb-question__head">
77
+ <h2 className="udb-onb-title">{config.title}</h2>
78
+ <p className="udb-onb-lead">{config.description}</p>
79
+ </div>
80
+ <ul className="udb-onb-list">
81
+ {config.benefits.map((benefit) => (
82
+ <li key={benefit} className="udb-onb-benefit">
83
+ <span className="udb-onb-benefit__dot" />
84
+ <span>{benefit}</span>
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ {error && (
89
+ <p role="alert" className="udb-onb-error">
90
+ {error}
91
+ </p>
92
+ )}
93
+ <PrimaryButton onClick={handleRequest} loading={isRequesting}>
94
+ {isPermissionGranted(status) ? labels.continueAction : config.cta ?? labels.allowAccess}
95
+ </PrimaryButton>
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,40 @@
1
+ import { motion } from 'framer-motion';
2
+ import { CheckCircle2 } from 'lucide-react';
3
+ import { PrimaryButton } from '../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="udb-onb-proof">
18
+ {config.title && <h2 className="udb-onb-title">{config.title}</h2>}
19
+ {config.subtitle && <p className="udb-onb-lead">{config.subtitle}</p>}
20
+ <ul className="udb-onb-solutions">
21
+ {visible.map((solution, i) => (
22
+ <motion.li
23
+ key={solution.painPointId}
24
+ initial={{ opacity: 0, x: -12 }}
25
+ animate={{ opacity: 1, x: 0 }}
26
+ transition={{ delay: i * 0.1, duration: 0.3 }}
27
+ className="udb-onb-solution"
28
+ >
29
+ <CheckCircle2 className="udb-onb-icon udb-onb-solution__icon" aria-hidden="true" />
30
+ <div className="udb-onb-solution__body">
31
+ <p>{solution.solution}</p>
32
+ {solution.statistic && <p className="udb-onb-solution__stat">{solution.statistic}</p>}
33
+ </div>
34
+ </motion.li>
35
+ ))}
36
+ </ul>
37
+ <PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
38
+ </div>
39
+ );
40
+ }