@umituz/web-dashboard 6.0.0 → 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 -7
- 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/styles/dashboard.contract.test.ts +1 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useOnboardingProgress } from './useOnboardingProgress';
|
|
4
|
+
import { defaultStorageKey } from '../../utils/default-storage-key';
|
|
5
|
+
import type { OnboardingScreen } from '../../types/screens';
|
|
6
|
+
|
|
7
|
+
function makeScreens(count: number): OnboardingScreen[] {
|
|
8
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
9
|
+
id: `s${i}`,
|
|
10
|
+
type: 'welcome',
|
|
11
|
+
title: `Step ${i}`,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
window.localStorage.clear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('useOnboardingProgress — state machine (§1.1, §1.2)', () => {
|
|
20
|
+
it('starts on the first screen', () => {
|
|
21
|
+
const { result } = renderHook(() =>
|
|
22
|
+
useOnboardingProgress({ screens: makeScreens(3) }),
|
|
23
|
+
);
|
|
24
|
+
expect(result.current.currentStep).toBe(0);
|
|
25
|
+
expect(result.current.isFirst).toBe(true);
|
|
26
|
+
expect(result.current.isLast).toBe(false);
|
|
27
|
+
expect(result.current.isFinished).toBe(false);
|
|
28
|
+
expect(result.current.totalSteps).toBe(3);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('advances, records completion, and collects data', () => {
|
|
32
|
+
const { result } = renderHook(() =>
|
|
33
|
+
useOnboardingProgress({ screens: makeScreens(3), ephemeral: true }),
|
|
34
|
+
);
|
|
35
|
+
act(() => result.current.next({ selected: ['x'] }));
|
|
36
|
+
expect(result.current.currentStep).toBe(1);
|
|
37
|
+
expect(result.current.completedSteps).toEqual(['s0']);
|
|
38
|
+
expect(result.current.collected).toEqual({ s0: { selected: ['x'] } });
|
|
39
|
+
expect(result.current.isFirst).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('retreats with prev and stops at the first screen', () => {
|
|
43
|
+
const { result } = renderHook(() =>
|
|
44
|
+
useOnboardingProgress({ screens: makeScreens(3), ephemeral: true }),
|
|
45
|
+
);
|
|
46
|
+
act(() => result.current.next());
|
|
47
|
+
act(() => result.current.next());
|
|
48
|
+
act(() => result.current.prev());
|
|
49
|
+
expect(result.current.currentStep).toBe(1);
|
|
50
|
+
act(() => result.current.prev());
|
|
51
|
+
act(() => result.current.prev()); // below zero — must clamp
|
|
52
|
+
expect(result.current.currentStep).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('finish() marks the flow finished and returns a result', () => {
|
|
56
|
+
const { result } = renderHook(() =>
|
|
57
|
+
useOnboardingProgress({ screens: makeScreens(2), ephemeral: true }),
|
|
58
|
+
);
|
|
59
|
+
act(() => result.current.next());
|
|
60
|
+
let returned: ReturnType<typeof result.current.finish> | undefined;
|
|
61
|
+
act(() => {
|
|
62
|
+
returned = result.current.finish({ final: true });
|
|
63
|
+
});
|
|
64
|
+
expect(result.current.isFinished).toBe(true);
|
|
65
|
+
expect(result.current.result).not.toBeNull();
|
|
66
|
+
expect(result.current.result?.durationMs).toBeGreaterThanOrEqual(0);
|
|
67
|
+
expect(returned?.collected).toEqual({ s1: { final: true } });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('next() on the last screen finishes the flow', () => {
|
|
71
|
+
const { result } = renderHook(() =>
|
|
72
|
+
useOnboardingProgress({ screens: makeScreens(2), ephemeral: true }),
|
|
73
|
+
);
|
|
74
|
+
act(() => result.current.next());
|
|
75
|
+
act(() => result.current.next()); // last screen -> finish
|
|
76
|
+
expect(result.current.isFinished).toBe(true);
|
|
77
|
+
expect(result.current.completedSteps).toEqual(['s0', 's1']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('reset() rewinds and clears stored progress (§16.1)', () => {
|
|
81
|
+
const { result } = renderHook(() =>
|
|
82
|
+
useOnboardingProgress({ screens: makeScreens(3), storageKey: 'k' }),
|
|
83
|
+
);
|
|
84
|
+
act(() => result.current.next({ selected: ['x'] }));
|
|
85
|
+
expect(JSON.parse(localStorage.getItem('k') ?? '{}').currentStep).toBe(1);
|
|
86
|
+
act(() => result.current.reset());
|
|
87
|
+
expect(result.current.currentStep).toBe(0);
|
|
88
|
+
expect(result.current.completedSteps).toEqual([]);
|
|
89
|
+
expect(result.current.collected).toEqual({});
|
|
90
|
+
// The stale checkpoint is gone; the persist effect re-saves a fresh one.
|
|
91
|
+
const stored = JSON.parse(localStorage.getItem('k') ?? 'null');
|
|
92
|
+
expect(stored).not.toBeNull();
|
|
93
|
+
expect(stored.currentStep).toBe(0);
|
|
94
|
+
expect(stored.completedSteps).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('goTo() clamps out-of-range indices', () => {
|
|
98
|
+
const { result } = renderHook(() =>
|
|
99
|
+
useOnboardingProgress({ screens: makeScreens(3), ephemeral: true }),
|
|
100
|
+
);
|
|
101
|
+
act(() => result.current.goTo(99));
|
|
102
|
+
expect(result.current.currentStep).toBe(2);
|
|
103
|
+
act(() => result.current.goTo(-5));
|
|
104
|
+
expect(result.current.currentStep).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('useOnboardingProgress — persistence (§1.4, §1.5, §1.6)', () => {
|
|
109
|
+
it('persists after each transition and rehydrates on a fresh mount', () => {
|
|
110
|
+
const screens = makeScreens(4);
|
|
111
|
+
const first = renderHook(() => useOnboardingProgress({ screens, storageKey: 'resume' }));
|
|
112
|
+
act(() => first.result.current.next({ selected: ['a'] }));
|
|
113
|
+
act(() => first.result.current.next());
|
|
114
|
+
first.unmount();
|
|
115
|
+
expect(JSON.parse(localStorage.getItem('resume') ?? '{}').currentStep).toBe(2);
|
|
116
|
+
|
|
117
|
+
const second = renderHook(() => useOnboardingProgress({ screens, storageKey: 'resume' }));
|
|
118
|
+
expect(second.result.current.currentStep).toBe(2);
|
|
119
|
+
expect(second.result.current.collected).toEqual({ s0: { selected: ['a'] } });
|
|
120
|
+
second.unmount();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('never touches storage in ephemeral mode', () => {
|
|
124
|
+
const { result } = renderHook(() =>
|
|
125
|
+
useOnboardingProgress({ screens: makeScreens(2), ephemeral: true }),
|
|
126
|
+
);
|
|
127
|
+
act(() => result.current.next());
|
|
128
|
+
expect(localStorage.getItem(defaultStorageKey)).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rehydration past the end of a shortened flow starts fresh', () => {
|
|
132
|
+
// Progress stored for 4 screens; the app now ships 2. currentStep=3 is
|
|
133
|
+
// out of bounds and must not be restored.
|
|
134
|
+
localStorage.setItem(
|
|
135
|
+
'short',
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
version: 1,
|
|
138
|
+
currentStep: 3,
|
|
139
|
+
completedSteps: ['s0', 's1', 's2'],
|
|
140
|
+
collected: {},
|
|
141
|
+
startedAt: new Date().toISOString(),
|
|
142
|
+
lastUpdated: new Date().toISOString(),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
const { result } = renderHook(() =>
|
|
146
|
+
useOnboardingProgress({ screens: makeScreens(2), storageKey: 'short' }),
|
|
147
|
+
);
|
|
148
|
+
expect(result.current.currentStep).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useReducer } from 'react';
|
|
2
|
+
import type { OnboardingScreen } from '../../types/screens';
|
|
3
|
+
import type { OnboardingResult } from '../../types/config';
|
|
4
|
+
import {
|
|
5
|
+
advanceStepIndex,
|
|
6
|
+
clampStepIndex,
|
|
7
|
+
retreatStepIndex,
|
|
8
|
+
} from '../../calc/step-index-clamp';
|
|
9
|
+
import {
|
|
10
|
+
computeProgressFraction,
|
|
11
|
+
computeRemainingSteps,
|
|
12
|
+
} from '../../calc/progress-percentage';
|
|
13
|
+
import {
|
|
14
|
+
buildOnboardingResult,
|
|
15
|
+
clearOnboardingProgress,
|
|
16
|
+
loadOnboardingProgress,
|
|
17
|
+
saveOnboardingProgress,
|
|
18
|
+
} from '../../utils/local-storage-bridge';
|
|
19
|
+
|
|
20
|
+
interface ProgressState {
|
|
21
|
+
currentStep: number;
|
|
22
|
+
totalSteps: number;
|
|
23
|
+
completedSteps: ReadonlyArray<string>;
|
|
24
|
+
collected: Record<string, unknown>;
|
|
25
|
+
startedAt: Date;
|
|
26
|
+
lastUpdated: Date;
|
|
27
|
+
finishedAt: Date | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ProgressAction =
|
|
31
|
+
| { type: 'next'; currentScreen: OnboardingScreen; data?: unknown }
|
|
32
|
+
| { type: 'previous' }
|
|
33
|
+
| { type: 'goto'; index: number }
|
|
34
|
+
| { type: 'reset' }
|
|
35
|
+
| { type: 'finish'; currentScreen: OnboardingScreen; data?: unknown }
|
|
36
|
+
| { type: 'hydrate'; state: ProgressState };
|
|
37
|
+
|
|
38
|
+
function reducer(state: ProgressState, action: ProgressAction): ProgressState {
|
|
39
|
+
switch (action.type) {
|
|
40
|
+
case 'next': {
|
|
41
|
+
const completed = state.completedSteps.includes(action.currentScreen.id)
|
|
42
|
+
? state.completedSteps
|
|
43
|
+
: [...state.completedSteps, action.currentScreen.id];
|
|
44
|
+
const collected =
|
|
45
|
+
action.data !== undefined
|
|
46
|
+
? { ...state.collected, [action.currentScreen.id]: action.data }
|
|
47
|
+
: state.collected;
|
|
48
|
+
return {
|
|
49
|
+
...state,
|
|
50
|
+
currentStep: advanceStepIndex(state.currentStep, state.totalSteps),
|
|
51
|
+
completedSteps: completed,
|
|
52
|
+
collected,
|
|
53
|
+
lastUpdated: new Date(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case 'previous':
|
|
57
|
+
return { ...state, currentStep: retreatStepIndex(state.currentStep), lastUpdated: new Date() };
|
|
58
|
+
case 'goto':
|
|
59
|
+
return {
|
|
60
|
+
...state,
|
|
61
|
+
currentStep: clampStepIndex(action.index, state.totalSteps),
|
|
62
|
+
lastUpdated: new Date(),
|
|
63
|
+
};
|
|
64
|
+
case 'finish': {
|
|
65
|
+
const completed = state.completedSteps.includes(action.currentScreen.id)
|
|
66
|
+
? state.completedSteps
|
|
67
|
+
: [...state.completedSteps, action.currentScreen.id];
|
|
68
|
+
const collected =
|
|
69
|
+
action.data !== undefined
|
|
70
|
+
? { ...state.collected, [action.currentScreen.id]: action.data }
|
|
71
|
+
: state.collected;
|
|
72
|
+
return {
|
|
73
|
+
...state,
|
|
74
|
+
completedSteps: completed,
|
|
75
|
+
collected,
|
|
76
|
+
finishedAt: new Date(),
|
|
77
|
+
lastUpdated: new Date(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
case 'reset':
|
|
81
|
+
return {
|
|
82
|
+
...state,
|
|
83
|
+
currentStep: 0,
|
|
84
|
+
completedSteps: [],
|
|
85
|
+
collected: {},
|
|
86
|
+
finishedAt: null,
|
|
87
|
+
lastUpdated: new Date(),
|
|
88
|
+
startedAt: new Date(),
|
|
89
|
+
};
|
|
90
|
+
case 'hydrate':
|
|
91
|
+
return action.state;
|
|
92
|
+
default:
|
|
93
|
+
return state;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface UseOnboardingProgressOptions {
|
|
98
|
+
screens: ReadonlyArray<OnboardingScreen>;
|
|
99
|
+
storageKey?: string;
|
|
100
|
+
/** Disable persistence (e.g. tests, ephemeral previews). */
|
|
101
|
+
ephemeral?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface UseOnboardingProgressReturn {
|
|
105
|
+
currentStep: number;
|
|
106
|
+
totalSteps: number;
|
|
107
|
+
progress: number; // 0..1
|
|
108
|
+
completedSteps: ReadonlyArray<string>;
|
|
109
|
+
collected: Record<string, unknown>;
|
|
110
|
+
remainingSteps: number;
|
|
111
|
+
isFirst: boolean;
|
|
112
|
+
isLast: boolean;
|
|
113
|
+
isFinished: boolean;
|
|
114
|
+
/** When the current run started (restored from storage on rehydration). */
|
|
115
|
+
startedAt: Date;
|
|
116
|
+
next: (data?: unknown) => void;
|
|
117
|
+
prev: () => void;
|
|
118
|
+
goTo: (index: number) => void;
|
|
119
|
+
reset: () => void;
|
|
120
|
+
finish: (data?: unknown) => OnboardingResult;
|
|
121
|
+
result: OnboardingResult | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Owns the state machine for an onboarding flow.
|
|
126
|
+
* Single responsibility: step transitions, persistence, completion.
|
|
127
|
+
* Math is delegated to `src/calc/`; storage I/O to `src/utils/local-storage-bridge`.
|
|
128
|
+
*/
|
|
129
|
+
export function useOnboardingProgress(
|
|
130
|
+
options: UseOnboardingProgressOptions,
|
|
131
|
+
): UseOnboardingProgressReturn {
|
|
132
|
+
const { screens, storageKey, ephemeral = false } = options;
|
|
133
|
+
const totalSteps = screens.length;
|
|
134
|
+
|
|
135
|
+
const initialState = useMemo<ProgressState>(() => {
|
|
136
|
+
const now = new Date();
|
|
137
|
+
if (!ephemeral) {
|
|
138
|
+
const restored = loadOnboardingProgress(storageKey);
|
|
139
|
+
if (restored && restored.currentStep < totalSteps) {
|
|
140
|
+
return {
|
|
141
|
+
currentStep: restored.currentStep,
|
|
142
|
+
totalSteps,
|
|
143
|
+
completedSteps: restored.completedSteps,
|
|
144
|
+
collected: restored.collected,
|
|
145
|
+
startedAt: restored.startedAt,
|
|
146
|
+
lastUpdated: restored.lastUpdated,
|
|
147
|
+
finishedAt: null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
currentStep: 0,
|
|
153
|
+
totalSteps,
|
|
154
|
+
completedSteps: [],
|
|
155
|
+
collected: {},
|
|
156
|
+
startedAt: now,
|
|
157
|
+
lastUpdated: now,
|
|
158
|
+
finishedAt: null,
|
|
159
|
+
};
|
|
160
|
+
// useReducer reads `initialState` only on mount, so this is effectively mount-only.
|
|
161
|
+
// We recompute if the screen set actually changes (different totalSteps).
|
|
162
|
+
}, [totalSteps, storageKey, ephemeral]);
|
|
163
|
+
|
|
164
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
165
|
+
|
|
166
|
+
// Persist whenever the state changes.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (ephemeral) return;
|
|
169
|
+
saveOnboardingProgress(storageKey, {
|
|
170
|
+
currentStep: state.currentStep,
|
|
171
|
+
completedSteps: state.completedSteps,
|
|
172
|
+
collected: state.collected,
|
|
173
|
+
startedAt: state.startedAt,
|
|
174
|
+
lastUpdated: state.lastUpdated,
|
|
175
|
+
});
|
|
176
|
+
}, [
|
|
177
|
+
ephemeral,
|
|
178
|
+
storageKey,
|
|
179
|
+
state.currentStep,
|
|
180
|
+
state.completedSteps,
|
|
181
|
+
state.collected,
|
|
182
|
+
state.startedAt,
|
|
183
|
+
state.lastUpdated,
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
const next = useCallback(
|
|
187
|
+
(data?: unknown) => {
|
|
188
|
+
const currentScreen = screens[state.currentStep];
|
|
189
|
+
if (!currentScreen) return;
|
|
190
|
+
if (state.currentStep === state.totalSteps - 1) {
|
|
191
|
+
dispatch({ type: 'finish', currentScreen, data });
|
|
192
|
+
} else {
|
|
193
|
+
dispatch({ type: 'next', currentScreen, data });
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
[screens, state.currentStep, state.totalSteps],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const prev = useCallback(() => dispatch({ type: 'previous' }), []);
|
|
200
|
+
const goTo = useCallback((index: number) => dispatch({ type: 'goto', index }), []);
|
|
201
|
+
const reset = useCallback(() => {
|
|
202
|
+
if (!ephemeral) clearOnboardingProgress(storageKey);
|
|
203
|
+
dispatch({ type: 'reset' });
|
|
204
|
+
}, [ephemeral, storageKey]);
|
|
205
|
+
|
|
206
|
+
const finish = useCallback(
|
|
207
|
+
(data?: unknown) => {
|
|
208
|
+
const currentScreen = screens[state.currentStep];
|
|
209
|
+
if (currentScreen) dispatch({ type: 'finish', currentScreen, data });
|
|
210
|
+
const finishedAt = new Date();
|
|
211
|
+
return buildOnboardingResult({
|
|
212
|
+
completedSteps:
|
|
213
|
+
currentScreen && !state.completedSteps.includes(currentScreen.id)
|
|
214
|
+
? [...state.completedSteps, currentScreen.id]
|
|
215
|
+
: state.completedSteps,
|
|
216
|
+
collected:
|
|
217
|
+
currentScreen && data !== undefined
|
|
218
|
+
? { ...state.collected, [currentScreen.id]: data }
|
|
219
|
+
: state.collected,
|
|
220
|
+
startedAt: state.startedAt,
|
|
221
|
+
finishedAt,
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
[screens, state.currentStep, state.completedSteps, state.collected, state.startedAt],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const progress = computeProgressFraction(state.currentStep, state.totalSteps);
|
|
228
|
+
const remainingSteps = computeRemainingSteps(state.currentStep, state.totalSteps);
|
|
229
|
+
const isFirst = state.currentStep === 0;
|
|
230
|
+
const isLast = state.currentStep === state.totalSteps - 1;
|
|
231
|
+
const isFinished = state.finishedAt !== null;
|
|
232
|
+
const result =
|
|
233
|
+
state.finishedAt !== null
|
|
234
|
+
? buildOnboardingResult({
|
|
235
|
+
completedSteps: state.completedSteps,
|
|
236
|
+
collected: state.collected,
|
|
237
|
+
startedAt: state.startedAt,
|
|
238
|
+
finishedAt: state.finishedAt,
|
|
239
|
+
})
|
|
240
|
+
: null;
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
currentStep: state.currentStep,
|
|
244
|
+
totalSteps,
|
|
245
|
+
progress,
|
|
246
|
+
completedSteps: state.completedSteps,
|
|
247
|
+
collected: state.collected,
|
|
248
|
+
remainingSteps,
|
|
249
|
+
isFirst,
|
|
250
|
+
isLast,
|
|
251
|
+
isFinished,
|
|
252
|
+
startedAt: state.startedAt,
|
|
253
|
+
next,
|
|
254
|
+
prev,
|
|
255
|
+
goTo,
|
|
256
|
+
reset,
|
|
257
|
+
finish,
|
|
258
|
+
result,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { PermissionStatus, PermissionType } from '../../types/screens';
|
|
3
|
+
import {
|
|
4
|
+
isPermissionDecided,
|
|
5
|
+
isPermissionUnsupported,
|
|
6
|
+
} from '../../predicates/permission-granted';
|
|
7
|
+
import { queryPermissionStatus, requestPermission } from '../../utils/permission-bridge';
|
|
8
|
+
|
|
9
|
+
export interface UsePermissionPrimingReturn {
|
|
10
|
+
status: PermissionStatus;
|
|
11
|
+
isRequesting: boolean;
|
|
12
|
+
requestPermission: () => Promise<PermissionStatus>;
|
|
13
|
+
reset: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Owns permission state for a single permission type.
|
|
18
|
+
* Single responsibility: query + request lifecycle, no UI.
|
|
19
|
+
*
|
|
20
|
+
* The `requestPermission` callback is a no-op (returns the current status)
|
|
21
|
+
* once the permission has been decided or the runtime reports no support.
|
|
22
|
+
*/
|
|
23
|
+
export function usePermissionPriming(permission: PermissionType): UsePermissionPrimingReturn {
|
|
24
|
+
const [status, setStatus] = useState<PermissionStatus>('prompt');
|
|
25
|
+
const [isRequesting, setIsRequesting] = useState(false);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
queryPermissionStatus(permission).then((initial) => {
|
|
30
|
+
if (!cancelled) setStatus(initial);
|
|
31
|
+
});
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
};
|
|
35
|
+
}, [permission]);
|
|
36
|
+
|
|
37
|
+
const request = useCallback(async (): Promise<PermissionStatus> => {
|
|
38
|
+
if (isPermissionDecided(status) || isPermissionUnsupported(status)) return status;
|
|
39
|
+
setIsRequesting(true);
|
|
40
|
+
try {
|
|
41
|
+
const next = await requestPermission(permission);
|
|
42
|
+
setStatus(next);
|
|
43
|
+
return next;
|
|
44
|
+
} finally {
|
|
45
|
+
setIsRequesting(false);
|
|
46
|
+
}
|
|
47
|
+
}, [permission, status]);
|
|
48
|
+
|
|
49
|
+
const reset = useCallback(() => setStatus('prompt'), []);
|
|
50
|
+
|
|
51
|
+
return { status, isRequesting, requestPermission: request, reset };
|
|
52
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detects the user's `prefers-reduced-motion` setting.
|
|
5
|
+
* Single responsibility: surface the OS-level preference to consumers so
|
|
6
|
+
* motion-heavy UI (Framer Motion, transitions) can opt out for accessibility.
|
|
7
|
+
*/
|
|
8
|
+
export function useReducedMotion(): boolean {
|
|
9
|
+
const getInitial = (): boolean => {
|
|
10
|
+
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
|
11
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
12
|
+
};
|
|
13
|
+
const [reduced, setReduced] = useState<boolean>(getInitial);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
17
|
+
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
18
|
+
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
19
|
+
mql.addEventListener('change', handler);
|
|
20
|
+
return () => mql.removeEventListener('change', handler);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
return reduced;
|
|
24
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { PrimaryButton } from '../shared/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
|
+
// Step lifecycle analytics (`onboarding_step_completed`) are owned by the
|
|
35
|
+
// orchestrator — the screen stays presentation-only.
|
|
36
|
+
|
|
37
|
+
async function handleSignIn(method: SignInMethod) {
|
|
38
|
+
setBusy(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
const result = await config.onSignIn(method);
|
|
42
|
+
onNext(result);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
const message = e instanceof Error ? e.message : labels.signInFailed;
|
|
45
|
+
setError(message);
|
|
46
|
+
} finally {
|
|
47
|
+
setBusy(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
|
53
|
+
{config.title && (
|
|
54
|
+
<h2 className="text-3xl font-bold text-[rgb(var(--uob-text-primary))]">{config.title}</h2>
|
|
55
|
+
)}
|
|
56
|
+
{config.subtitle && (
|
|
57
|
+
<p className="text-base text-[rgb(var(--uob-text-muted))]">{config.subtitle}</p>
|
|
58
|
+
)}
|
|
59
|
+
{error && (
|
|
60
|
+
<p
|
|
61
|
+
role="alert"
|
|
62
|
+
className="w-full rounded-md border border-[rgb(var(--uob-error))] bg-[rgb(var(--uob-error))]/10 p-3 text-sm text-[rgb(var(--uob-error))]"
|
|
63
|
+
>
|
|
64
|
+
{error}
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
<div className="flex w-full flex-col gap-3">
|
|
68
|
+
{config.signInMethod && hasOnSignIn(config) && (
|
|
69
|
+
<PrimaryButton
|
|
70
|
+
onClick={() => handleSignIn(config.signInMethod as SignInMethod)}
|
|
71
|
+
loading={busy}
|
|
72
|
+
>
|
|
73
|
+
{methodLabel(config.signInMethod, labels)}
|
|
74
|
+
</PrimaryButton>
|
|
75
|
+
)}
|
|
76
|
+
{accountGateAllowsSkip(config) && (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
disabled={busy}
|
|
80
|
+
onClick={() => onNext({ signedIn: false })}
|
|
81
|
+
className="text-sm font-semibold text-[rgb(var(--uob-text-muted))] underline-offset-4 hover:underline disabled:opacity-50"
|
|
82
|
+
>
|
|
83
|
+
{config.skipText ?? labels.skipForNow}
|
|
84
|
+
</button>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { PrimaryButton } from '../shared/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
|
+
// The demo component is a consumer-supplied component reference held in config —
|
|
15
|
+
// aliasing it here is a lookup, not creation.
|
|
16
|
+
const Demo = config.demo.component;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex flex-1 flex-col gap-6">
|
|
20
|
+
{config.title && (
|
|
21
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
|
|
22
|
+
{config.title}
|
|
23
|
+
</h2>
|
|
24
|
+
)}
|
|
25
|
+
<div className="rounded-2xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface-muted))] p-4">
|
|
26
|
+
<Demo
|
|
27
|
+
{...((config.demo.props ?? {}) as Record<string, unknown>)}
|
|
28
|
+
onSave={(value: unknown) => {
|
|
29
|
+
setResult(value);
|
|
30
|
+
config.demo.onSave(value);
|
|
31
|
+
}}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
<PrimaryButton onClick={() => onNext({ result })} disabled={result === null}>
|
|
35
|
+
{config.cta ?? labels.continueWithResult}
|
|
36
|
+
</PrimaryButton>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Check, X } from 'lucide-react';
|
|
2
|
+
import { PrimaryButton } from '../shared/PrimaryButton';
|
|
3
|
+
import { cn } from '../../theme/cn';
|
|
4
|
+
import { useOnboardingIntl } from '../OnboardingIntl';
|
|
5
|
+
import type { ComparisonScreenConfig } from '../../types/screens';
|
|
6
|
+
|
|
7
|
+
export interface ComparisonScreenProps {
|
|
8
|
+
config: ComparisonScreenConfig;
|
|
9
|
+
onNext: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ComparisonScreen({ config, onNext }: ComparisonScreenProps) {
|
|
13
|
+
const { labels } = useOnboardingIntl();
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-1 flex-col gap-6">
|
|
16
|
+
{config.title && (
|
|
17
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
|
|
18
|
+
{config.title}
|
|
19
|
+
</h2>
|
|
20
|
+
)}
|
|
21
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
22
|
+
<ComparisonColumn heading={labels.withoutUs} items={config.before} tone="negative" />
|
|
23
|
+
<ComparisonColumn heading={labels.withUs} items={config.after} tone="positive" />
|
|
24
|
+
</div>
|
|
25
|
+
<PrimaryButton onClick={onNext}>{config.cta ?? labels.continueAction}</PrimaryButton>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ComparisonColumnProps {
|
|
31
|
+
heading: string;
|
|
32
|
+
items: ReadonlyArray<string>;
|
|
33
|
+
tone: 'positive' | 'negative';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ComparisonColumn({ heading, items, tone }: ComparisonColumnProps) {
|
|
37
|
+
const Icon = tone === 'positive' ? Check : X;
|
|
38
|
+
const colorClass =
|
|
39
|
+
tone === 'positive'
|
|
40
|
+
? 'border-[rgb(var(--uob-success))] bg-[rgb(var(--uob-surface))]'
|
|
41
|
+
: 'border-[rgb(var(--uob-error))] bg-[rgb(var(--uob-surface))]';
|
|
42
|
+
const iconColor =
|
|
43
|
+
tone === 'positive'
|
|
44
|
+
? 'text-[rgb(var(--uob-success))]'
|
|
45
|
+
: 'text-[rgb(var(--uob-error))]';
|
|
46
|
+
return (
|
|
47
|
+
<div className={cn('flex flex-col gap-3 rounded-2xl border p-5', colorClass)}>
|
|
48
|
+
<h3 className="text-lg font-semibold text-[rgb(var(--uob-text-primary))]">{heading}</h3>
|
|
49
|
+
<ul className="flex flex-col gap-2">
|
|
50
|
+
{items.map((item) => (
|
|
51
|
+
<li key={item} className="flex items-start gap-2 text-sm text-[rgb(var(--uob-text-primary))]">
|
|
52
|
+
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', iconColor)} aria-hidden="true" />
|
|
53
|
+
<span>{item}</span>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|