@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,93 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildOnboardingResult,
|
|
4
|
+
clearOnboardingProgress,
|
|
5
|
+
loadOnboardingProgress,
|
|
6
|
+
saveOnboardingProgress,
|
|
7
|
+
} from './local-storage-bridge';
|
|
8
|
+
import { defaultStorageKey } from './default-storage-key';
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
window.localStorage.clear();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('local-storage-bridge round-trip', () => {
|
|
15
|
+
it('saves and loads progress under the default key', () => {
|
|
16
|
+
const startedAt = new Date('2026-01-01T10:00:00.000Z');
|
|
17
|
+
const ok = saveOnboardingProgress(undefined, {
|
|
18
|
+
currentStep: 2,
|
|
19
|
+
completedSteps: ['a', 'b'],
|
|
20
|
+
collected: { a: { selected: ['x'] } },
|
|
21
|
+
startedAt,
|
|
22
|
+
lastUpdated: new Date('2026-01-01T10:01:00.000Z'),
|
|
23
|
+
});
|
|
24
|
+
expect(ok).toBe(true);
|
|
25
|
+
expect(localStorage.getItem(defaultStorageKey)).toContain('"version":1');
|
|
26
|
+
|
|
27
|
+
const restored = loadOnboardingProgress(undefined);
|
|
28
|
+
expect(restored).not.toBeNull();
|
|
29
|
+
expect(restored?.currentStep).toBe(2);
|
|
30
|
+
expect(restored?.completedSteps).toEqual(['a', 'b']);
|
|
31
|
+
expect(restored?.collected).toEqual({ a: { selected: ['x'] } });
|
|
32
|
+
expect(restored?.startedAt.toISOString()).toBe('2026-01-01T10:00:00.000Z');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('honors a custom storage key without touching the default one', () => {
|
|
36
|
+
saveOnboardingProgress('my-app:onboarding', {
|
|
37
|
+
currentStep: 1,
|
|
38
|
+
completedSteps: ['a'],
|
|
39
|
+
collected: {},
|
|
40
|
+
startedAt: new Date(),
|
|
41
|
+
lastUpdated: new Date(),
|
|
42
|
+
});
|
|
43
|
+
expect(localStorage.getItem('my-app:onboarding')).not.toBeNull();
|
|
44
|
+
expect(localStorage.getItem(defaultStorageKey)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null and discards corrupt payloads so future saves start clean', () => {
|
|
48
|
+
localStorage.setItem(defaultStorageKey, '{corrupt');
|
|
49
|
+
expect(loadOnboardingProgress(undefined)).toBeNull();
|
|
50
|
+
expect(localStorage.getItem(defaultStorageKey)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns null when nothing is stored', () => {
|
|
54
|
+
expect(loadOnboardingProgress(undefined)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('clears the stored payload safely', () => {
|
|
58
|
+
saveOnboardingProgress(undefined, {
|
|
59
|
+
currentStep: 0,
|
|
60
|
+
completedSteps: [],
|
|
61
|
+
collected: {},
|
|
62
|
+
startedAt: new Date(),
|
|
63
|
+
lastUpdated: new Date(),
|
|
64
|
+
});
|
|
65
|
+
clearOnboardingProgress(undefined);
|
|
66
|
+
expect(localStorage.getItem(defaultStorageKey)).toBeNull();
|
|
67
|
+
expect(() => clearOnboardingProgress(undefined)).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('buildOnboardingResult', () => {
|
|
72
|
+
it('computes the duration from start to finish', () => {
|
|
73
|
+
const result = buildOnboardingResult({
|
|
74
|
+
completedSteps: ['a', 'b'],
|
|
75
|
+
collected: { a: 1 },
|
|
76
|
+
startedAt: new Date('2026-01-01T10:00:00.000Z'),
|
|
77
|
+
finishedAt: new Date('2026-01-01T10:02:30.000Z'),
|
|
78
|
+
});
|
|
79
|
+
expect(result.durationMs).toBe(150_000);
|
|
80
|
+
expect(result.completedSteps).toEqual(['a', 'b']);
|
|
81
|
+
expect(result.collected).toEqual({ a: 1 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('clamps negative durations to zero', () => {
|
|
85
|
+
const result = buildOnboardingResult({
|
|
86
|
+
completedSteps: [],
|
|
87
|
+
collected: {},
|
|
88
|
+
startedAt: new Date('2026-01-01T10:02:30.000Z'),
|
|
89
|
+
finishedAt: new Date('2026-01-01T10:00:00.000Z'),
|
|
90
|
+
});
|
|
91
|
+
expect(result.durationMs).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage bridge for onboarding progress.
|
|
3
|
+
* Owns the I/O — validation, parsing, and the schema version are delegated
|
|
4
|
+
* to the validation module so this file is a thin persistence layer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { elapsedMillis } from '../calc/elapsed-millis';
|
|
8
|
+
import { isLocalStorageAvailable } from '../predicates/storage-available';
|
|
9
|
+
import { checkStoredProgress } from '../validation/stored-progress-check';
|
|
10
|
+
import { defaultStorageKey } from './default-storage-key';
|
|
11
|
+
import type { OnboardingResult } from '../types/config';
|
|
12
|
+
|
|
13
|
+
export interface RehydratedProgress {
|
|
14
|
+
currentStep: number;
|
|
15
|
+
completedSteps: string[];
|
|
16
|
+
collected: Record<string, unknown>;
|
|
17
|
+
startedAt: Date;
|
|
18
|
+
lastUpdated: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildKey(customKey: string | undefined): string {
|
|
22
|
+
return customKey ?? defaultStorageKey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Save the current progress to localStorage. Returns true on success.
|
|
27
|
+
* Surfaces persistence failures to the console; never silently masks them.
|
|
28
|
+
*/
|
|
29
|
+
export function saveOnboardingProgress(
|
|
30
|
+
key: string | undefined,
|
|
31
|
+
payload: {
|
|
32
|
+
currentStep: number;
|
|
33
|
+
completedSteps: ReadonlyArray<string>;
|
|
34
|
+
collected: Record<string, unknown>;
|
|
35
|
+
startedAt: Date;
|
|
36
|
+
lastUpdated: Date;
|
|
37
|
+
},
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!isLocalStorageAvailable()) return false;
|
|
40
|
+
try {
|
|
41
|
+
const serialized = JSON.stringify({
|
|
42
|
+
version: 1,
|
|
43
|
+
currentStep: payload.currentStep,
|
|
44
|
+
completedSteps: [...payload.completedSteps],
|
|
45
|
+
collected: { ...payload.collected },
|
|
46
|
+
startedAt: payload.startedAt.toISOString(),
|
|
47
|
+
lastUpdated: payload.lastUpdated.toISOString(),
|
|
48
|
+
});
|
|
49
|
+
window.localStorage.setItem(buildKey(key), serialized);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
|
|
53
|
+
console.error('[web-onboarding] failed to persist progress', error);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load and validate a previously saved progress payload.
|
|
60
|
+
* Returns null if storage is unavailable, nothing is stored, or the payload is corrupt.
|
|
61
|
+
*/
|
|
62
|
+
export function loadOnboardingProgress(key: string | undefined): RehydratedProgress | null {
|
|
63
|
+
if (!isLocalStorageAvailable()) return null;
|
|
64
|
+
const raw = window.localStorage.getItem(buildKey(key));
|
|
65
|
+
const checked = checkStoredProgress(raw);
|
|
66
|
+
if (!checked.ok) {
|
|
67
|
+
if (raw !== null) {
|
|
68
|
+
// Corrupt payload — discard so future saves start clean.
|
|
69
|
+
window.localStorage.removeItem(buildKey(key));
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
currentStep: checked.data.currentStep,
|
|
75
|
+
completedSteps: checked.data.completedSteps,
|
|
76
|
+
collected: checked.data.collected,
|
|
77
|
+
startedAt: new Date(checked.data.startedAt),
|
|
78
|
+
lastUpdated: new Date(checked.data.lastUpdated),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Remove the stored payload. Safe to call when nothing is stored. */
|
|
83
|
+
export function clearOnboardingProgress(key: string | undefined): void {
|
|
84
|
+
if (!isLocalStorageAvailable()) return;
|
|
85
|
+
window.localStorage.removeItem(buildKey(key));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the final `OnboardingResult` value the consumer receives via
|
|
90
|
+
* `onComplete`. Owns the duration calculation; no magic numbers here.
|
|
91
|
+
*/
|
|
92
|
+
export function buildOnboardingResult(input: {
|
|
93
|
+
completedSteps: ReadonlyArray<string>;
|
|
94
|
+
collected: Record<string, unknown>;
|
|
95
|
+
startedAt: Date;
|
|
96
|
+
finishedAt: Date;
|
|
97
|
+
}): OnboardingResult {
|
|
98
|
+
return {
|
|
99
|
+
completedSteps: input.completedSteps,
|
|
100
|
+
collected: input.collected,
|
|
101
|
+
durationMs: elapsedMillis(input.startedAt, input.finishedAt),
|
|
102
|
+
finishedAt: input.finishedAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { queryPermissionStatus, requestPermission } from './permission-bridge';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
vi.unstubAllGlobals();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('queryPermissionStatus', () => {
|
|
10
|
+
it('maps photos to prompt and contacts to unsupported (no web-standard API)', async () => {
|
|
11
|
+
await expect(queryPermissionStatus('photos')).resolves.toBe('prompt');
|
|
12
|
+
await expect(queryPermissionStatus('contacts')).resolves.toBe('unsupported');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('reads Notification.permission for notifications', async () => {
|
|
16
|
+
vi.stubGlobal('Notification', { permission: 'granted' });
|
|
17
|
+
await expect(queryPermissionStatus('notifications')).resolves.toBe('granted');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('normalizes the browser "default" state to prompt', async () => {
|
|
21
|
+
vi.stubGlobal('Notification', { permission: 'default' });
|
|
22
|
+
await expect(queryPermissionStatus('notifications')).resolves.toBe('prompt');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns unsupported when Notification is missing', async () => {
|
|
26
|
+
const original = (globalThis as { Notification?: unknown }).Notification;
|
|
27
|
+
vi.stubGlobal('Notification', undefined);
|
|
28
|
+
try {
|
|
29
|
+
await expect(queryPermissionStatus('notifications')).resolves.toBe('unsupported');
|
|
30
|
+
} finally {
|
|
31
|
+
if (original !== undefined) vi.stubGlobal('Notification', original);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('requestPermission — never throws', () => {
|
|
37
|
+
it('resolves granted when the user accepts notifications', async () => {
|
|
38
|
+
vi.stubGlobal('Notification', {
|
|
39
|
+
permission: 'default',
|
|
40
|
+
requestPermission: () => Promise.resolve('granted'),
|
|
41
|
+
});
|
|
42
|
+
await expect(requestPermission('notifications')).resolves.toBe('granted');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('resolves denied when the user rejects notifications', async () => {
|
|
46
|
+
vi.stubGlobal('Notification', {
|
|
47
|
+
permission: 'default',
|
|
48
|
+
requestPermission: () => Promise.resolve('denied'),
|
|
49
|
+
});
|
|
50
|
+
await expect(requestPermission('notifications')).resolves.toBe('denied');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('maps a throwing requestPermission to denied, not a rejection', async () => {
|
|
54
|
+
vi.stubGlobal('Notification', {
|
|
55
|
+
permission: 'default',
|
|
56
|
+
requestPermission: () => Promise.reject(new Error('blocked')),
|
|
57
|
+
});
|
|
58
|
+
await expect(requestPermission('notifications')).resolves.toBe('denied');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns unsupported for permission types without a web API', async () => {
|
|
62
|
+
await expect(requestPermission('contacts')).resolves.toBe('unsupported');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser permission API bridge.
|
|
3
|
+
* Owns the I/O — the predicates and the validation module decide whether
|
|
4
|
+
* the runtime supports the call, and how to normalize the raw status.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
doesBrowserSupportMediaCapture,
|
|
9
|
+
doesBrowserSupportGeolocation,
|
|
10
|
+
doesBrowserSupportNotifications,
|
|
11
|
+
} from '../predicates/permission-supports';
|
|
12
|
+
import { normalizePermissionStatus } from '../validation/permission-status-check';
|
|
13
|
+
import type { PermissionStatus, PermissionType } from '../types/screens';
|
|
14
|
+
|
|
15
|
+
const GEOLOCATION_TIMEOUT_MS = 5000;
|
|
16
|
+
|
|
17
|
+
/** Read the current permission status without prompting the user. */
|
|
18
|
+
export async function queryPermissionStatus(permission: PermissionType): Promise<PermissionStatus> {
|
|
19
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') return 'unsupported';
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
switch (permission) {
|
|
23
|
+
case 'notifications': {
|
|
24
|
+
if (!doesBrowserSupportNotifications()) return 'unsupported';
|
|
25
|
+
return normalizePermissionStatus(window.Notification.permission);
|
|
26
|
+
}
|
|
27
|
+
case 'camera':
|
|
28
|
+
case 'microphone': {
|
|
29
|
+
if (!doesBrowserSupportMediaCapture()) return 'unsupported';
|
|
30
|
+
if (!navigator.permissions?.query) return 'unsupported';
|
|
31
|
+
const status = await navigator.permissions.query({
|
|
32
|
+
name: permission === 'camera' ? 'camera' : 'microphone',
|
|
33
|
+
});
|
|
34
|
+
return normalizePermissionStatus(status.state);
|
|
35
|
+
}
|
|
36
|
+
case 'location': {
|
|
37
|
+
if (!doesBrowserSupportGeolocation()) return 'unsupported';
|
|
38
|
+
if (!navigator.permissions?.query) return 'unsupported';
|
|
39
|
+
const status = await navigator.permissions.query({ name: 'geolocation' });
|
|
40
|
+
return normalizePermissionStatus(status.state);
|
|
41
|
+
}
|
|
42
|
+
case 'photos':
|
|
43
|
+
return 'prompt';
|
|
44
|
+
case 'contacts':
|
|
45
|
+
return 'unsupported';
|
|
46
|
+
default:
|
|
47
|
+
return 'unsupported';
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
return 'unsupported';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Prompt the user for the given permission. Never throws — failures map to a status. */
|
|
55
|
+
export async function requestPermission(permission: PermissionType): Promise<PermissionStatus> {
|
|
56
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') return 'unsupported';
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
switch (permission) {
|
|
60
|
+
case 'notifications': {
|
|
61
|
+
if (!doesBrowserSupportNotifications()) return 'unsupported';
|
|
62
|
+
const result = await window.Notification.requestPermission();
|
|
63
|
+
return normalizePermissionStatus(result);
|
|
64
|
+
}
|
|
65
|
+
case 'camera':
|
|
66
|
+
case 'microphone': {
|
|
67
|
+
if (!doesBrowserSupportMediaCapture()) return 'unsupported';
|
|
68
|
+
const constraints: MediaStreamConstraints =
|
|
69
|
+
permission === 'camera' ? { video: true } : { audio: true };
|
|
70
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
71
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
72
|
+
return 'granted';
|
|
73
|
+
}
|
|
74
|
+
case 'location': {
|
|
75
|
+
if (!doesBrowserSupportGeolocation()) return 'unsupported';
|
|
76
|
+
return new Promise<PermissionStatus>((resolve) => {
|
|
77
|
+
navigator.geolocation.getCurrentPosition(
|
|
78
|
+
() => resolve('granted'),
|
|
79
|
+
(err) =>
|
|
80
|
+
resolve(err.code === err.PERMISSION_DENIED ? 'denied' : 'unsupported'),
|
|
81
|
+
{ timeout: GEOLOCATION_TIMEOUT_MS },
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
case 'photos':
|
|
86
|
+
return 'prompt';
|
|
87
|
+
case 'contacts':
|
|
88
|
+
return 'unsupported';
|
|
89
|
+
default:
|
|
90
|
+
return 'unsupported';
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
return 'denied';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OnboardingScreen } from '../types/screens';
|
|
2
|
+
import { checkScreenConfig } from './screen-config-check';
|
|
3
|
+
|
|
4
|
+
export type ValidationOutcome = { ok: true } | { ok: false; reason: string };
|
|
5
|
+
|
|
6
|
+
const MIN_FLOW_LENGTH = 1;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates the structure of an entire onboarding flow:
|
|
10
|
+
* - At least one screen exists
|
|
11
|
+
* - Every screen passes its own check
|
|
12
|
+
* - Step IDs are unique
|
|
13
|
+
*/
|
|
14
|
+
export function checkFlowStructure(screens: ReadonlyArray<OnboardingScreen>): ValidationOutcome {
|
|
15
|
+
if (!Array.isArray(screens) || screens.length < MIN_FLOW_LENGTH) {
|
|
16
|
+
return { ok: false, reason: 'flow must contain at least one screen' };
|
|
17
|
+
}
|
|
18
|
+
const seen = new Set<string>();
|
|
19
|
+
for (const screen of screens) {
|
|
20
|
+
const result = checkScreenConfig(screen);
|
|
21
|
+
if (!result.ok) return result;
|
|
22
|
+
if (seen.has(screen.id)) {
|
|
23
|
+
return { ok: false, reason: `duplicate step id: "${screen.id}"` };
|
|
24
|
+
}
|
|
25
|
+
seen.add(screen.id);
|
|
26
|
+
}
|
|
27
|
+
return { ok: true };
|
|
28
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { checkFlowStructure } from './flow-structure-check';
|
|
2
|
+
export type { ValidationOutcome } from './flow-structure-check';
|
|
3
|
+
export { checkScreenConfig, getRecognizedScreenTypes } from './screen-config-check';
|
|
4
|
+
export { checkStoredProgress } from './stored-progress-check';
|
|
5
|
+
export type { StoredProgressShape, StoredProgressOutcome } from './stored-progress-check';
|
|
6
|
+
export {
|
|
7
|
+
isRecognizedPermissionStatus,
|
|
8
|
+
normalizePermissionStatus,
|
|
9
|
+
} from './permission-status-check';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PermissionStatus } from '../types/screens';
|
|
2
|
+
|
|
3
|
+
const RECOGNIZED_STATUSES: ReadonlySet<PermissionStatus> = new Set<PermissionStatus>([
|
|
4
|
+
'granted',
|
|
5
|
+
'denied',
|
|
6
|
+
'prompt',
|
|
7
|
+
'unsupported',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/** Is the given string one of the recognized permission statuses? */
|
|
11
|
+
export function isRecognizedPermissionStatus(value: unknown): value is PermissionStatus {
|
|
12
|
+
return typeof value === 'string' && RECOGNIZED_STATUSES.has(value as PermissionStatus);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalize a browser-level permission string (which may be "default")
|
|
17
|
+
* to our internal PermissionStatus enum.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizePermissionStatus(rawValue: string | null | undefined): PermissionStatus {
|
|
20
|
+
if (rawValue === 'granted' || rawValue === 'denied') return rawValue;
|
|
21
|
+
if (rawValue === 'default' || rawValue === 'prompt') return 'prompt';
|
|
22
|
+
return 'unsupported';
|
|
23
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OnboardingScreen,
|
|
3
|
+
OnboardingScreenType,
|
|
4
|
+
} from '../types/screens';
|
|
5
|
+
import type { ValidationOutcome } from './flow-structure-check';
|
|
6
|
+
|
|
7
|
+
const RECOGNIZED_SCREEN_TYPES: ReadonlySet<OnboardingScreenType> = new Set<OnboardingScreenType>([
|
|
8
|
+
'welcome',
|
|
9
|
+
'goal-question',
|
|
10
|
+
'pain-points',
|
|
11
|
+
'social-proof',
|
|
12
|
+
'tinder-cards',
|
|
13
|
+
'personalized-solution',
|
|
14
|
+
'comparison',
|
|
15
|
+
'preferences',
|
|
16
|
+
'permission-priming',
|
|
17
|
+
'processing',
|
|
18
|
+
'app-demo',
|
|
19
|
+
'value-delivery',
|
|
20
|
+
'account-gate',
|
|
21
|
+
'paywall',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === 'object' && value !== null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkGoalQuestion(screen: OnboardingScreen): ValidationOutcome {
|
|
29
|
+
const options = (screen as { options?: unknown }).options;
|
|
30
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
31
|
+
return { ok: false, reason: 'goal-question screen requires non-empty options' };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function checkPainPoints(screen: OnboardingScreen): ValidationOutcome {
|
|
37
|
+
const points = (screen as { painPoints?: unknown }).painPoints;
|
|
38
|
+
if (!Array.isArray(points) || points.length === 0) {
|
|
39
|
+
return { ok: false, reason: 'pain-points screen requires non-empty painPoints' };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkTinderCards(screen: OnboardingScreen): ValidationOutcome {
|
|
45
|
+
const statements = (screen as { statements?: unknown }).statements;
|
|
46
|
+
if (!Array.isArray(statements) || statements.length === 0) {
|
|
47
|
+
return { ok: false, reason: 'tinder-cards screen requires non-empty statements' };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function checkPermissionPriming(screen: OnboardingScreen): ValidationOutcome {
|
|
53
|
+
const permission = (screen as { permission?: unknown }).permission;
|
|
54
|
+
if (typeof permission !== 'string') {
|
|
55
|
+
return { ok: false, reason: 'permission-priming screen requires a permission identifier' };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkPaywall(screen: OnboardingScreen): ValidationOutcome {
|
|
61
|
+
const price = (screen as { price?: unknown }).price;
|
|
62
|
+
const trialDays = (screen as { trialDays?: unknown }).trialDays;
|
|
63
|
+
if (typeof price !== 'string' || typeof trialDays !== 'number') {
|
|
64
|
+
return { ok: false, reason: 'paywall screen requires price and trialDays' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validates a single screen config. Per-screen rules live here so the file
|
|
71
|
+
* is the single place to look when a new screen type is added.
|
|
72
|
+
*/
|
|
73
|
+
export function checkScreenConfig(screen: OnboardingScreen): ValidationOutcome {
|
|
74
|
+
if (!isPlainObject(screen)) {
|
|
75
|
+
return { ok: false, reason: 'screen must be an object' };
|
|
76
|
+
}
|
|
77
|
+
const id = (screen as { id?: unknown }).id;
|
|
78
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
79
|
+
return { ok: false, reason: 'screen.id must be a non-empty string' };
|
|
80
|
+
}
|
|
81
|
+
const type = (screen as { type?: unknown }).type;
|
|
82
|
+
if (typeof type !== 'string' || !RECOGNIZED_SCREEN_TYPES.has(type as OnboardingScreenType)) {
|
|
83
|
+
return { ok: false, reason: `screen.type "${String(type)}" is not a recognized screen type` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
switch (type) {
|
|
87
|
+
case 'goal-question':
|
|
88
|
+
return checkGoalQuestion(screen);
|
|
89
|
+
case 'pain-points':
|
|
90
|
+
return checkPainPoints(screen);
|
|
91
|
+
case 'tinder-cards':
|
|
92
|
+
return checkTinderCards(screen);
|
|
93
|
+
case 'permission-priming':
|
|
94
|
+
return checkPermissionPriming(screen);
|
|
95
|
+
case 'paywall':
|
|
96
|
+
return checkPaywall(screen);
|
|
97
|
+
default:
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The set of screen types the orchestrator knows how to render. */
|
|
103
|
+
export function getRecognizedScreenTypes(): ReadonlySet<OnboardingScreenType> {
|
|
104
|
+
return RECOGNIZED_SCREEN_TYPES;
|
|
105
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage payload integrity check.
|
|
3
|
+
* Decides whether a localStorage string is something we can safely rehydrate
|
|
4
|
+
* from. The shape is versioned so future migrations stay clean.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface StoredProgressShape {
|
|
8
|
+
version: 1;
|
|
9
|
+
currentStep: number;
|
|
10
|
+
completedSteps: string[];
|
|
11
|
+
collected: Record<string, unknown>;
|
|
12
|
+
startedAt: string; // ISO
|
|
13
|
+
lastUpdated: string; // ISO
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type StoredProgressOutcome =
|
|
17
|
+
| { ok: true; data: StoredProgressShape }
|
|
18
|
+
| { ok: false; reason: string };
|
|
19
|
+
|
|
20
|
+
const STORAGE_VERSION = 1;
|
|
21
|
+
const MIN_STEP_INDEX = 0;
|
|
22
|
+
|
|
23
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === 'object' && value !== null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isStringArray(value: unknown): value is string[] {
|
|
28
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isIsoDateString(value: unknown): value is string {
|
|
32
|
+
if (typeof value !== 'string') return false;
|
|
33
|
+
const ms = Date.parse(value);
|
|
34
|
+
return Number.isFinite(ms);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Is the raw string a valid, current-version stored progress payload? */
|
|
38
|
+
export function checkStoredProgress(raw: string | null): StoredProgressOutcome {
|
|
39
|
+
if (raw === null) return { ok: false, reason: 'no stored progress' };
|
|
40
|
+
let parsed: unknown;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: e instanceof Error ? e.message : 'invalid JSON',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (!isObject(parsed)) {
|
|
50
|
+
return { ok: false, reason: 'stored payload must be an object' };
|
|
51
|
+
}
|
|
52
|
+
if (parsed.version !== STORAGE_VERSION) {
|
|
53
|
+
return { ok: false, reason: `unsupported storage version: ${String(parsed.version)}` };
|
|
54
|
+
}
|
|
55
|
+
if (typeof parsed.currentStep !== 'number' || parsed.currentStep < MIN_STEP_INDEX) {
|
|
56
|
+
return { ok: false, reason: 'currentStep must be a non-negative number' };
|
|
57
|
+
}
|
|
58
|
+
if (!isStringArray(parsed.completedSteps)) {
|
|
59
|
+
return { ok: false, reason: 'completedSteps must be a string array' };
|
|
60
|
+
}
|
|
61
|
+
if (!isObject(parsed.collected)) {
|
|
62
|
+
return { ok: false, reason: 'collected must be an object' };
|
|
63
|
+
}
|
|
64
|
+
if (!isIsoDateString(parsed.startedAt) || !isIsoDateString(parsed.lastUpdated)) {
|
|
65
|
+
return { ok: false, reason: 'startedAt and lastUpdated must be ISO date strings' };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, data: parsed as unknown as StoredProgressShape };
|
|
68
|
+
}
|