@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.
- package/README.md +47 -73
- package/package.json +15 -7
- package/src/config/DashboardConfig.ts +19 -68
- package/src/domains/analytics/components/index.ts +0 -6
- package/src/domains/analytics/hooks/index.ts +0 -6
- package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
- package/src/domains/analytics/index.ts +0 -10
- package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
- package/src/domains/analytics/services/PerformanceService.ts +27 -138
- package/src/domains/analytics/services/index.ts +0 -4
- package/src/domains/analytics/types/analytics.ts +100 -270
- package/src/domains/analytics/types/index.ts +0 -6
- package/src/domains/analytics/utils/analytics.test.ts +2 -9
- package/src/domains/analytics/utils/analytics.ts +1 -22
- package/src/domains/analytics/utils/index.ts +0 -6
- package/src/domains/calendar/config.ts +0 -7
- package/src/domains/calendar/hooks/index.ts +0 -4
- package/src/domains/calendar/hooks/useCalendar.ts +7 -27
- package/src/domains/calendar/index.ts +0 -4
- package/src/domains/calendar/services/CalendarService.ts +0 -21
- package/src/domains/calendar/services/index.ts +0 -4
- package/src/domains/calendar/types/calendar.types.ts +9 -53
- package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
- package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
- package/src/domains/calendar/utils/index.ts +0 -26
- package/src/domains/layouts/components/index.ts +0 -4
- package/src/domains/layouts/hooks/dashboard.ts +0 -21
- package/src/domains/layouts/hooks/index.ts +0 -4
- package/src/domains/layouts/index.ts +0 -6
- package/src/domains/layouts/theme/default.ts +0 -12
- package/src/domains/layouts/theme/index.ts +0 -4
- package/src/domains/layouts/theme/presets.ts +0 -9
- package/src/domains/layouts/theme/utils.ts +0 -15
- package/src/domains/layouts/types/index.ts +0 -4
- package/src/domains/layouts/types/layout.ts +18 -56
- package/src/domains/layouts/types/notification.ts +4 -17
- package/src/domains/layouts/types/sidebar.ts +8 -28
- package/src/domains/layouts/types/theme.ts +23 -59
- package/src/domains/layouts/types/user.ts +9 -30
- package/src/domains/layouts/utils/dashboard.ts +4 -48
- package/src/domains/layouts/utils/index.ts +0 -4
- package/src/domains/modules/build.tsx +2 -16
- package/src/domains/modules/types.ts +8 -23
- package/src/domains/onboarding/calc/calc.test.ts +173 -0
- package/src/domains/onboarding/calc/card-stack-motion-curve.ts +27 -0
- package/src/domains/onboarding/calc/count-with-locale.ts +8 -0
- package/src/domains/onboarding/calc/duration-equal-split.ts +12 -0
- package/src/domains/onboarding/calc/elapsed-millis.ts +6 -0
- package/src/domains/onboarding/calc/index.ts +36 -0
- package/src/domains/onboarding/calc/progress-percentage.ts +27 -0
- package/src/domains/onboarding/calc/slider-value-bounds.ts +34 -0
- package/src/domains/onboarding/calc/step-index-clamp.ts +19 -0
- package/src/domains/onboarding/calc/swipe-gesture-curve.ts +54 -0
- package/src/domains/onboarding/components/AnimationWrapper.tsx +69 -0
- package/src/domains/onboarding/components/OnboardingErrorBoundary.tsx +61 -0
- package/src/domains/onboarding/components/OnboardingFlow.test.tsx +459 -0
- package/src/domains/onboarding/components/OnboardingFlow.tsx +313 -0
- package/src/domains/onboarding/components/OnboardingIntl.tsx +98 -0
- package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
- package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
- package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
- package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
- package/src/domains/onboarding/components/SwipeCard.test.tsx +49 -0
- package/src/domains/onboarding/components/SwipeCard.tsx +88 -0
- package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
- package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +79 -0
- package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +33 -0
- package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +49 -0
- package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +59 -0
- package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
- package/src/domains/onboarding/components/screens/PaywallScreen.tsx +122 -0
- package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +98 -0
- package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +40 -0
- package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +180 -0
- package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +63 -0
- package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +87 -0
- package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +91 -0
- package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +69 -0
- package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +32 -0
- package/src/domains/onboarding/components/screens/index.ts +28 -0
- package/src/domains/onboarding/hooks/index.ts +7 -0
- package/src/domains/onboarding/hooks/useOnboardingAnalytics.test.tsx +74 -0
- package/src/domains/onboarding/hooks/useOnboardingAnalytics.ts +49 -0
- package/src/domains/onboarding/hooks/useOnboardingProgress.test.tsx +150 -0
- package/src/domains/onboarding/hooks/useOnboardingProgress.ts +249 -0
- package/src/domains/onboarding/hooks/usePermissionPriming.ts +45 -0
- package/src/domains/onboarding/hooks/useReducedMotion.ts +19 -0
- package/src/domains/onboarding/index.ts +153 -0
- package/src/domains/onboarding/predicates/has-handler.ts +21 -0
- package/src/domains/onboarding/predicates/icon-name-resolves.ts +97 -0
- package/src/domains/onboarding/predicates/index.ts +26 -0
- package/src/domains/onboarding/predicates/permission-granted.ts +21 -0
- package/src/domains/onboarding/predicates/permission-supports.ts +56 -0
- package/src/domains/onboarding/predicates/predicates.test.ts +188 -0
- package/src/domains/onboarding/predicates/screen-emits-data.ts +13 -0
- package/src/domains/onboarding/predicates/storage-available.ts +14 -0
- package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +92 -0
- package/src/domains/onboarding/templates/art-onboarding.ts +71 -0
- package/src/domains/onboarding/templates/health-onboarding.ts +92 -0
- package/src/domains/onboarding/templates/index.ts +6 -0
- package/src/domains/onboarding/templates/platform-onboarding.ts +201 -0
- package/src/domains/onboarding/templates/video-onboarding.ts +95 -0
- package/src/domains/onboarding/test/setup.ts +23 -0
- package/src/domains/onboarding/types/config.ts +57 -0
- package/src/domains/onboarding/types/index.ts +10 -0
- package/src/domains/onboarding/types/labels.ts +49 -0
- package/src/domains/onboarding/types/screens.ts +185 -0
- package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
- package/src/domains/onboarding/utils/analytics-dispatch.ts +73 -0
- package/src/domains/onboarding/utils/default-storage-key.ts +1 -0
- package/src/domains/onboarding/utils/index.ts +12 -0
- package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
- package/src/domains/onboarding/utils/local-storage-bridge.ts +84 -0
- package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
- package/src/domains/onboarding/utils/permission-bridge.ts +87 -0
- package/src/domains/onboarding/validation/flow-structure-check.ts +22 -0
- package/src/domains/onboarding/validation/index.ts +9 -0
- package/src/domains/onboarding/validation/permission-status-check.ts +18 -0
- package/src/domains/onboarding/validation/screen-config-check.ts +100 -0
- package/src/domains/onboarding/validation/stored-progress-check.ts +59 -0
- package/src/domains/onboarding/validation/validation.test.ts +170 -0
- package/src/domains/permissions/hasPermission.ts +0 -8
- package/src/domains/permissions/types.ts +0 -20
- package/src/domains/settings/components/index.ts +0 -4
- package/src/domains/settings/hooks/index.ts +0 -4
- package/src/domains/settings/hooks/useSettings.ts +1 -26
- package/src/domains/settings/index.ts +0 -6
- package/src/domains/settings/types/index.ts +0 -4
- package/src/domains/settings/types/settings.ts +23 -67
- package/src/domains/settings/utils/index.ts +0 -4
- package/src/domains/settings/utils/settings.ts +0 -45
- package/src/styles/dashboard.contract.test.ts +1 -0
- 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
|
+
}
|