@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,49 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
AnalyticsConfig,
|
|
4
|
+
OnboardingAnalyticsEvent,
|
|
5
|
+
} from '../types/config';
|
|
6
|
+
import { trackOnboardingEvent } from '../utils/analytics-dispatch';
|
|
7
|
+
|
|
8
|
+
export interface UseOnboardingAnalyticsReturn {
|
|
9
|
+
trackEvent: (event: OnboardingAnalyticsEvent) => void;
|
|
10
|
+
trackFunnel: (event: OnboardingAnalyticsEvent) => void;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useOnboardingAnalytics(config: AnalyticsConfig | undefined): UseOnboardingAnalyticsReturn {
|
|
15
|
+
const configRef = useRef(config);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
configRef.current = config;
|
|
18
|
+
}, [config]);
|
|
19
|
+
|
|
20
|
+
const enabled = config?.trackEvents === true;
|
|
21
|
+
|
|
22
|
+
const dispatch = useCallback(
|
|
23
|
+
(event: OnboardingAnalyticsEvent) => {
|
|
24
|
+
if (!enabled) return;
|
|
25
|
+
const current = configRef.current;
|
|
26
|
+
if (!current) return;
|
|
27
|
+
trackOnboardingEvent(event, {
|
|
28
|
+
provider: current.provider,
|
|
29
|
+
customTracker: current.customTracker,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
[enabled],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const trackEvent = useCallback(
|
|
36
|
+
(event: OnboardingAnalyticsEvent) => dispatch(event),
|
|
37
|
+
[dispatch],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const trackFunnel = useCallback(
|
|
41
|
+
(event: OnboardingAnalyticsEvent) => dispatch(event),
|
|
42
|
+
[dispatch],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return useMemo(
|
|
46
|
+
() => ({ trackEvent, trackFunnel, enabled }),
|
|
47
|
+
[trackEvent, trackFunnel, enabled],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -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,249 @@
|
|
|
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
|
+
ephemeral?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface UseOnboardingProgressReturn {
|
|
104
|
+
currentStep: number;
|
|
105
|
+
totalSteps: number;
|
|
106
|
+
progress: number; completedSteps: ReadonlyArray<string>;
|
|
107
|
+
collected: Record<string, unknown>;
|
|
108
|
+
remainingSteps: number;
|
|
109
|
+
isFirst: boolean;
|
|
110
|
+
isLast: boolean;
|
|
111
|
+
isFinished: boolean;
|
|
112
|
+
startedAt: Date;
|
|
113
|
+
next: (data?: unknown) => void;
|
|
114
|
+
prev: () => void;
|
|
115
|
+
goTo: (index: number) => void;
|
|
116
|
+
reset: () => void;
|
|
117
|
+
finish: (data?: unknown) => OnboardingResult;
|
|
118
|
+
result: OnboardingResult | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function useOnboardingProgress(
|
|
122
|
+
options: UseOnboardingProgressOptions,
|
|
123
|
+
): UseOnboardingProgressReturn {
|
|
124
|
+
const { screens, storageKey, ephemeral = false } = options;
|
|
125
|
+
const totalSteps = screens.length;
|
|
126
|
+
|
|
127
|
+
const initialState = useMemo<ProgressState>(() => {
|
|
128
|
+
const now = new Date();
|
|
129
|
+
if (!ephemeral) {
|
|
130
|
+
const restored = loadOnboardingProgress(storageKey);
|
|
131
|
+
if (restored && restored.currentStep < totalSteps) {
|
|
132
|
+
return {
|
|
133
|
+
currentStep: restored.currentStep,
|
|
134
|
+
totalSteps,
|
|
135
|
+
completedSteps: restored.completedSteps,
|
|
136
|
+
collected: restored.collected,
|
|
137
|
+
startedAt: restored.startedAt,
|
|
138
|
+
lastUpdated: restored.lastUpdated,
|
|
139
|
+
finishedAt: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
currentStep: 0,
|
|
145
|
+
totalSteps,
|
|
146
|
+
completedSteps: [],
|
|
147
|
+
collected: {},
|
|
148
|
+
startedAt: now,
|
|
149
|
+
lastUpdated: now,
|
|
150
|
+
finishedAt: null,
|
|
151
|
+
};
|
|
152
|
+
}, [totalSteps, storageKey, ephemeral]);
|
|
153
|
+
|
|
154
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (ephemeral) return;
|
|
158
|
+
saveOnboardingProgress(storageKey, {
|
|
159
|
+
currentStep: state.currentStep,
|
|
160
|
+
completedSteps: state.completedSteps,
|
|
161
|
+
collected: state.collected,
|
|
162
|
+
startedAt: state.startedAt,
|
|
163
|
+
lastUpdated: state.lastUpdated,
|
|
164
|
+
});
|
|
165
|
+
}, [
|
|
166
|
+
ephemeral,
|
|
167
|
+
storageKey,
|
|
168
|
+
state.currentStep,
|
|
169
|
+
state.completedSteps,
|
|
170
|
+
state.collected,
|
|
171
|
+
state.startedAt,
|
|
172
|
+
state.lastUpdated,
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
const next = useCallback(
|
|
176
|
+
(data?: unknown) => {
|
|
177
|
+
const currentScreen = screens[state.currentStep];
|
|
178
|
+
if (!currentScreen) return;
|
|
179
|
+
if (state.currentStep === state.totalSteps - 1) {
|
|
180
|
+
dispatch({ type: 'finish', currentScreen, data });
|
|
181
|
+
} else {
|
|
182
|
+
dispatch({ type: 'next', currentScreen, data });
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[screens, state.currentStep, state.totalSteps],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const prev = useCallback(() => dispatch({ type: 'previous' }), []);
|
|
189
|
+
const goTo = useCallback((index: number) => dispatch({ type: 'goto', index }), []);
|
|
190
|
+
const reset = useCallback(() => {
|
|
191
|
+
if (!ephemeral) clearOnboardingProgress(storageKey);
|
|
192
|
+
dispatch({ type: 'reset' });
|
|
193
|
+
}, [ephemeral, storageKey]);
|
|
194
|
+
|
|
195
|
+
const finish = useCallback(
|
|
196
|
+
(data?: unknown) => {
|
|
197
|
+
const currentScreen = screens[state.currentStep];
|
|
198
|
+
if (currentScreen) dispatch({ type: 'finish', currentScreen, data });
|
|
199
|
+
const finishedAt = new Date();
|
|
200
|
+
return buildOnboardingResult({
|
|
201
|
+
completedSteps:
|
|
202
|
+
currentScreen && !state.completedSteps.includes(currentScreen.id)
|
|
203
|
+
? [...state.completedSteps, currentScreen.id]
|
|
204
|
+
: state.completedSteps,
|
|
205
|
+
collected:
|
|
206
|
+
currentScreen && data !== undefined
|
|
207
|
+
? { ...state.collected, [currentScreen.id]: data }
|
|
208
|
+
: state.collected,
|
|
209
|
+
startedAt: state.startedAt,
|
|
210
|
+
finishedAt,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
[screens, state.currentStep, state.completedSteps, state.collected, state.startedAt],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const progress = computeProgressFraction(state.currentStep, state.totalSteps);
|
|
217
|
+
const remainingSteps = computeRemainingSteps(state.currentStep, state.totalSteps);
|
|
218
|
+
const isFirst = state.currentStep === 0;
|
|
219
|
+
const isLast = state.currentStep === state.totalSteps - 1;
|
|
220
|
+
const isFinished = state.finishedAt !== null;
|
|
221
|
+
const result =
|
|
222
|
+
state.finishedAt !== null
|
|
223
|
+
? buildOnboardingResult({
|
|
224
|
+
completedSteps: state.completedSteps,
|
|
225
|
+
collected: state.collected,
|
|
226
|
+
startedAt: state.startedAt,
|
|
227
|
+
finishedAt: state.finishedAt,
|
|
228
|
+
})
|
|
229
|
+
: null;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
currentStep: state.currentStep,
|
|
233
|
+
totalSteps,
|
|
234
|
+
progress,
|
|
235
|
+
completedSteps: state.completedSteps,
|
|
236
|
+
collected: state.collected,
|
|
237
|
+
remainingSteps,
|
|
238
|
+
isFirst,
|
|
239
|
+
isLast,
|
|
240
|
+
isFinished,
|
|
241
|
+
startedAt: state.startedAt,
|
|
242
|
+
next,
|
|
243
|
+
prev,
|
|
244
|
+
goTo,
|
|
245
|
+
reset,
|
|
246
|
+
finish,
|
|
247
|
+
result,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
export function usePermissionPriming(permission: PermissionType): UsePermissionPrimingReturn {
|
|
17
|
+
const [status, setStatus] = useState<PermissionStatus>('prompt');
|
|
18
|
+
const [isRequesting, setIsRequesting] = useState(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
queryPermissionStatus(permission).then((initial) => {
|
|
23
|
+
if (!cancelled) setStatus(initial);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true;
|
|
27
|
+
};
|
|
28
|
+
}, [permission]);
|
|
29
|
+
|
|
30
|
+
const request = useCallback(async (): Promise<PermissionStatus> => {
|
|
31
|
+
if (isPermissionDecided(status) || isPermissionUnsupported(status)) return status;
|
|
32
|
+
setIsRequesting(true);
|
|
33
|
+
try {
|
|
34
|
+
const next = await requestPermission(permission);
|
|
35
|
+
setStatus(next);
|
|
36
|
+
return next;
|
|
37
|
+
} finally {
|
|
38
|
+
setIsRequesting(false);
|
|
39
|
+
}
|
|
40
|
+
}, [permission, status]);
|
|
41
|
+
|
|
42
|
+
const reset = useCallback(() => setStatus('prompt'), []);
|
|
43
|
+
|
|
44
|
+
return { status, isRequesting, requestPermission: request, reset };
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useReducedMotion(): boolean {
|
|
4
|
+
const getInitial = (): boolean => {
|
|
5
|
+
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
|
6
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
7
|
+
};
|
|
8
|
+
const [reduced, setReduced] = useState<boolean>(getInitial);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
12
|
+
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
13
|
+
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
14
|
+
mql.addEventListener('change', handler);
|
|
15
|
+
return () => mql.removeEventListener('change', handler);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return reduced;
|
|
19
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export { OnboardingFlow } from './components/OnboardingFlow';
|
|
2
|
+
export type { OnboardingFlowProps } from './components/OnboardingFlow';
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
OnboardingIntlProvider,
|
|
6
|
+
OnboardingIntlContext,
|
|
7
|
+
useOnboardingIntl,
|
|
8
|
+
defaultOnboardingLabels,
|
|
9
|
+
formatLabel,
|
|
10
|
+
} from './components/OnboardingIntl';
|
|
11
|
+
export type { OnboardingIntl, OnboardingIntlProviderProps } from './components/OnboardingIntl';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
WelcomeScreen,
|
|
15
|
+
GoalQuestionScreen,
|
|
16
|
+
PainPointsScreen,
|
|
17
|
+
SocialProofScreen,
|
|
18
|
+
TinderCardsScreen,
|
|
19
|
+
PersonalizedSolutionScreen,
|
|
20
|
+
ComparisonScreen,
|
|
21
|
+
PreferencesScreen,
|
|
22
|
+
PermissionPrimingScreen,
|
|
23
|
+
ProcessingScreen,
|
|
24
|
+
AppDemoScreen,
|
|
25
|
+
ValueDeliveryScreen,
|
|
26
|
+
AccountGateScreen,
|
|
27
|
+
PaywallScreen,
|
|
28
|
+
} from './components/screens';
|
|
29
|
+
|
|
30
|
+
export { ProgressBar } from './components/ProgressBar';
|
|
31
|
+
export type { ProgressBarProps } from './components/ProgressBar';
|
|
32
|
+
export { QuestionWrapper } from './components/QuestionWrapper';
|
|
33
|
+
export type { QuestionWrapperProps } from './components/QuestionWrapper';
|
|
34
|
+
export { SwipeCard } from './components/SwipeCard';
|
|
35
|
+
export type { SwipeCardProps } from './components/SwipeCard';
|
|
36
|
+
export { AnimationWrapper } from './components/AnimationWrapper';
|
|
37
|
+
export type { AnimationWrapperProps, ScreenTransition } from './components/AnimationWrapper';
|
|
38
|
+
export { ScreenContainer } from './components/ScreenContainer';
|
|
39
|
+
export type { ScreenContainerProps } from './components/ScreenContainer';
|
|
40
|
+
export { PrimaryButton } from './components/PrimaryButton';
|
|
41
|
+
export type { PrimaryButtonProps } from './components/PrimaryButton';
|
|
42
|
+
export { OnboardingErrorBoundary } from './components/OnboardingErrorBoundary';
|
|
43
|
+
export type { OnboardingErrorBoundaryProps } from './components/OnboardingErrorBoundary';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
useOnboardingProgress,
|
|
47
|
+
usePermissionPriming,
|
|
48
|
+
useOnboardingAnalytics,
|
|
49
|
+
useReducedMotion,
|
|
50
|
+
} from './hooks';
|
|
51
|
+
export type {
|
|
52
|
+
UseOnboardingProgressReturn,
|
|
53
|
+
UsePermissionPrimingReturn,
|
|
54
|
+
UseOnboardingAnalyticsReturn,
|
|
55
|
+
} from './hooks';
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
healthOnboardingTemplate,
|
|
59
|
+
artOnboardingTemplate,
|
|
60
|
+
videoOnboardingTemplate,
|
|
61
|
+
platformOnboardingTemplate,
|
|
62
|
+
withDefaultDemo,
|
|
63
|
+
defaultDemoResult,
|
|
64
|
+
TemplateDemoFallback,
|
|
65
|
+
} from './templates';
|
|
66
|
+
export type { TemplateDemoResult } from './templates';
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
computeProgressFraction,
|
|
70
|
+
computeProgressPercentage,
|
|
71
|
+
computeRemainingSteps,
|
|
72
|
+
clampStepIndex,
|
|
73
|
+
advanceStepIndex,
|
|
74
|
+
retreatStepIndex,
|
|
75
|
+
SWIPE_DECISION_THRESHOLD_PX,
|
|
76
|
+
SWIPE_ROTATION_RANGE,
|
|
77
|
+
SWIPE_ROTATION_VALUES,
|
|
78
|
+
SWIPE_LEFT_OPACITY_INPUT,
|
|
79
|
+
SWIPE_LEFT_OPACITY_VALUES,
|
|
80
|
+
SWIPE_RIGHT_OPACITY_INPUT,
|
|
81
|
+
SWIPE_RIGHT_OPACITY_VALUES,
|
|
82
|
+
didCrossRightSwipeThreshold,
|
|
83
|
+
didCrossLeftSwipeThreshold,
|
|
84
|
+
getCardEnterInitial,
|
|
85
|
+
getCardEnterAnimate,
|
|
86
|
+
getCardEnterExit,
|
|
87
|
+
getCardTransitionDurationMs,
|
|
88
|
+
resolveSliderBounds,
|
|
89
|
+
areSliderBoundsValid,
|
|
90
|
+
clampValueToBounds,
|
|
91
|
+
splitTotalDuration,
|
|
92
|
+
getDefaultStepDurationMs,
|
|
93
|
+
formatCountWithLocale,
|
|
94
|
+
elapsedMillis,
|
|
95
|
+
} from './calc';
|
|
96
|
+
export type { CardMotionState } from './calc';
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
checkFlowStructure,
|
|
100
|
+
checkScreenConfig,
|
|
101
|
+
checkStoredProgress,
|
|
102
|
+
getRecognizedScreenTypes,
|
|
103
|
+
isRecognizedPermissionStatus,
|
|
104
|
+
normalizePermissionStatus,
|
|
105
|
+
} from './validation';
|
|
106
|
+
export type { ValidationOutcome, StoredProgressShape, StoredProgressOutcome } from './validation';
|
|
107
|
+
|
|
108
|
+
export {
|
|
109
|
+
isPermissionGranted,
|
|
110
|
+
isPermissionDenied,
|
|
111
|
+
isPermissionPrompt,
|
|
112
|
+
isPermissionUnsupported,
|
|
113
|
+
isPermissionDecided,
|
|
114
|
+
doesBrowserSupportNotifications,
|
|
115
|
+
doesBrowserSupportMediaCapture,
|
|
116
|
+
doesBrowserSupportGeolocation,
|
|
117
|
+
isRuntimeSupportMissing,
|
|
118
|
+
isLocalStorageAvailable,
|
|
119
|
+
doesIconNameResolve,
|
|
120
|
+
getRegisteredIconNames,
|
|
121
|
+
resolveIconByName,
|
|
122
|
+
hasOnRequest,
|
|
123
|
+
hasOnAccept,
|
|
124
|
+
hasOnSignIn,
|
|
125
|
+
accountGateAllowsSkip,
|
|
126
|
+
screenEmitsData,
|
|
127
|
+
} from './predicates';
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
trackOnboardingEvent,
|
|
131
|
+
dispatchAnalyticsEvent,
|
|
132
|
+
saveOnboardingProgress,
|
|
133
|
+
loadOnboardingProgress,
|
|
134
|
+
clearOnboardingProgress,
|
|
135
|
+
buildOnboardingResult,
|
|
136
|
+
queryPermissionStatus,
|
|
137
|
+
requestPermission,
|
|
138
|
+
defaultStorageKey,
|
|
139
|
+
} from './utils';
|
|
140
|
+
export type { RehydratedProgress } from './utils';
|
|
141
|
+
|
|
142
|
+
export type {
|
|
143
|
+
OnboardingScreen,
|
|
144
|
+
OnboardingScreenType,
|
|
145
|
+
OnboardingOption,
|
|
146
|
+
OnboardingConfig,
|
|
147
|
+
OnboardingCategory,
|
|
148
|
+
OnboardingLabels,
|
|
149
|
+
AnalyticsConfig,
|
|
150
|
+
AnalyticsProvider,
|
|
151
|
+
OnboardingAnalyticsEvent,
|
|
152
|
+
OnboardingResult,
|
|
153
|
+
} from './types';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountGateScreenConfig,
|
|
3
|
+
PaywallScreenConfig,
|
|
4
|
+
PermissionPrimingScreenConfig,
|
|
5
|
+
} from '../types/screens';
|
|
6
|
+
|
|
7
|
+
export function hasOnRequest(config: PermissionPrimingScreenConfig): boolean {
|
|
8
|
+
return typeof config.onRequest === 'function';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function hasOnAccept(config: PaywallScreenConfig): boolean {
|
|
12
|
+
return typeof config.onAccept === 'function';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasOnSignIn(config: AccountGateScreenConfig): boolean {
|
|
16
|
+
return typeof config.onSignIn === 'function';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function accountGateAllowsSkip(config: AccountGateScreenConfig): boolean {
|
|
20
|
+
return config.optional === true;
|
|
21
|
+
}
|