@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.
- package/package.json +19 -17
- package/src/domains/onboarding/calc/calc.test.ts +181 -0
- package/src/domains/onboarding/calc/card-stack-motion-curve.ts +33 -0
- package/src/domains/onboarding/calc/count-with-locale.ts +16 -0
- package/src/domains/onboarding/calc/duration-equal-split.ts +24 -0
- package/src/domains/onboarding/calc/elapsed-millis.ts +12 -0
- package/src/domains/onboarding/calc/index.ts +36 -0
- package/src/domains/onboarding/calc/progress-percentage.ts +37 -0
- package/src/domains/onboarding/calc/slider-value-bounds.ts +44 -0
- package/src/domains/onboarding/calc/step-index-clamp.ts +28 -0
- package/src/domains/onboarding/calc/swipe-gesture-curve.ts +67 -0
- package/src/domains/onboarding/components/OnboardingFlow.test.tsx +481 -0
- package/src/domains/onboarding/components/OnboardingFlow.tsx +361 -0
- package/src/domains/onboarding/components/OnboardingIntl.tsx +108 -0
- package/src/domains/onboarding/components/hooks/index.ts +7 -0
- package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.test.tsx +74 -0
- package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.ts +62 -0
- package/src/domains/onboarding/components/hooks/useOnboardingProgress.test.tsx +150 -0
- package/src/domains/onboarding/components/hooks/useOnboardingProgress.ts +260 -0
- package/src/domains/onboarding/components/hooks/usePermissionPriming.ts +52 -0
- package/src/domains/onboarding/components/hooks/useReducedMotion.ts +24 -0
- package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +89 -0
- package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +39 -0
- package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +59 -0
- package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +56 -0
- package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
- package/src/domains/onboarding/components/screens/PaywallScreen.tsx +138 -0
- package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +113 -0
- package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +53 -0
- package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +194 -0
- package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +79 -0
- package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +116 -0
- package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +108 -0
- package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +85 -0
- package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +52 -0
- package/src/domains/onboarding/components/screens/index.ts +28 -0
- package/src/domains/onboarding/components/shared/AnimationWrapper.tsx +73 -0
- package/src/domains/onboarding/components/shared/OnboardingErrorBoundary.tsx +81 -0
- package/src/domains/onboarding/components/shared/OptionCard.tsx +73 -0
- package/src/domains/onboarding/components/shared/PrimaryButton.tsx +58 -0
- package/src/domains/onboarding/components/shared/ProgressBar.tsx +51 -0
- package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +36 -0
- package/src/domains/onboarding/components/shared/ScreenContainer.tsx +29 -0
- package/src/domains/onboarding/components/shared/SwipeCard.test.tsx +51 -0
- package/src/domains/onboarding/components/shared/SwipeCard.tsx +106 -0
- package/src/domains/onboarding/components/shared/index.ts +16 -0
- package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +20 -0
- package/src/domains/onboarding/index.ts +182 -0
- package/src/domains/onboarding/onboarding.css +1 -0
- package/src/domains/onboarding/predicates/has-handler.ts +25 -0
- package/src/domains/onboarding/predicates/icon-name-resolves.ts +113 -0
- package/src/domains/onboarding/predicates/index.ts +26 -0
- package/src/domains/onboarding/predicates/permission-granted.ts +26 -0
- package/src/domains/onboarding/predicates/permission-supports.ts +66 -0
- package/src/domains/onboarding/predicates/predicates.test.ts +202 -0
- package/src/domains/onboarding/predicates/screen-emits-data.ts +17 -0
- package/src/domains/onboarding/predicates/storage-available.ts +22 -0
- package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +112 -0
- package/src/domains/onboarding/templates/art-onboarding.ts +81 -0
- package/src/domains/onboarding/templates/health-onboarding.ts +97 -0
- package/src/domains/onboarding/templates/index.ts +6 -0
- package/src/domains/onboarding/templates/platform-onboarding.ts +210 -0
- package/src/domains/onboarding/templates/video-onboarding.ts +100 -0
- package/src/domains/onboarding/test/setup.ts +30 -0
- package/src/domains/onboarding/theme/cn.ts +10 -0
- package/src/domains/onboarding/theme/index.css +131 -0
- package/src/domains/onboarding/theme/index.ts +12 -0
- package/src/domains/onboarding/theme/tokens.ts +122 -0
- package/src/domains/onboarding/types/config.ts +68 -0
- package/src/domains/onboarding/types/index.ts +10 -0
- package/src/domains/onboarding/types/labels.ts +83 -0
- package/src/domains/onboarding/types/screens.ts +193 -0
- package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
- package/src/domains/onboarding/utils/analytics-dispatch.ts +92 -0
- package/src/domains/onboarding/utils/default-storage-key.ts +2 -0
- package/src/domains/onboarding/utils/index.ts +13 -0
- package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
- package/src/domains/onboarding/utils/local-storage-bridge.ts +104 -0
- package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
- package/src/domains/onboarding/utils/permission-bridge.ts +95 -0
- package/src/domains/onboarding/validation/flow-structure-check.ts +28 -0
- package/src/domains/onboarding/validation/index.ts +9 -0
- package/src/domains/onboarding/validation/permission-status-check.ts +23 -0
- package/src/domains/onboarding/validation/screen-config-check.ts +105 -0
- package/src/domains/onboarding/validation/stored-progress-check.ts +68 -0
- package/src/domains/onboarding/validation/validation.test.ts +175 -0
- package/src/index.ts +0 -10
- package/src/styles/dashboard.contract.test.ts +1 -0
- package/src/styles/dashboard.css +0 -460
- package/src/domains/auth/components/AuthLayout.tsx +0 -99
- package/src/domains/auth/components/ForgotPasswordForm.tsx +0 -167
- package/src/domains/auth/components/LoginForm.tsx +0 -222
- package/src/domains/auth/components/RegisterForm.tsx +0 -281
- package/src/domains/auth/components/ResetPasswordForm.tsx +0 -173
- package/src/domains/auth/components/index.ts +0 -11
- package/src/domains/auth/components/shared/AuthErrorBanner.tsx +0 -25
- package/src/domains/auth/components/shared/EmailInput.tsx +0 -73
- package/src/domains/auth/components/shared/PasswordInput.tsx +0 -100
- package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +0 -63
- package/src/domains/auth/components/shared/SocialLoginButtons.tsx +0 -44
- package/src/domains/auth/components/shared/SocialProviderIcons.tsx +0 -48
- package/src/domains/auth/components/shared/index.ts +0 -20
- package/src/domains/auth/hooks/index.ts +0 -8
- package/src/domains/auth/hooks/useAuth.ts +0 -230
- package/src/domains/auth/index.ts +0 -62
- package/src/domains/auth/types/auth.ts +0 -302
- package/src/domains/auth/types/index.ts +0 -25
- package/src/domains/auth/utils/auth.test.ts +0 -188
- package/src/domains/auth/utils/auth.ts +0 -221
- package/src/domains/auth/utils/i18nKeys.ts +0 -109
- package/src/domains/auth/utils/index.ts +0 -29
- package/src/domains/auth/utils/secureToken.ts +0 -33
- package/src/domains/auth/utils/validationKeys.ts +0 -23
- package/src/domains/session/DemoLogin.tsx +0 -42
- package/src/domains/session/OrgSwitcher.tsx +0 -102
- package/src/domains/session/SessionContext.tsx +0 -150
- package/src/domains/session/inMemoryAuthAdapter.ts +0 -53
- package/src/domains/session/index.ts +0 -18
- package/src/domains/session/types.ts +0 -47
- package/src/domains/session/utils/i18nKeys.ts +0 -12
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Tokens - Single Source of Truth
|
|
3
|
+
* All visual values MUST be sourced from here.
|
|
4
|
+
* Per project rules: no hardcoded colors, spacing, or font sizes anywhere else.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const spacing = {
|
|
8
|
+
px: '1px',
|
|
9
|
+
0: '0',
|
|
10
|
+
0.5: '0.125rem',
|
|
11
|
+
1: '0.25rem',
|
|
12
|
+
1.5: '0.375rem',
|
|
13
|
+
2: '0.5rem',
|
|
14
|
+
2.5: '0.625rem',
|
|
15
|
+
3: '0.75rem',
|
|
16
|
+
3.5: '0.875rem',
|
|
17
|
+
4: '1rem',
|
|
18
|
+
5: '1.25rem',
|
|
19
|
+
6: '1.5rem',
|
|
20
|
+
7: '1.75rem',
|
|
21
|
+
8: '2rem',
|
|
22
|
+
10: '2.5rem',
|
|
23
|
+
12: '3rem',
|
|
24
|
+
16: '4rem',
|
|
25
|
+
20: '5rem',
|
|
26
|
+
24: '6rem',
|
|
27
|
+
32: '8rem',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export const radius = {
|
|
31
|
+
none: '0',
|
|
32
|
+
sm: '0.25rem',
|
|
33
|
+
md: '0.5rem',
|
|
34
|
+
lg: '0.75rem',
|
|
35
|
+
xl: '1rem',
|
|
36
|
+
'2xl': '1.5rem',
|
|
37
|
+
'3xl': '2rem',
|
|
38
|
+
full: '9999px',
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export const typography = {
|
|
42
|
+
fontFamily: {
|
|
43
|
+
sans: 'var(--uob-font-sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)',
|
|
44
|
+
},
|
|
45
|
+
fontSize: {
|
|
46
|
+
xs: ['0.75rem', { lineHeight: '1rem' }],
|
|
47
|
+
sm: ['0.875rem', { lineHeight: '1.25rem' }],
|
|
48
|
+
base: ['1rem', { lineHeight: '1.5rem' }],
|
|
49
|
+
lg: ['1.125rem', { lineHeight: '1.75rem' }],
|
|
50
|
+
xl: ['1.25rem', { lineHeight: '1.75rem' }],
|
|
51
|
+
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
|
52
|
+
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
|
53
|
+
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
|
54
|
+
'5xl': ['3rem', { lineHeight: '1.15' }],
|
|
55
|
+
'6xl': ['3.75rem', { lineHeight: '1.1' }],
|
|
56
|
+
},
|
|
57
|
+
fontWeight: {
|
|
58
|
+
normal: '400',
|
|
59
|
+
medium: '500',
|
|
60
|
+
semibold: '600',
|
|
61
|
+
bold: '700',
|
|
62
|
+
},
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export const zIndex = {
|
|
66
|
+
hide: -1,
|
|
67
|
+
base: 0,
|
|
68
|
+
raised: 10,
|
|
69
|
+
sticky: 20,
|
|
70
|
+
overlay: 30,
|
|
71
|
+
modal: 40,
|
|
72
|
+
toast: 50,
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
export const motion = {
|
|
76
|
+
duration: {
|
|
77
|
+
fast: '150ms',
|
|
78
|
+
normal: '250ms',
|
|
79
|
+
slow: '400ms',
|
|
80
|
+
slower: '600ms',
|
|
81
|
+
},
|
|
82
|
+
easing: {
|
|
83
|
+
linear: 'linear',
|
|
84
|
+
in: 'cubic-bezier(0.4, 0, 1, 1)',
|
|
85
|
+
out: 'cubic-bezier(0, 0, 0.2, 1)',
|
|
86
|
+
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
87
|
+
},
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
export const breakpoint = {
|
|
91
|
+
sm: '640px',
|
|
92
|
+
md: '768px',
|
|
93
|
+
lg: '1024px',
|
|
94
|
+
xl: '1280px',
|
|
95
|
+
'2xl': '1536px',
|
|
96
|
+
} as const;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Semantic color tokens — referenced via CSS custom properties.
|
|
100
|
+
* Consumers override --uob-primary-500 etc. on `.uob-flow` to rebrand
|
|
101
|
+
* without code changes. Values are space-separated RGB triplets so they
|
|
102
|
+
* compose with `rgb(var(--x) / <alpha>)`.
|
|
103
|
+
*/
|
|
104
|
+
export const colorCssVar = {
|
|
105
|
+
primary50: 'var(--uob-primary-50, 238 242 255)',
|
|
106
|
+
primary100: 'var(--uob-primary-100, 224 231 255)',
|
|
107
|
+
primary500: 'var(--uob-primary-500, 99 102 241)',
|
|
108
|
+
primary600: 'var(--uob-primary-600, 79 70 229)',
|
|
109
|
+
primary700: 'var(--uob-primary-700, 67 56 202)',
|
|
110
|
+
surface: 'var(--uob-surface, 255 255 255)',
|
|
111
|
+
surfaceMuted: 'var(--uob-surface-muted, 249 250 251)',
|
|
112
|
+
textPrimary: 'var(--uob-text-primary, 17 24 39)',
|
|
113
|
+
textMuted: 'var(--uob-text-muted, 107 114 128)',
|
|
114
|
+
border: 'var(--uob-border, 229 231 235)',
|
|
115
|
+
success: 'var(--uob-success, 16 185 129)',
|
|
116
|
+
warning: 'var(--uob-warning, 245 158 11)',
|
|
117
|
+
error: 'var(--uob-error, 239 68 68)',
|
|
118
|
+
} as const;
|
|
119
|
+
|
|
120
|
+
export type SpacingToken = keyof typeof spacing;
|
|
121
|
+
export type RadiusToken = keyof typeof radius;
|
|
122
|
+
export type FontSizeToken = keyof typeof typography.fontSize;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { DemoConfig, OnboardingScreen, PaywallScreenConfig } from './screens';
|
|
3
|
+
import type { OnboardingLabels } from './labels';
|
|
4
|
+
|
|
5
|
+
export type { OnboardingLabels } from './labels';
|
|
6
|
+
|
|
7
|
+
export type OnboardingCategory =
|
|
8
|
+
| 'health'
|
|
9
|
+
| 'art'
|
|
10
|
+
| 'video'
|
|
11
|
+
| 'security'
|
|
12
|
+
| 'education'
|
|
13
|
+
| 'platform'
|
|
14
|
+
| 'lifestyle';
|
|
15
|
+
|
|
16
|
+
export type AnalyticsProvider = 'google-analytics' | 'amplitude' | 'mixpanel' | 'custom';
|
|
17
|
+
|
|
18
|
+
export interface AnalyticsConfig {
|
|
19
|
+
trackEvents: boolean;
|
|
20
|
+
provider?: AnalyticsProvider;
|
|
21
|
+
/** Custom event sink used when provider === 'custom'. */
|
|
22
|
+
customTracker?: (event: OnboardingAnalyticsEvent) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type OnboardingAnalyticsEvent =
|
|
26
|
+
| { name: 'onboarding_started'; category: OnboardingCategory; timestamp: number }
|
|
27
|
+
| { name: 'onboarding_step_completed'; stepId: string; stepType: string; index: number; timestamp: number }
|
|
28
|
+
| { name: 'onboarding_step_skipped'; stepId: string; index: number; timestamp: number }
|
|
29
|
+
| { name: 'onboarding_completed'; durationMs: number; timestamp: number }
|
|
30
|
+
| { name: 'onboarding_abandoned'; lastStepId: string; durationMs: number; timestamp: number }
|
|
31
|
+
| { name: 'paywall_viewed'; stepId: string; timestamp: number }
|
|
32
|
+
| { name: 'paywall_accepted'; stepId: string; price: string; timestamp: number }
|
|
33
|
+
| { name: 'paywall_declined'; stepId: string; timestamp: number }
|
|
34
|
+
| { name: 'permission_requested'; permission: string; stepId: string; timestamp: number }
|
|
35
|
+
| { name: 'permission_granted'; permission: string; stepId: string; timestamp: number }
|
|
36
|
+
| { name: 'permission_denied'; permission: string; stepId: string; timestamp: number };
|
|
37
|
+
|
|
38
|
+
export interface OnboardingConfig {
|
|
39
|
+
category: OnboardingCategory;
|
|
40
|
+
screens: ReadonlyArray<OnboardingScreen>;
|
|
41
|
+
demo?: DemoConfig;
|
|
42
|
+
paywall?: PaywallScreenConfig;
|
|
43
|
+
analytics?: AnalyticsConfig;
|
|
44
|
+
/**
|
|
45
|
+
* Override any user-facing string the library renders. English defaults
|
|
46
|
+
* apply to keys you omit. See `OnboardingLabels`.
|
|
47
|
+
*/
|
|
48
|
+
labels?: Partial<OnboardingLabels>;
|
|
49
|
+
/** BCP-47 locale used for locale-aware formatting (e.g. "250.000" vs "250,000"). */
|
|
50
|
+
locale?: string;
|
|
51
|
+
/** Custom slot rendered above the flow (e.g. brand banner). */
|
|
52
|
+
headerSlot?: ReactNode;
|
|
53
|
+
/** Custom slot rendered below the flow. */
|
|
54
|
+
footerSlot?: ReactNode;
|
|
55
|
+
onComplete?: (data: OnboardingResult) => void;
|
|
56
|
+
onSkip?: () => void;
|
|
57
|
+
/** Persistence key — defaults to '@umituz/web-onboarding:progress'. */
|
|
58
|
+
storageKey?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Collected data exposed to onComplete. Typed in place of `Record<string, any>`. */
|
|
62
|
+
export interface OnboardingResult {
|
|
63
|
+
completedSteps: ReadonlyArray<string>;
|
|
64
|
+
/** Per-screen data captured during the run. */
|
|
65
|
+
collected: Record<string, unknown>;
|
|
66
|
+
durationMs: number;
|
|
67
|
+
finishedAt: Date;
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type { OnboardingScreen, OnboardingScreenType, OnboardingOption } from './screens';
|
|
2
|
+
export type {
|
|
3
|
+
OnboardingConfig,
|
|
4
|
+
OnboardingCategory,
|
|
5
|
+
AnalyticsConfig,
|
|
6
|
+
AnalyticsProvider,
|
|
7
|
+
OnboardingAnalyticsEvent,
|
|
8
|
+
OnboardingResult,
|
|
9
|
+
OnboardingLabels,
|
|
10
|
+
} from './config';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every user-facing string the library renders lives here as an overridable
|
|
3
|
+
* label. Applications serving non-English markets pass a `labels` subset (and
|
|
4
|
+
* optionally a `locale`) via `OnboardingConfig`; anything not overridden falls
|
|
5
|
+
* back to the English defaults.
|
|
6
|
+
*
|
|
7
|
+
* Labels are flat, serializable strings — no functions — so they can be fed
|
|
8
|
+
* straight from any translation system (i18next, Lingui, a CMS, …).
|
|
9
|
+
* Pattern labels use `{placeholder}` interpolation, e.g. `"Step {current} of {total}"`.
|
|
10
|
+
*/
|
|
11
|
+
export interface OnboardingLabels {
|
|
12
|
+
/** Default CTA label when a screen defines no `cta`. */
|
|
13
|
+
continueAction: string;
|
|
14
|
+
/** Footer back affordance. */
|
|
15
|
+
back: string;
|
|
16
|
+
/** Footer skip affordance (the `skipLabel` prop takes precedence). */
|
|
17
|
+
skip: string;
|
|
18
|
+
/** Primary button label while `loading` is true. */
|
|
19
|
+
loading: string;
|
|
20
|
+
/** Progress bar label. `{current}`/`{total}` are interpolated. */
|
|
21
|
+
stepOfPattern: string;
|
|
22
|
+
/** Card counter label. `{current}`/`{total}` are interpolated. */
|
|
23
|
+
countOfPattern: string;
|
|
24
|
+
|
|
25
|
+
/** Question screens */
|
|
26
|
+
selectAllThatApply: string;
|
|
27
|
+
selectOne: string;
|
|
28
|
+
/** Multi-select hint with a cap. `{max}` is interpolated. */
|
|
29
|
+
pickUpToPattern: string;
|
|
30
|
+
/** Single-select hint for the pain-points screen. */
|
|
31
|
+
pickTheOne: string;
|
|
32
|
+
/** Preferences screen subtitle. */
|
|
33
|
+
tailorExperience: string;
|
|
34
|
+
|
|
35
|
+
/** Swipe / tinder screens */
|
|
36
|
+
agree: string;
|
|
37
|
+
disagree: string;
|
|
38
|
+
/** How to operate the swipe card (gesture + keyboard). */
|
|
39
|
+
swipeHint: string;
|
|
40
|
+
/** Shown when every card has been decided. */
|
|
41
|
+
allDone: string;
|
|
42
|
+
|
|
43
|
+
/** Comparison screen column headings */
|
|
44
|
+
withoutUs: string;
|
|
45
|
+
withUs: string;
|
|
46
|
+
|
|
47
|
+
/** Social proof carousel controls */
|
|
48
|
+
previous: string;
|
|
49
|
+
nextAction: string;
|
|
50
|
+
|
|
51
|
+
/** App demo screen CTA. */
|
|
52
|
+
continueWithResult: string;
|
|
53
|
+
|
|
54
|
+
/** Permission priming */
|
|
55
|
+
allowAccess: string;
|
|
56
|
+
permissionRequestFailed: string;
|
|
57
|
+
|
|
58
|
+
/** Account gate */
|
|
59
|
+
continueWithEmail: string;
|
|
60
|
+
continueWithGoogle: string;
|
|
61
|
+
continueWithApple: string;
|
|
62
|
+
continueWithGitHub: string;
|
|
63
|
+
skipForNow: string;
|
|
64
|
+
signInFailed: string;
|
|
65
|
+
|
|
66
|
+
/** Paywall */
|
|
67
|
+
couldNotStartTrial: string;
|
|
68
|
+
/** `{count}` interpolated, e.g. "7-day free trial". */
|
|
69
|
+
trialDaysPattern: string;
|
|
70
|
+
/** `{price}`/`{days}` interpolated, e.g. "$0.99 for the first 7 days". */
|
|
71
|
+
trialIntroPattern: string;
|
|
72
|
+
/** Reassurance line under the paywall CAs. */
|
|
73
|
+
cancelAnytimeNote: string;
|
|
74
|
+
|
|
75
|
+
/** Value delivery sharing */
|
|
76
|
+
share: string;
|
|
77
|
+
copied: string;
|
|
78
|
+
|
|
79
|
+
/** Error boundary default UI */
|
|
80
|
+
somethingWentWrong: string;
|
|
81
|
+
unexpectedError: string;
|
|
82
|
+
tryAgain: string;
|
|
83
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discriminated union: every screen is one of 14 strictly-typed shapes.
|
|
5
|
+
* Eliminates `Record<string, any>` masking (project rule: no `any`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface BaseScreen<T extends string, D = unknown> {
|
|
9
|
+
id: string;
|
|
10
|
+
type: T;
|
|
11
|
+
title?: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
cta?: string;
|
|
14
|
+
onCta?: () => void;
|
|
15
|
+
data?: D;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OnboardingOption {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
iconName?: string; // lucide-react icon name; resolved by UI layer
|
|
23
|
+
image?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WelcomeScreenConfig = BaseScreen<'welcome'> & {
|
|
27
|
+
heroIconName?: string;
|
|
28
|
+
highlight?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type GoalQuestionScreenConfig = BaseScreen<'goal-question', string[]> & {
|
|
32
|
+
question: string;
|
|
33
|
+
options: OnboardingOption[];
|
|
34
|
+
multiSelect?: boolean;
|
|
35
|
+
onChange: (selectedIds: string[]) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type PainPointsScreenConfig = BaseScreen<'pain-points', string[]> & {
|
|
39
|
+
title: string;
|
|
40
|
+
painPoints: OnboardingOption[];
|
|
41
|
+
multiSelect?: boolean;
|
|
42
|
+
maxSelect?: number;
|
|
43
|
+
onChange: (selectedIds: string[]) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type SocialProofScreenConfig = BaseScreen<'social-proof'> & {
|
|
47
|
+
testimonials: ReadonlyArray<{
|
|
48
|
+
name: string;
|
|
49
|
+
role: string;
|
|
50
|
+
content: string;
|
|
51
|
+
avatar?: string;
|
|
52
|
+
rating?: number;
|
|
53
|
+
}>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type TinderCardsScreenConfig = BaseScreen<'tinder-cards', string[]> & {
|
|
57
|
+
statements: ReadonlyArray<{
|
|
58
|
+
id: string;
|
|
59
|
+
text: string;
|
|
60
|
+
category: string;
|
|
61
|
+
}>;
|
|
62
|
+
onChange: (likedIds: string[]) => void;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type PersonalizedSolutionScreenConfig = BaseScreen<'personalized-solution'> & {
|
|
66
|
+
solutions: ReadonlyArray<{
|
|
67
|
+
painPointId: string;
|
|
68
|
+
solution: string;
|
|
69
|
+
statistic?: string;
|
|
70
|
+
}>;
|
|
71
|
+
selectedPainPointIds: ReadonlyArray<string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type ComparisonScreenConfig = BaseScreen<'comparison'> & {
|
|
75
|
+
before: ReadonlyArray<string>;
|
|
76
|
+
after: ReadonlyArray<string>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type PreferenceItem = {
|
|
80
|
+
id: string;
|
|
81
|
+
label: string;
|
|
82
|
+
type: 'select' | 'multi-select' | 'toggle' | 'slider';
|
|
83
|
+
options?: OnboardingOption[];
|
|
84
|
+
min?: number;
|
|
85
|
+
max?: number;
|
|
86
|
+
defaultValue?: string | number | string[] | boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type PreferencesScreenConfig = BaseScreen<
|
|
90
|
+
'preferences',
|
|
91
|
+
Record<string, string | number | string[] | boolean>
|
|
92
|
+
> & {
|
|
93
|
+
title: string;
|
|
94
|
+
preferences: ReadonlyArray<PreferenceItem>;
|
|
95
|
+
onChange: (values: Record<string, string | number | string[] | boolean>) => void;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type PermissionType = 'camera' | 'microphone' | 'location' | 'notifications' | 'photos' | 'contacts';
|
|
99
|
+
|
|
100
|
+
export type PermissionPrimingScreenConfig = BaseScreen<'permission-priming', PermissionStatus> & {
|
|
101
|
+
permission: PermissionType;
|
|
102
|
+
description: string;
|
|
103
|
+
benefits: ReadonlyArray<string>;
|
|
104
|
+
iconName?: string;
|
|
105
|
+
onRequest: () => Promise<PermissionStatus>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unsupported';
|
|
109
|
+
|
|
110
|
+
export type ProcessingScreenConfig = BaseScreen<'processing'> & {
|
|
111
|
+
title: string;
|
|
112
|
+
steps: ReadonlyArray<string>;
|
|
113
|
+
durationMs?: number;
|
|
114
|
+
onComplete: () => void;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type DemoConfig<TProps = Record<string, unknown>, TResult = unknown> = {
|
|
118
|
+
component: React.ComponentType<TProps>;
|
|
119
|
+
props?: TProps;
|
|
120
|
+
onSave: (result: TResult) => void;
|
|
121
|
+
durationMs?: number;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export type AppDemoScreenConfig = BaseScreen<'app-demo', unknown> & {
|
|
125
|
+
demo: DemoConfig;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type ValueDeliveryScreenConfig<TResult = unknown> = BaseScreen<
|
|
129
|
+
'value-delivery',
|
|
130
|
+
TResult
|
|
131
|
+
> & {
|
|
132
|
+
title: string;
|
|
133
|
+
result: TResult;
|
|
134
|
+
/** Optional share affordance. Uses the Web Share API with a clipboard fallback. */
|
|
135
|
+
shareConfig?: {
|
|
136
|
+
text: string;
|
|
137
|
+
url?: string;
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type SignInMethod = 'email' | 'google' | 'apple' | 'github';
|
|
142
|
+
|
|
143
|
+
export type AccountGateScreenConfig = BaseScreen<'account-gate', { signedIn: boolean; method?: SignInMethod }> & {
|
|
144
|
+
optional: boolean;
|
|
145
|
+
signInMethod?: SignInMethod;
|
|
146
|
+
skipText?: string;
|
|
147
|
+
onSignIn: (method: SignInMethod) => Promise<{ signedIn: boolean; method?: SignInMethod }>;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type PaywallFeature = {
|
|
151
|
+
iconName: string; // lucide-react
|
|
152
|
+
text: string;
|
|
153
|
+
highlighted?: boolean;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export type PaywallScreenConfig = BaseScreen<
|
|
157
|
+
'paywall',
|
|
158
|
+
{ accepted: boolean; trialStarted: boolean }
|
|
159
|
+
> & {
|
|
160
|
+
subtitle: string;
|
|
161
|
+
price: string;
|
|
162
|
+
trialDays: number;
|
|
163
|
+
trialPrice?: string;
|
|
164
|
+
features: ReadonlyArray<PaywallFeature>;
|
|
165
|
+
socialProof?: {
|
|
166
|
+
text: string;
|
|
167
|
+
count: number;
|
|
168
|
+
};
|
|
169
|
+
cta: string;
|
|
170
|
+
secondaryCta?: string;
|
|
171
|
+
onSecondaryCta?: () => void;
|
|
172
|
+
onAccept: () => Promise<{ accepted: boolean; trialStarted: boolean }>;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export type OnboardingScreen =
|
|
176
|
+
| WelcomeScreenConfig
|
|
177
|
+
| GoalQuestionScreenConfig
|
|
178
|
+
| PainPointsScreenConfig
|
|
179
|
+
| SocialProofScreenConfig
|
|
180
|
+
| TinderCardsScreenConfig
|
|
181
|
+
| PersonalizedSolutionScreenConfig
|
|
182
|
+
| ComparisonScreenConfig
|
|
183
|
+
| PreferencesScreenConfig
|
|
184
|
+
| PermissionPrimingScreenConfig
|
|
185
|
+
| ProcessingScreenConfig
|
|
186
|
+
| AppDemoScreenConfig
|
|
187
|
+
| ValueDeliveryScreenConfig
|
|
188
|
+
| AccountGateScreenConfig
|
|
189
|
+
| PaywallScreenConfig;
|
|
190
|
+
|
|
191
|
+
export type OnboardingScreenType = OnboardingScreen['type'];
|
|
192
|
+
|
|
193
|
+
export type { ReactNode };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { dispatchAnalyticsEvent, trackOnboardingEvent } from './analytics-dispatch';
|
|
3
|
+
import type { OnboardingAnalyticsEvent } from '../types/config';
|
|
4
|
+
|
|
5
|
+
const event: OnboardingAnalyticsEvent = {
|
|
6
|
+
name: 'onboarding_started',
|
|
7
|
+
category: 'health',
|
|
8
|
+
timestamp: 0,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
delete (window as { gtag?: unknown }).gtag;
|
|
14
|
+
delete (window as { amplitude?: unknown }).amplitude;
|
|
15
|
+
delete (window as { mixpanel?: unknown }).mixpanel;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('analytics-dispatch — the "never throws" contract', () => {
|
|
19
|
+
it('routes to the custom tracker when provided', () => {
|
|
20
|
+
const tracker = vi.fn();
|
|
21
|
+
dispatchAnalyticsEvent(event, 'custom', tracker);
|
|
22
|
+
expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('warns and drops (instead of throwing) for custom without a tracker', () => {
|
|
26
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
27
|
+
expect(() => dispatchAnalyticsEvent(event, 'custom', undefined)).not.toThrow();
|
|
28
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
29
|
+
expect(warn.mock.calls[0][0]).toContain('customTracker');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('never throws when the custom tracker itself throws', () => {
|
|
33
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
34
|
+
const tracker = () => {
|
|
35
|
+
throw new Error('tracker outage');
|
|
36
|
+
};
|
|
37
|
+
expect(() => dispatchAnalyticsEvent(event, 'custom', tracker)).not.toThrow();
|
|
38
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(error.mock.calls[0][0]).toContain('dropped');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is a no-op without a provider', () => {
|
|
43
|
+
const tracker = vi.fn();
|
|
44
|
+
expect(() => dispatchAnalyticsEvent(event, undefined, tracker)).not.toThrow();
|
|
45
|
+
expect(tracker).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('analytics-dispatch — provider routing', () => {
|
|
50
|
+
it('dispatches to gtag for google-analytics', () => {
|
|
51
|
+
const gtag = vi.fn();
|
|
52
|
+
(window as { gtag?: unknown }).gtag = gtag;
|
|
53
|
+
dispatchAnalyticsEvent(event, 'google-analytics', undefined);
|
|
54
|
+
expect(gtag).toHaveBeenCalledExactlyOnceWith('event', 'onboarding_started', event);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('dispatches to amplitude via getInstance().logEvent', () => {
|
|
58
|
+
const logEvent = vi.fn();
|
|
59
|
+
(window as { amplitude?: unknown }).amplitude = {
|
|
60
|
+
getInstance: () => ({ logEvent }),
|
|
61
|
+
};
|
|
62
|
+
dispatchAnalyticsEvent(event, 'amplitude', undefined);
|
|
63
|
+
expect(logEvent).toHaveBeenCalledExactlyOnceWith('onboarding_started', event);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('dispatches to mixpanel.track', () => {
|
|
67
|
+
const track = vi.fn();
|
|
68
|
+
(window as { mixpanel?: unknown }).mixpanel = { track };
|
|
69
|
+
dispatchAnalyticsEvent(event, 'mixpanel', undefined);
|
|
70
|
+
expect(track).toHaveBeenCalledExactlyOnceWith('onboarding_started', event);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('survives a provider global that throws', () => {
|
|
74
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
75
|
+
(window as { gtag?: unknown }).gtag = () => {
|
|
76
|
+
throw new Error('gtag exploded');
|
|
77
|
+
};
|
|
78
|
+
expect(() => dispatchAnalyticsEvent(event, 'google-analytics', undefined)).not.toThrow();
|
|
79
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('trackOnboardingEvent', () => {
|
|
84
|
+
it('forwards provider + tracker options', () => {
|
|
85
|
+
const tracker = vi.fn();
|
|
86
|
+
trackOnboardingEvent(event, { provider: 'custom', customTracker: tracker });
|
|
87
|
+
expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('defaults to a silent no-op', () => {
|
|
91
|
+
expect(() => trackOnboardingEvent(event)).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics dispatch service.
|
|
3
|
+
* Routes a typed onboarding event to the configured provider.
|
|
4
|
+
* Provider-specific dispatch is isolated here; callers stay provider-agnostic.
|
|
5
|
+
*
|
|
6
|
+
* Contract: dispatch NEVER throws. Analytics is an observation channel — a
|
|
7
|
+
* broken tracker must not take down the onboarding funnel. Failures are
|
|
8
|
+
* surfaced to the console (the library's documented error channel) and
|
|
9
|
+
* dropped.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
AnalyticsProvider,
|
|
14
|
+
OnboardingAnalyticsEvent,
|
|
15
|
+
} from '../types/config';
|
|
16
|
+
|
|
17
|
+
/** Provider globals are read through a LOCAL view of `window` — this package
|
|
18
|
+
* must never augment the global `Window` type for its consumers. */
|
|
19
|
+
type AnalyticsWindow = Window & {
|
|
20
|
+
gtag?: (...args: unknown[]) => void;
|
|
21
|
+
amplitude?: {
|
|
22
|
+
getInstance: () => { logEvent: (name: string, props: Record<string, unknown>) => void };
|
|
23
|
+
};
|
|
24
|
+
mixpanel?: { track: (name: string, props: Record<string, unknown>) => void };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function dispatchToProvider(event: OnboardingAnalyticsEvent, provider: AnalyticsProvider): void {
|
|
28
|
+
if (typeof window === 'undefined') return; // SSR / non-browser: nothing to do.
|
|
29
|
+
const win = window as AnalyticsWindow;
|
|
30
|
+
switch (provider) {
|
|
31
|
+
case 'google-analytics':
|
|
32
|
+
win.gtag?.('event', event.name, event as unknown as Record<string, unknown>);
|
|
33
|
+
return;
|
|
34
|
+
case 'amplitude': {
|
|
35
|
+
const amp = win.amplitude?.getInstance();
|
|
36
|
+
amp?.logEvent(event.name, event as unknown as Record<string, unknown>);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
case 'mixpanel':
|
|
40
|
+
win.mixpanel?.track(event.name, event as unknown as Record<string, unknown>);
|
|
41
|
+
return;
|
|
42
|
+
case 'custom':
|
|
43
|
+
return; // Handled by the caller via the customTracker path.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dispatchToCustomTracker(
|
|
48
|
+
event: OnboardingAnalyticsEvent,
|
|
49
|
+
customTracker: ((e: OnboardingAnalyticsEvent) => void) | undefined,
|
|
50
|
+
): void {
|
|
51
|
+
if (typeof customTracker !== 'function') {
|
|
52
|
+
|
|
53
|
+
console.warn(
|
|
54
|
+
'[web-onboarding] analytics provider is "custom" but no customTracker was provided; event dropped:',
|
|
55
|
+
event.name,
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
customTracker(event);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Routes an event to the configured provider. Never throws: provider failures
|
|
64
|
+
* are logged and dropped so an analytics outage cannot break the flow UI.
|
|
65
|
+
*/
|
|
66
|
+
export function dispatchAnalyticsEvent(
|
|
67
|
+
event: OnboardingAnalyticsEvent,
|
|
68
|
+
provider: AnalyticsProvider | undefined,
|
|
69
|
+
customTracker: ((e: OnboardingAnalyticsEvent) => void) | undefined,
|
|
70
|
+
): void {
|
|
71
|
+
try {
|
|
72
|
+
if (provider === 'custom') {
|
|
73
|
+
dispatchToCustomTracker(event, customTracker);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!provider) return;
|
|
77
|
+
dispatchToProvider(event, provider);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
|
|
80
|
+
console.error('[web-onboarding] analytics dispatch failed; event dropped:', event.name, error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function trackOnboardingEvent(
|
|
85
|
+
event: OnboardingAnalyticsEvent,
|
|
86
|
+
options: {
|
|
87
|
+
provider?: AnalyticsProvider;
|
|
88
|
+
customTracker?: (e: OnboardingAnalyticsEvent) => void;
|
|
89
|
+
} = {},
|
|
90
|
+
): void {
|
|
91
|
+
dispatchAnalyticsEvent(event, options.provider, options.customTracker);
|
|
92
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { trackOnboardingEvent, dispatchAnalyticsEvent } from './analytics-dispatch';
|
|
2
|
+
export {
|
|
3
|
+
saveOnboardingProgress,
|
|
4
|
+
loadOnboardingProgress,
|
|
5
|
+
clearOnboardingProgress,
|
|
6
|
+
buildOnboardingResult,
|
|
7
|
+
} from './local-storage-bridge';
|
|
8
|
+
export type { RehydratedProgress } from './local-storage-bridge';
|
|
9
|
+
export { queryPermissionStatus, requestPermission } from './permission-bridge';
|
|
10
|
+
export { defaultStorageKey } from './default-storage-key';
|
|
11
|
+
|
|
12
|
+
// Re-export validation for convenience (the public API treats validation as a util).
|
|
13
|
+
export { checkFlowStructure, checkScreenConfig, checkStoredProgress } from '../validation';
|