@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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon name registry.
|
|
3
|
+
*
|
|
4
|
+
* Consumers configure icons by string name (`iconName`, `heroIconName`).
|
|
5
|
+
* The library resolves those names against a CURATED, STATICALLY-IMPORTED set
|
|
6
|
+
* of lucide-react icons. A static registry is what keeps the published bundle
|
|
7
|
+
* small: a namespace import (`import * as Icons`) with dynamic lookup would
|
|
8
|
+
* force every one of lucide's ~1,500 icons into the consumer's bundle.
|
|
9
|
+
*
|
|
10
|
+
* Icons not in the registry simply do not render (callers fall back to a
|
|
11
|
+
* default or hide the icon slot). See README "Icons" for the full list.
|
|
12
|
+
*/
|
|
13
|
+
import type { LucideIcon } from 'lucide-react';
|
|
14
|
+
import {
|
|
15
|
+
Activity,
|
|
16
|
+
AlertTriangle,
|
|
17
|
+
Apple,
|
|
18
|
+
Award,
|
|
19
|
+
Ban,
|
|
20
|
+
Bell,
|
|
21
|
+
BookOpen,
|
|
22
|
+
Camera,
|
|
23
|
+
Check,
|
|
24
|
+
CheckCircle2,
|
|
25
|
+
Download,
|
|
26
|
+
Dumbbell,
|
|
27
|
+
GraduationCap,
|
|
28
|
+
Headphones,
|
|
29
|
+
HeartPulse,
|
|
30
|
+
Image,
|
|
31
|
+
Infinity as InfinityIcon,
|
|
32
|
+
Lock,
|
|
33
|
+
MapPin,
|
|
34
|
+
MessageCircle,
|
|
35
|
+
Mic,
|
|
36
|
+
Moon,
|
|
37
|
+
Palette,
|
|
38
|
+
PenTool,
|
|
39
|
+
Play,
|
|
40
|
+
Rocket,
|
|
41
|
+
Rewind,
|
|
42
|
+
Share2,
|
|
43
|
+
Shield,
|
|
44
|
+
ShieldCheck,
|
|
45
|
+
Smile,
|
|
46
|
+
Sparkles,
|
|
47
|
+
Square,
|
|
48
|
+
Star,
|
|
49
|
+
Theater,
|
|
50
|
+
Tv,
|
|
51
|
+
Users,
|
|
52
|
+
X,
|
|
53
|
+
Zap,
|
|
54
|
+
} from 'lucide-react';
|
|
55
|
+
|
|
56
|
+
const ICON_REGISTRY: Readonly<Record<string, LucideIcon>> = {
|
|
57
|
+
Activity,
|
|
58
|
+
AlertTriangle,
|
|
59
|
+
Apple,
|
|
60
|
+
Award,
|
|
61
|
+
Ban,
|
|
62
|
+
Bell,
|
|
63
|
+
BookOpen,
|
|
64
|
+
Camera,
|
|
65
|
+
Check,
|
|
66
|
+
CheckCircle2,
|
|
67
|
+
Download,
|
|
68
|
+
Dumbbell,
|
|
69
|
+
GraduationCap,
|
|
70
|
+
Headphones,
|
|
71
|
+
HeartPulse,
|
|
72
|
+
Image,
|
|
73
|
+
// `Infinity` is a reserved word — the lucide export is aliased at import.
|
|
74
|
+
Infinity: InfinityIcon,
|
|
75
|
+
Lock,
|
|
76
|
+
MapPin,
|
|
77
|
+
MessageCircle,
|
|
78
|
+
Mic,
|
|
79
|
+
Moon,
|
|
80
|
+
Palette,
|
|
81
|
+
PenTool,
|
|
82
|
+
Play,
|
|
83
|
+
Rocket,
|
|
84
|
+
Rewind,
|
|
85
|
+
Share2,
|
|
86
|
+
Shield,
|
|
87
|
+
ShieldCheck,
|
|
88
|
+
Smile,
|
|
89
|
+
Sparkles,
|
|
90
|
+
Square,
|
|
91
|
+
Star,
|
|
92
|
+
Theater,
|
|
93
|
+
Tv,
|
|
94
|
+
Users,
|
|
95
|
+
X,
|
|
96
|
+
Zap,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The icon names this package can resolve without application help. */
|
|
100
|
+
export function getRegisteredIconNames(): ReadonlyArray<string> {
|
|
101
|
+
return Object.keys(ICON_REGISTRY);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Does the given name resolve to an icon bundled with this package? */
|
|
105
|
+
export function doesIconNameResolve(name: string | undefined): boolean {
|
|
106
|
+
return typeof name === 'string' && name in ICON_REGISTRY;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resolve a bundled icon by name. Returns null when the name is unknown. */
|
|
110
|
+
export function resolveIconByName(name: string | undefined): LucideIcon | null {
|
|
111
|
+
if (!doesIconNameResolve(name)) return null;
|
|
112
|
+
return ICON_REGISTRY[name as string];
|
|
113
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export {
|
|
2
|
+
isPermissionGranted,
|
|
3
|
+
isPermissionDenied,
|
|
4
|
+
isPermissionPrompt,
|
|
5
|
+
isPermissionUnsupported,
|
|
6
|
+
isPermissionDecided,
|
|
7
|
+
} from './permission-granted';
|
|
8
|
+
export {
|
|
9
|
+
doesBrowserSupportNotifications,
|
|
10
|
+
doesBrowserSupportMediaCapture,
|
|
11
|
+
doesBrowserSupportGeolocation,
|
|
12
|
+
isRuntimeSupportMissing,
|
|
13
|
+
} from './permission-supports';
|
|
14
|
+
export { isLocalStorageAvailable } from './storage-available';
|
|
15
|
+
export {
|
|
16
|
+
doesIconNameResolve,
|
|
17
|
+
resolveIconByName,
|
|
18
|
+
getRegisteredIconNames,
|
|
19
|
+
} from './icon-name-resolves';
|
|
20
|
+
export {
|
|
21
|
+
hasOnRequest,
|
|
22
|
+
hasOnAccept,
|
|
23
|
+
hasOnSignIn,
|
|
24
|
+
accountGateAllowsSkip,
|
|
25
|
+
} from './has-handler';
|
|
26
|
+
export { screenEmitsData } from './screen-emits-data';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PermissionStatus } from '../types/screens';
|
|
2
|
+
|
|
3
|
+
/** Is the permission currently granted by the user? */
|
|
4
|
+
export function isPermissionGranted(status: PermissionStatus): boolean {
|
|
5
|
+
return status === 'granted';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Did the user explicitly deny this permission? */
|
|
9
|
+
export function isPermissionDenied(status: PermissionStatus): boolean {
|
|
10
|
+
return status === 'denied';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Is the permission in the initial "ask the user" state? */
|
|
14
|
+
export function isPermissionPrompt(status: PermissionStatus): boolean {
|
|
15
|
+
return status === 'prompt';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Does the current runtime lack support for this permission? */
|
|
19
|
+
export function isPermissionUnsupported(status: PermissionStatus): boolean {
|
|
20
|
+
return status === 'unsupported';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Has the permission been decided one way or the other? (granted OR denied) */
|
|
24
|
+
export function isPermissionDecided(status: PermissionStatus): boolean {
|
|
25
|
+
return status === 'granted' || status === 'denied';
|
|
26
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-feature predicates for the runtime.
|
|
3
|
+
* Each predicate answers "does the user's browser/environment support this API?".
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PermissionType } from '../types/screens';
|
|
7
|
+
|
|
8
|
+
declare const navigator: undefined | {
|
|
9
|
+
mediaDevices?: { getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream> };
|
|
10
|
+
geolocation?: {
|
|
11
|
+
getCurrentPosition: (
|
|
12
|
+
success: (pos: GeolocationPosition) => void,
|
|
13
|
+
error?: (err: GeolocationPositionError) => void,
|
|
14
|
+
options?: PositionOptions,
|
|
15
|
+
) => void;
|
|
16
|
+
};
|
|
17
|
+
permissions?: { query: (descriptor: { name: string }) => Promise<{ state: string }> };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function safeWindow(): (Window & typeof globalThis) | null {
|
|
21
|
+
return typeof window === 'undefined' ? null : (window as Window & typeof globalThis);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeNavigator(): typeof navigator | null {
|
|
25
|
+
if (typeof navigator === 'undefined') return null;
|
|
26
|
+
return navigator;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Is `Notification` available in the current runtime? */
|
|
30
|
+
export function doesBrowserSupportNotifications(): boolean {
|
|
31
|
+
const win = safeWindow();
|
|
32
|
+
if (!win) return false;
|
|
33
|
+
return typeof win.Notification !== 'undefined';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Is `getUserMedia` available (camera/microphone capture)? */
|
|
37
|
+
export function doesBrowserSupportMediaCapture(): boolean {
|
|
38
|
+
const nav = safeNavigator();
|
|
39
|
+
if (!nav) return false;
|
|
40
|
+
return Boolean(nav.mediaDevices?.getUserMedia);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Is geolocation available? */
|
|
44
|
+
export function doesBrowserSupportGeolocation(): boolean {
|
|
45
|
+
const nav = safeNavigator();
|
|
46
|
+
if (!nav) return false;
|
|
47
|
+
return Boolean(nav.geolocation);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Aggregate check used by `usePermissionPriming` to report "unsupported" cleanly. */
|
|
51
|
+
export function isRuntimeSupportMissing(permission: PermissionType): boolean {
|
|
52
|
+
switch (permission) {
|
|
53
|
+
case 'notifications':
|
|
54
|
+
return !doesBrowserSupportNotifications();
|
|
55
|
+
case 'camera':
|
|
56
|
+
case 'microphone':
|
|
57
|
+
return !doesBrowserSupportMediaCapture();
|
|
58
|
+
case 'location':
|
|
59
|
+
return !doesBrowserSupportGeolocation();
|
|
60
|
+
case 'photos':
|
|
61
|
+
case 'contacts':
|
|
62
|
+
return true; // No web-standard APIs for these in this package.
|
|
63
|
+
default:
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
doesIconNameResolve,
|
|
4
|
+
getRegisteredIconNames,
|
|
5
|
+
resolveIconByName,
|
|
6
|
+
} from './icon-name-resolves';
|
|
7
|
+
import {
|
|
8
|
+
isPermissionDecided,
|
|
9
|
+
isPermissionDenied,
|
|
10
|
+
isPermissionGranted,
|
|
11
|
+
isPermissionPrompt,
|
|
12
|
+
isPermissionUnsupported,
|
|
13
|
+
} from './permission-granted';
|
|
14
|
+
import {
|
|
15
|
+
doesBrowserSupportGeolocation,
|
|
16
|
+
doesBrowserSupportMediaCapture,
|
|
17
|
+
doesBrowserSupportNotifications,
|
|
18
|
+
isRuntimeSupportMissing,
|
|
19
|
+
} from './permission-supports';
|
|
20
|
+
import { isLocalStorageAvailable } from './storage-available';
|
|
21
|
+
import {
|
|
22
|
+
accountGateAllowsSkip,
|
|
23
|
+
hasOnAccept,
|
|
24
|
+
hasOnRequest,
|
|
25
|
+
hasOnSignIn,
|
|
26
|
+
} from './has-handler';
|
|
27
|
+
import { screenEmitsData } from './screen-emits-data';
|
|
28
|
+
import type { OnboardingScreen } from '../types/screens';
|
|
29
|
+
|
|
30
|
+
// TEST_SCENARIOS.md §24
|
|
31
|
+
describe('icon-name-resolves (§24.4)', () => {
|
|
32
|
+
it('resolves registered names', () => {
|
|
33
|
+
expect(doesIconNameResolve('HeartPulse')).toBe(true);
|
|
34
|
+
expect(doesIconNameResolve('Sparkles')).toBe(true);
|
|
35
|
+
// lucide-react icons are forwardRef memo objects, not plain functions.
|
|
36
|
+
expect(resolveIconByName('Sparkles')).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not resolve unknown, missing, or case-mismatched names', () => {
|
|
40
|
+
expect(doesIconNameResolve('NonExistentIcon')).toBe(false);
|
|
41
|
+
expect(doesIconNameResolve(undefined)).toBe(false);
|
|
42
|
+
expect(doesIconNameResolve('sparkles')).toBe(false); // registry is PascalCase
|
|
43
|
+
expect(resolveIconByName('NonExistentIcon')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('resolves the reserved-word alias "Infinity"', () => {
|
|
47
|
+
expect(doesIconNameResolve('Infinity')).toBe(true);
|
|
48
|
+
expect(resolveIconByName('Infinity')).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('resolves every icon used by the bundled templates — including the video set', () => {
|
|
52
|
+
// Icons referenced across art/health/platform/video templates.
|
|
53
|
+
const templateIcons = [
|
|
54
|
+
'Activity', 'Apple', 'Award', 'Ban', 'Bell', 'BookOpen', 'Camera', 'Check',
|
|
55
|
+
'CheckCircle2', 'Download', 'Dumbbell', 'GraduationCap', 'Headphones',
|
|
56
|
+
'HeartPulse', 'Image', 'Infinity', 'Lock', 'MapPin', 'MessageCircle',
|
|
57
|
+
'Mic', 'Moon', 'Palette', 'PenTool', 'Play', 'Rocket', 'Rewind', 'Share2',
|
|
58
|
+
'Shield', 'ShieldCheck', 'Smile', 'Sparkles', 'Square', 'Star', 'Theater',
|
|
59
|
+
'Tv', 'Users', 'X', 'Zap',
|
|
60
|
+
];
|
|
61
|
+
for (const name of templateIcons) {
|
|
62
|
+
expect(doesIconNameResolve(name), `expected "${name}" to resolve`).toBe(true);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps the registry curated (each name maps to a component)', () => {
|
|
67
|
+
const names = getRegisteredIconNames();
|
|
68
|
+
expect(names.length).toBeGreaterThan(30);
|
|
69
|
+
expect(names.length).toBeLessThan(60); // a namespace import would be ~1,500
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
expect(resolveIconByName(name), `icon "${name}"`).toBeTruthy();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// TEST_SCENARIOS.md §24.1
|
|
77
|
+
describe('permission-granted (§24.1)', () => {
|
|
78
|
+
it('classifies statuses', () => {
|
|
79
|
+
expect(isPermissionGranted('granted')).toBe(true);
|
|
80
|
+
expect(isPermissionGranted('prompt')).toBe(false);
|
|
81
|
+
expect(isPermissionDenied('denied')).toBe(true);
|
|
82
|
+
expect(isPermissionPrompt('prompt')).toBe(true);
|
|
83
|
+
expect(isPermissionDecided('granted')).toBe(true);
|
|
84
|
+
expect(isPermissionDecided('denied')).toBe(true);
|
|
85
|
+
expect(isPermissionDecided('prompt')).toBe(false);
|
|
86
|
+
expect(isPermissionUnsupported('unsupported')).toBe(true);
|
|
87
|
+
expect(isPermissionUnsupported('granted')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// TEST_SCENARIOS.md §24.2 — jsdom stands in for the "modern browser" runtime.
|
|
92
|
+
describe('permission-supports (§24.2)', () => {
|
|
93
|
+
it('reports support from the live navigator/window', () => {
|
|
94
|
+
// jsdom ships geolocation + mediaDevices in recent versions; the exact
|
|
95
|
+
// answer varies by jsdom build, so assert predicate/API agreement instead
|
|
96
|
+
// of hardcoding booleans.
|
|
97
|
+
expect(doesBrowserSupportMediaCapture()).toBe(
|
|
98
|
+
Boolean((globalThis as { navigator?: { mediaDevices?: { getUserMedia?: unknown } } })
|
|
99
|
+
.navigator?.mediaDevices?.getUserMedia),
|
|
100
|
+
);
|
|
101
|
+
expect(doesBrowserSupportGeolocation()).toBe(
|
|
102
|
+
Boolean(
|
|
103
|
+
(globalThis as { navigator?: { geolocation?: unknown } }).navigator?.geolocation,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reports notification support from the window object', () => {
|
|
109
|
+
expect(doesBrowserSupportNotifications()).toBe(
|
|
110
|
+
typeof (globalThis as { Notification?: unknown }).Notification !== 'undefined',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('treats photos/contacts as unsupported (no web-standard API)', () => {
|
|
115
|
+
expect(isRuntimeSupportMissing('photos')).toBe(true);
|
|
116
|
+
expect(isRuntimeSupportMissing('contacts')).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// TEST_SCENARIOS.md §24.3
|
|
121
|
+
describe('storage-available (§24.3)', () => {
|
|
122
|
+
it('works in the jsdom browser environment', () => {
|
|
123
|
+
expect(isLocalStorageAvailable()).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns false when storage access throws (e.g. blocked cookies)', () => {
|
|
127
|
+
const original = window.localStorage;
|
|
128
|
+
// Replace with a getter that throws, as Safari private mode historically did.
|
|
129
|
+
Object.defineProperty(window, 'localStorage', {
|
|
130
|
+
configurable: true,
|
|
131
|
+
get() {
|
|
132
|
+
throw new DOMException('Access is denied for this document', 'SecurityError');
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
expect(isLocalStorageAvailable()).toBe(false);
|
|
137
|
+
} finally {
|
|
138
|
+
Object.defineProperty(window, 'localStorage', {
|
|
139
|
+
configurable: true,
|
|
140
|
+
value: original,
|
|
141
|
+
writable: true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// TEST_SCENARIOS.md §24.5
|
|
148
|
+
describe('has-handler (§24.5)', () => {
|
|
149
|
+
const paywall = {
|
|
150
|
+
id: 'w',
|
|
151
|
+
type: 'paywall',
|
|
152
|
+
price: '$1',
|
|
153
|
+
trialDays: 7,
|
|
154
|
+
} as Parameters<typeof hasOnAccept>[0];
|
|
155
|
+
|
|
156
|
+
it('detects usable callbacks', () => {
|
|
157
|
+
expect(hasOnAccept(paywall)).toBe(false);
|
|
158
|
+
expect(hasOnAccept({ ...paywall, onAccept: async () => ({ accepted: true, trialStarted: true }) })).toBe(true);
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
hasOnRequest({
|
|
162
|
+
permission: 'camera',
|
|
163
|
+
description: 'd',
|
|
164
|
+
benefits: [],
|
|
165
|
+
} as unknown as Parameters<typeof hasOnRequest>[0]),
|
|
166
|
+
).toBe(false);
|
|
167
|
+
|
|
168
|
+
expect(
|
|
169
|
+
hasOnSignIn({
|
|
170
|
+
optional: false,
|
|
171
|
+
onSignIn: () => Promise.resolve({ signedIn: true }),
|
|
172
|
+
} as unknown as Parameters<typeof hasOnSignIn>[0]),
|
|
173
|
+
).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('allows skip only on optional account gates', () => {
|
|
177
|
+
expect(
|
|
178
|
+
accountGateAllowsSkip({ optional: true } as unknown as Parameters<
|
|
179
|
+
typeof accountGateAllowsSkip
|
|
180
|
+
>[0]),
|
|
181
|
+
).toBe(true);
|
|
182
|
+
expect(
|
|
183
|
+
accountGateAllowsSkip({ optional: false } as unknown as Parameters<
|
|
184
|
+
typeof accountGateAllowsSkip
|
|
185
|
+
>[0]),
|
|
186
|
+
).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// TEST_SCENARIOS.md §24.6
|
|
191
|
+
describe('screen-emits-data (§24.6)', () => {
|
|
192
|
+
it('marks view-only screens as non-emitting', () => {
|
|
193
|
+
expect(screenEmitsData({ id: 'a', type: 'welcome' } as OnboardingScreen)).toBe(false);
|
|
194
|
+
expect(screenEmitsData({ id: 'v', type: 'value-delivery' } as OnboardingScreen)).toBe(false);
|
|
195
|
+
expect(screenEmitsData({ id: 's', type: 'social-proof' } as OnboardingScreen)).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('marks data-collecting screens as emitting', () => {
|
|
199
|
+
expect(screenEmitsData({ id: 'g', type: 'goal-question' } as OnboardingScreen)).toBe(true);
|
|
200
|
+
expect(screenEmitsData({ id: 'p', type: 'preferences' } as OnboardingScreen)).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OnboardingScreen } from '../types/screens';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Does this screen type emit a payload to the orchestrator when the user advances?
|
|
5
|
+
* Pure view-only screens (welcome, social-proof, comparison, value-delivery) do not.
|
|
6
|
+
*/
|
|
7
|
+
const VIEW_ONLY_SCREEN_TYPES: ReadonlySet<OnboardingScreen['type']> = new Set([
|
|
8
|
+
'welcome',
|
|
9
|
+
'social-proof',
|
|
10
|
+
'comparison',
|
|
11
|
+
'value-delivery',
|
|
12
|
+
'personalized-solution',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export function screenEmitsData(screen: OnboardingScreen): boolean {
|
|
16
|
+
return !VIEW_ONLY_SCREEN_TYPES.has(screen.type);
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const PROBE_KEY = '@umituz/web-onboarding:probe';
|
|
2
|
+
const PROBE_VALUE = '1';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Can the current environment use `localStorage`?
|
|
6
|
+
* - Server-side / SSR: false
|
|
7
|
+
* - Disabled cookies/storage in the browser: false
|
|
8
|
+
* - Otherwise: true
|
|
9
|
+
*
|
|
10
|
+
* Side effect: writes and removes a tiny probe to verify both write & read work.
|
|
11
|
+
*/
|
|
12
|
+
export function isLocalStorageAvailable(): boolean {
|
|
13
|
+
if (typeof window === 'undefined') return false;
|
|
14
|
+
try {
|
|
15
|
+
const probe = window.localStorage;
|
|
16
|
+
probe.setItem(PROBE_KEY, PROBE_VALUE);
|
|
17
|
+
probe.removeItem(PROBE_KEY);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Sparkles } from 'lucide-react';
|
|
3
|
+
import type { DemoConfig } from '../types/screens';
|
|
4
|
+
import type { OnboardingScreen } from '../types/screens';
|
|
5
|
+
|
|
6
|
+
export interface TemplateDemoResult {
|
|
7
|
+
variant: string;
|
|
8
|
+
palette: string[];
|
|
9
|
+
generatedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default demo component used by the art and platform templates.
|
|
14
|
+
* Lets users produce a real artifact in the template without forcing consumers
|
|
15
|
+
* to wire up their own demo before they can preview the flow.
|
|
16
|
+
*
|
|
17
|
+
* Consumers who want their own demo should clone the template and replace
|
|
18
|
+
* `screens[?].demo.component` with their own implementation.
|
|
19
|
+
*/
|
|
20
|
+
function TemplateDemoFallback({ onSave }: { onSave: (result: TemplateDemoResult) => void }) {
|
|
21
|
+
const palettes: ReadonlyArray<{ variant: string; palette: string[] }> = [
|
|
22
|
+
{ variant: 'Minimal', palette: ['#0F172A', '#475569', '#94A3B8', '#F1F5F9', '#FFFFFF'] },
|
|
23
|
+
{ variant: 'Bold', palette: ['#7C3AED', '#EC4899', '#F59E0B', '#10B981', '#0EA5E9'] },
|
|
24
|
+
{ variant: 'Retro', palette: ['#92400E', '#B45309', '#D97706', '#FCD34D', '#FEF3C7'] },
|
|
25
|
+
{ variant: 'Playful', palette: ['#F472B6', '#A78BFA', '#60A9FA', '#34D399', '#FBBF24'] },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
29
|
+
const active = palettes[activeIdx];
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex flex-col gap-4">
|
|
33
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
34
|
+
{palettes.map((p, i) => (
|
|
35
|
+
<button
|
|
36
|
+
key={p.variant}
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() => setActiveIdx(i)}
|
|
39
|
+
className={
|
|
40
|
+
i === activeIdx
|
|
41
|
+
? 'rounded-lg border-2 border-[rgb(var(--uob-primary-500))] p-2'
|
|
42
|
+
: 'rounded-lg border-2 border-transparent p-2 hover:border-[rgb(var(--uob-border))]'
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
<div className="flex h-12 w-full overflow-hidden rounded">
|
|
46
|
+
{p.palette.map((c) => (
|
|
47
|
+
<span key={c} className="flex-1" style={{ background: c }} />
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
<span className="mt-1 block text-xs font-medium text-[rgb(var(--uob-text-primary))]">
|
|
51
|
+
{p.variant}
|
|
52
|
+
</span>
|
|
53
|
+
</button>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="rounded-xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-6">
|
|
57
|
+
<div className="flex h-32 w-full overflow-hidden rounded-lg">
|
|
58
|
+
{active.palette.map((c) => (
|
|
59
|
+
<span key={c} className="flex-1" style={{ background: c }} />
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
<p className="mt-3 text-sm text-[rgb(var(--uob-text-muted))]">
|
|
63
|
+
Preview: <strong className="text-[rgb(var(--uob-text-primary))]">{active.variant}</strong>{' '}
|
|
64
|
+
palette
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() =>
|
|
70
|
+
onSave({
|
|
71
|
+
variant: active.variant,
|
|
72
|
+
palette: [...active.palette],
|
|
73
|
+
generatedAt: Date.now(),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[rgb(var(--uob-primary-500))] px-4 py-3 text-sm font-semibold text-white hover:bg-[rgb(var(--uob-primary-600))]"
|
|
77
|
+
>
|
|
78
|
+
<Sparkles className="h-4 w-4" />
|
|
79
|
+
Save this style
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fallbackDemo: DemoConfig = {
|
|
86
|
+
component: TemplateDemoFallback as React.ComponentType<Record<string, unknown>>,
|
|
87
|
+
onSave: () => undefined,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const fallbackResult: TemplateDemoResult = {
|
|
91
|
+
variant: 'Minimal',
|
|
92
|
+
palette: ['#0F172A', '#475569', '#94A3B8', '#F1F5F9', '#FFFFFF'],
|
|
93
|
+
generatedAt: 0,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Replace an `app-demo` screen in a template with a working default.
|
|
98
|
+
* Used by the art and platform templates so the flow is fully runnable
|
|
99
|
+
* out of the box; consumers can override the demo.component for their app.
|
|
100
|
+
*/
|
|
101
|
+
export function withDefaultDemo(screen: OnboardingScreen, fallback: DemoConfig = fallbackDemo): OnboardingScreen {
|
|
102
|
+
if (screen.type !== 'app-demo') return screen;
|
|
103
|
+
return { ...screen, demo: { ...fallback, ...screen.demo } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function defaultDemoResult(): TemplateDemoResult {
|
|
107
|
+
return { ...fallbackResult };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Re-export for convenience.
|
|
111
|
+
export { TemplateDemoFallback };
|
|
112
|
+
export type { DemoConfig };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { OnboardingConfig } from '../types/config';
|
|
2
|
+
import type { OnboardingScreen } from '../types/screens';
|
|
3
|
+
import { withDefaultDemo, defaultDemoResult } from './TemplateDemoFallback';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Art category — demo-driven, 3–5 screens.
|
|
7
|
+
* Show value fast: by the end of step 3 the user has produced a real artifact.
|
|
8
|
+
*
|
|
9
|
+
* The demo screen ships with a working palette-preview fallback so the template
|
|
10
|
+
* is runnable out of the box. Replace `screens[2].demo.component` to plug in
|
|
11
|
+
* the consumer's own demo.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const baseScreens: OnboardingScreen[] = [
|
|
15
|
+
{
|
|
16
|
+
id: 'welcome',
|
|
17
|
+
type: 'welcome',
|
|
18
|
+
title: 'Make something beautiful in 60 seconds',
|
|
19
|
+
subtitle: 'No design skills. No learning curve. Just create.',
|
|
20
|
+
cta: 'Try the demo',
|
|
21
|
+
heroIconName: 'Sparkles',
|
|
22
|
+
highlight: 'Live demo',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'style',
|
|
26
|
+
type: 'goal-question',
|
|
27
|
+
question: 'Pick a style that feels like you',
|
|
28
|
+
multiSelect: true,
|
|
29
|
+
options: [
|
|
30
|
+
{ id: 'minimal', label: 'Minimal', iconName: 'Square' },
|
|
31
|
+
{ id: 'bold', label: 'Bold colors', iconName: 'Zap' },
|
|
32
|
+
{ id: 'retro', label: 'Retro', iconName: 'Rewind' },
|
|
33
|
+
{ id: 'playful', label: 'Playful', iconName: 'Smile' },
|
|
34
|
+
],
|
|
35
|
+
onChange: () => undefined,
|
|
36
|
+
cta: 'Continue',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'demo',
|
|
40
|
+
type: 'app-demo',
|
|
41
|
+
title: 'Your turn — pick a palette',
|
|
42
|
+
demo: {
|
|
43
|
+
// `withDefaultDemo` below replaces this no-op with the built-in fallback.
|
|
44
|
+
component: () => null,
|
|
45
|
+
onSave: () => undefined,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'value',
|
|
50
|
+
type: 'value-delivery',
|
|
51
|
+
title: 'You made this!',
|
|
52
|
+
result: defaultDemoResult(),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'paywall',
|
|
56
|
+
type: 'paywall',
|
|
57
|
+
title: 'Keep creating',
|
|
58
|
+
subtitle: 'Unlimited generations, all styles, no watermarks.',
|
|
59
|
+
price: '$4.99 / week',
|
|
60
|
+
trialDays: 3,
|
|
61
|
+
cta: 'Start free trial',
|
|
62
|
+
secondaryCta: 'Not now',
|
|
63
|
+
features: [
|
|
64
|
+
{ iconName: 'Infinity', text: 'Unlimited creations', highlighted: true },
|
|
65
|
+
{ iconName: 'Palette', text: 'All styles unlocked' },
|
|
66
|
+
{ iconName: 'Download', text: 'High-res export' },
|
|
67
|
+
{ iconName: 'Share2', text: 'Share anywhere' },
|
|
68
|
+
],
|
|
69
|
+
onSecondaryCta: () => undefined,
|
|
70
|
+
onAccept: async () => ({ accepted: true, trialStarted: true }),
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const screens = baseScreens.map((s) => withDefaultDemo(s));
|
|
75
|
+
|
|
76
|
+
export const artOnboardingTemplate: OnboardingConfig = {
|
|
77
|
+
category: 'art',
|
|
78
|
+
screens,
|
|
79
|
+
onSkip: () => undefined,
|
|
80
|
+
onComplete: () => undefined,
|
|
81
|
+
};
|