@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.
Files changed (133) hide show
  1. package/README.md +47 -73
  2. package/package.json +15 -7
  3. package/src/config/DashboardConfig.ts +19 -68
  4. package/src/domains/analytics/components/index.ts +0 -6
  5. package/src/domains/analytics/hooks/index.ts +0 -6
  6. package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
  7. package/src/domains/analytics/index.ts +0 -10
  8. package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
  9. package/src/domains/analytics/services/PerformanceService.ts +27 -138
  10. package/src/domains/analytics/services/index.ts +0 -4
  11. package/src/domains/analytics/types/analytics.ts +100 -270
  12. package/src/domains/analytics/types/index.ts +0 -6
  13. package/src/domains/analytics/utils/analytics.test.ts +2 -9
  14. package/src/domains/analytics/utils/analytics.ts +1 -22
  15. package/src/domains/analytics/utils/index.ts +0 -6
  16. package/src/domains/calendar/config.ts +0 -7
  17. package/src/domains/calendar/hooks/index.ts +0 -4
  18. package/src/domains/calendar/hooks/useCalendar.ts +7 -27
  19. package/src/domains/calendar/index.ts +0 -4
  20. package/src/domains/calendar/services/CalendarService.ts +0 -21
  21. package/src/domains/calendar/services/index.ts +0 -4
  22. package/src/domains/calendar/types/calendar.types.ts +9 -53
  23. package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
  24. package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
  25. package/src/domains/calendar/utils/index.ts +0 -26
  26. package/src/domains/layouts/components/index.ts +0 -4
  27. package/src/domains/layouts/hooks/dashboard.ts +0 -21
  28. package/src/domains/layouts/hooks/index.ts +0 -4
  29. package/src/domains/layouts/index.ts +0 -6
  30. package/src/domains/layouts/theme/default.ts +0 -12
  31. package/src/domains/layouts/theme/index.ts +0 -4
  32. package/src/domains/layouts/theme/presets.ts +0 -9
  33. package/src/domains/layouts/theme/utils.ts +0 -15
  34. package/src/domains/layouts/types/index.ts +0 -4
  35. package/src/domains/layouts/types/layout.ts +18 -56
  36. package/src/domains/layouts/types/notification.ts +4 -17
  37. package/src/domains/layouts/types/sidebar.ts +8 -28
  38. package/src/domains/layouts/types/theme.ts +23 -59
  39. package/src/domains/layouts/types/user.ts +9 -30
  40. package/src/domains/layouts/utils/dashboard.ts +4 -48
  41. package/src/domains/layouts/utils/index.ts +0 -4
  42. package/src/domains/modules/build.tsx +2 -16
  43. package/src/domains/modules/types.ts +8 -23
  44. package/src/domains/onboarding/calc/calc.test.ts +173 -0
  45. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +27 -0
  46. package/src/domains/onboarding/calc/count-with-locale.ts +8 -0
  47. package/src/domains/onboarding/calc/duration-equal-split.ts +12 -0
  48. package/src/domains/onboarding/calc/elapsed-millis.ts +6 -0
  49. package/src/domains/onboarding/calc/index.ts +36 -0
  50. package/src/domains/onboarding/calc/progress-percentage.ts +27 -0
  51. package/src/domains/onboarding/calc/slider-value-bounds.ts +34 -0
  52. package/src/domains/onboarding/calc/step-index-clamp.ts +19 -0
  53. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +54 -0
  54. package/src/domains/onboarding/components/AnimationWrapper.tsx +69 -0
  55. package/src/domains/onboarding/components/OnboardingErrorBoundary.tsx +61 -0
  56. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +459 -0
  57. package/src/domains/onboarding/components/OnboardingFlow.tsx +313 -0
  58. package/src/domains/onboarding/components/OnboardingIntl.tsx +98 -0
  59. package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
  60. package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
  61. package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
  62. package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
  63. package/src/domains/onboarding/components/SwipeCard.test.tsx +49 -0
  64. package/src/domains/onboarding/components/SwipeCard.tsx +88 -0
  65. package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
  66. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +79 -0
  67. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +33 -0
  68. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +49 -0
  69. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +59 -0
  70. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  71. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +122 -0
  72. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +98 -0
  73. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +40 -0
  74. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +180 -0
  75. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +63 -0
  76. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +87 -0
  77. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +91 -0
  78. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +69 -0
  79. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +32 -0
  80. package/src/domains/onboarding/components/screens/index.ts +28 -0
  81. package/src/domains/onboarding/hooks/index.ts +7 -0
  82. package/src/domains/onboarding/hooks/useOnboardingAnalytics.test.tsx +74 -0
  83. package/src/domains/onboarding/hooks/useOnboardingAnalytics.ts +49 -0
  84. package/src/domains/onboarding/hooks/useOnboardingProgress.test.tsx +150 -0
  85. package/src/domains/onboarding/hooks/useOnboardingProgress.ts +249 -0
  86. package/src/domains/onboarding/hooks/usePermissionPriming.ts +45 -0
  87. package/src/domains/onboarding/hooks/useReducedMotion.ts +19 -0
  88. package/src/domains/onboarding/index.ts +153 -0
  89. package/src/domains/onboarding/predicates/has-handler.ts +21 -0
  90. package/src/domains/onboarding/predicates/icon-name-resolves.ts +97 -0
  91. package/src/domains/onboarding/predicates/index.ts +26 -0
  92. package/src/domains/onboarding/predicates/permission-granted.ts +21 -0
  93. package/src/domains/onboarding/predicates/permission-supports.ts +56 -0
  94. package/src/domains/onboarding/predicates/predicates.test.ts +188 -0
  95. package/src/domains/onboarding/predicates/screen-emits-data.ts +13 -0
  96. package/src/domains/onboarding/predicates/storage-available.ts +14 -0
  97. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +92 -0
  98. package/src/domains/onboarding/templates/art-onboarding.ts +71 -0
  99. package/src/domains/onboarding/templates/health-onboarding.ts +92 -0
  100. package/src/domains/onboarding/templates/index.ts +6 -0
  101. package/src/domains/onboarding/templates/platform-onboarding.ts +201 -0
  102. package/src/domains/onboarding/templates/video-onboarding.ts +95 -0
  103. package/src/domains/onboarding/test/setup.ts +23 -0
  104. package/src/domains/onboarding/types/config.ts +57 -0
  105. package/src/domains/onboarding/types/index.ts +10 -0
  106. package/src/domains/onboarding/types/labels.ts +49 -0
  107. package/src/domains/onboarding/types/screens.ts +185 -0
  108. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  109. package/src/domains/onboarding/utils/analytics-dispatch.ts +73 -0
  110. package/src/domains/onboarding/utils/default-storage-key.ts +1 -0
  111. package/src/domains/onboarding/utils/index.ts +12 -0
  112. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  113. package/src/domains/onboarding/utils/local-storage-bridge.ts +84 -0
  114. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  115. package/src/domains/onboarding/utils/permission-bridge.ts +87 -0
  116. package/src/domains/onboarding/validation/flow-structure-check.ts +22 -0
  117. package/src/domains/onboarding/validation/index.ts +9 -0
  118. package/src/domains/onboarding/validation/permission-status-check.ts +18 -0
  119. package/src/domains/onboarding/validation/screen-config-check.ts +100 -0
  120. package/src/domains/onboarding/validation/stored-progress-check.ts +59 -0
  121. package/src/domains/onboarding/validation/validation.test.ts +170 -0
  122. package/src/domains/permissions/hasPermission.ts +0 -8
  123. package/src/domains/permissions/types.ts +0 -20
  124. package/src/domains/settings/components/index.ts +0 -4
  125. package/src/domains/settings/hooks/index.ts +0 -4
  126. package/src/domains/settings/hooks/useSettings.ts +1 -26
  127. package/src/domains/settings/index.ts +0 -6
  128. package/src/domains/settings/types/index.ts +0 -4
  129. package/src/domains/settings/types/settings.ts +23 -67
  130. package/src/domains/settings/utils/index.ts +0 -4
  131. package/src/domains/settings/utils/settings.ts +0 -45
  132. package/src/styles/dashboard.contract.test.ts +1 -0
  133. package/src/styles/dashboard.css +818 -1
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { dispatchAnalyticsEvent, trackOnboardingEvent } from './analytics-dispatch';
3
+ import type { OnboardingAnalyticsEvent } from '../types/config';
4
+
5
+ const event: OnboardingAnalyticsEvent = {
6
+ name: 'onboarding_started',
7
+ category: 'health',
8
+ timestamp: 0,
9
+ };
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ delete (window as { gtag?: unknown }).gtag;
14
+ delete (window as { amplitude?: unknown }).amplitude;
15
+ delete (window as { mixpanel?: unknown }).mixpanel;
16
+ });
17
+
18
+ describe('analytics-dispatch — the "never throws" contract', () => {
19
+ it('routes to the custom tracker when provided', () => {
20
+ const tracker = vi.fn();
21
+ dispatchAnalyticsEvent(event, 'custom', tracker);
22
+ expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
23
+ });
24
+
25
+ it('warns and drops (instead of throwing) for custom without a tracker', () => {
26
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
27
+ expect(() => dispatchAnalyticsEvent(event, 'custom', undefined)).not.toThrow();
28
+ expect(warn).toHaveBeenCalledTimes(1);
29
+ expect(warn.mock.calls[0][0]).toContain('customTracker');
30
+ });
31
+
32
+ it('never throws when the custom tracker itself throws', () => {
33
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
34
+ const tracker = () => {
35
+ throw new Error('tracker outage');
36
+ };
37
+ expect(() => dispatchAnalyticsEvent(event, 'custom', tracker)).not.toThrow();
38
+ expect(error).toHaveBeenCalledTimes(1);
39
+ expect(error.mock.calls[0][0]).toContain('dropped');
40
+ });
41
+
42
+ it('is a no-op without a provider', () => {
43
+ const tracker = vi.fn();
44
+ expect(() => dispatchAnalyticsEvent(event, undefined, tracker)).not.toThrow();
45
+ expect(tracker).not.toHaveBeenCalled();
46
+ });
47
+ });
48
+
49
+ describe('analytics-dispatch — provider routing', () => {
50
+ it('dispatches to gtag for google-analytics', () => {
51
+ const gtag = vi.fn();
52
+ (window as { gtag?: unknown }).gtag = gtag;
53
+ dispatchAnalyticsEvent(event, 'google-analytics', undefined);
54
+ expect(gtag).toHaveBeenCalledExactlyOnceWith('event', 'onboarding_started', event);
55
+ });
56
+
57
+ it('dispatches to amplitude via getInstance().logEvent', () => {
58
+ const logEvent = vi.fn();
59
+ (window as { amplitude?: unknown }).amplitude = {
60
+ getInstance: () => ({ logEvent }),
61
+ };
62
+ dispatchAnalyticsEvent(event, 'amplitude', undefined);
63
+ expect(logEvent).toHaveBeenCalledExactlyOnceWith('onboarding_started', event);
64
+ });
65
+
66
+ it('dispatches to mixpanel.track', () => {
67
+ const track = vi.fn();
68
+ (window as { mixpanel?: unknown }).mixpanel = { track };
69
+ dispatchAnalyticsEvent(event, 'mixpanel', undefined);
70
+ expect(track).toHaveBeenCalledExactlyOnceWith('onboarding_started', event);
71
+ });
72
+
73
+ it('survives a provider global that throws', () => {
74
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
75
+ (window as { gtag?: unknown }).gtag = () => {
76
+ throw new Error('gtag exploded');
77
+ };
78
+ expect(() => dispatchAnalyticsEvent(event, 'google-analytics', undefined)).not.toThrow();
79
+ expect(error).toHaveBeenCalledTimes(1);
80
+ });
81
+ });
82
+
83
+ describe('trackOnboardingEvent', () => {
84
+ it('forwards provider + tracker options', () => {
85
+ const tracker = vi.fn();
86
+ trackOnboardingEvent(event, { provider: 'custom', customTracker: tracker });
87
+ expect(tracker).toHaveBeenCalledExactlyOnceWith(event);
88
+ });
89
+
90
+ it('defaults to a silent no-op', () => {
91
+ expect(() => trackOnboardingEvent(event)).not.toThrow();
92
+ });
93
+ });
@@ -0,0 +1,73 @@
1
+ import type {
2
+ AnalyticsProvider,
3
+ OnboardingAnalyticsEvent,
4
+ } from '../types/config';
5
+
6
+ type AnalyticsWindow = Window & {
7
+ gtag?: (...args: unknown[]) => void;
8
+ amplitude?: {
9
+ getInstance: () => { logEvent: (name: string, props: Record<string, unknown>) => void };
10
+ };
11
+ mixpanel?: { track: (name: string, props: Record<string, unknown>) => void };
12
+ };
13
+
14
+ function dispatchToProvider(event: OnboardingAnalyticsEvent, provider: AnalyticsProvider): void {
15
+ if (typeof window === 'undefined') return; const win = window as AnalyticsWindow;
16
+ switch (provider) {
17
+ case 'google-analytics':
18
+ win.gtag?.('event', event.name, event as unknown as Record<string, unknown>);
19
+ return;
20
+ case 'amplitude': {
21
+ const amp = win.amplitude?.getInstance();
22
+ amp?.logEvent(event.name, event as unknown as Record<string, unknown>);
23
+ return;
24
+ }
25
+ case 'mixpanel':
26
+ win.mixpanel?.track(event.name, event as unknown as Record<string, unknown>);
27
+ return;
28
+ case 'custom':
29
+ return; }
30
+ }
31
+
32
+ function dispatchToCustomTracker(
33
+ event: OnboardingAnalyticsEvent,
34
+ customTracker: ((e: OnboardingAnalyticsEvent) => void) | undefined,
35
+ ): void {
36
+ if (typeof customTracker !== 'function') {
37
+
38
+ console.warn(
39
+ '[web-onboarding] analytics provider is "custom" but no customTracker was provided; event dropped:',
40
+ event.name,
41
+ );
42
+ return;
43
+ }
44
+ customTracker(event);
45
+ }
46
+
47
+ export function dispatchAnalyticsEvent(
48
+ event: OnboardingAnalyticsEvent,
49
+ provider: AnalyticsProvider | undefined,
50
+ customTracker: ((e: OnboardingAnalyticsEvent) => void) | undefined,
51
+ ): void {
52
+ try {
53
+ if (provider === 'custom') {
54
+ dispatchToCustomTracker(event, customTracker);
55
+ return;
56
+ }
57
+ if (!provider) return;
58
+ dispatchToProvider(event, provider);
59
+ } catch (error) {
60
+
61
+ console.error('[web-onboarding] analytics dispatch failed; event dropped:', event.name, error);
62
+ }
63
+ }
64
+
65
+ export function trackOnboardingEvent(
66
+ event: OnboardingAnalyticsEvent,
67
+ options: {
68
+ provider?: AnalyticsProvider;
69
+ customTracker?: (e: OnboardingAnalyticsEvent) => void;
70
+ } = {},
71
+ ): void {
72
+ dispatchAnalyticsEvent(event, options.provider, options.customTracker);
73
+ }
@@ -0,0 +1 @@
1
+ export const defaultStorageKey = '@umituz/web-onboarding:progress';
@@ -0,0 +1,12 @@
1
+ export { trackOnboardingEvent, dispatchAnalyticsEvent } from './analytics-dispatch';
2
+ export {
3
+ saveOnboardingProgress,
4
+ loadOnboardingProgress,
5
+ clearOnboardingProgress,
6
+ buildOnboardingResult,
7
+ } from './local-storage-bridge';
8
+ export type { RehydratedProgress } from './local-storage-bridge';
9
+ export { queryPermissionStatus, requestPermission } from './permission-bridge';
10
+ export { defaultStorageKey } from './default-storage-key';
11
+
12
+ export { checkFlowStructure, checkScreenConfig, checkStoredProgress } from '../validation';
@@ -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,84 @@
1
+ import { elapsedMillis } from '../calc/elapsed-millis';
2
+ import { isLocalStorageAvailable } from '../predicates/storage-available';
3
+ import { checkStoredProgress } from '../validation/stored-progress-check';
4
+ import { defaultStorageKey } from './default-storage-key';
5
+ import type { OnboardingResult } from '../types/config';
6
+
7
+ export interface RehydratedProgress {
8
+ currentStep: number;
9
+ completedSteps: string[];
10
+ collected: Record<string, unknown>;
11
+ startedAt: Date;
12
+ lastUpdated: Date;
13
+ }
14
+
15
+ function buildKey(customKey: string | undefined): string {
16
+ return customKey ?? defaultStorageKey;
17
+ }
18
+
19
+ export function saveOnboardingProgress(
20
+ key: string | undefined,
21
+ payload: {
22
+ currentStep: number;
23
+ completedSteps: ReadonlyArray<string>;
24
+ collected: Record<string, unknown>;
25
+ startedAt: Date;
26
+ lastUpdated: Date;
27
+ },
28
+ ): boolean {
29
+ if (!isLocalStorageAvailable()) return false;
30
+ try {
31
+ const serialized = JSON.stringify({
32
+ version: 1,
33
+ currentStep: payload.currentStep,
34
+ completedSteps: [...payload.completedSteps],
35
+ collected: { ...payload.collected },
36
+ startedAt: payload.startedAt.toISOString(),
37
+ lastUpdated: payload.lastUpdated.toISOString(),
38
+ });
39
+ window.localStorage.setItem(buildKey(key), serialized);
40
+ return true;
41
+ } catch (error) {
42
+
43
+ console.error('[web-onboarding] failed to persist progress', error);
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function loadOnboardingProgress(key: string | undefined): RehydratedProgress | null {
49
+ if (!isLocalStorageAvailable()) return null;
50
+ const raw = window.localStorage.getItem(buildKey(key));
51
+ const checked = checkStoredProgress(raw);
52
+ if (!checked.ok) {
53
+ if (raw !== null) {
54
+ window.localStorage.removeItem(buildKey(key));
55
+ }
56
+ return null;
57
+ }
58
+ return {
59
+ currentStep: checked.data.currentStep,
60
+ completedSteps: checked.data.completedSteps,
61
+ collected: checked.data.collected,
62
+ startedAt: new Date(checked.data.startedAt),
63
+ lastUpdated: new Date(checked.data.lastUpdated),
64
+ };
65
+ }
66
+
67
+ export function clearOnboardingProgress(key: string | undefined): void {
68
+ if (!isLocalStorageAvailable()) return;
69
+ window.localStorage.removeItem(buildKey(key));
70
+ }
71
+
72
+ export function buildOnboardingResult(input: {
73
+ completedSteps: ReadonlyArray<string>;
74
+ collected: Record<string, unknown>;
75
+ startedAt: Date;
76
+ finishedAt: Date;
77
+ }): OnboardingResult {
78
+ return {
79
+ completedSteps: input.completedSteps,
80
+ collected: input.collected,
81
+ durationMs: elapsedMillis(input.startedAt, input.finishedAt),
82
+ finishedAt: input.finishedAt,
83
+ };
84
+ }
@@ -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,87 @@
1
+ import {
2
+ doesBrowserSupportMediaCapture,
3
+ doesBrowserSupportGeolocation,
4
+ doesBrowserSupportNotifications,
5
+ } from '../predicates/permission-supports';
6
+ import { normalizePermissionStatus } from '../validation/permission-status-check';
7
+ import type { PermissionStatus, PermissionType } from '../types/screens';
8
+
9
+ const GEOLOCATION_TIMEOUT_MS = 5000;
10
+
11
+ export async function queryPermissionStatus(permission: PermissionType): Promise<PermissionStatus> {
12
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') return 'unsupported';
13
+
14
+ try {
15
+ switch (permission) {
16
+ case 'notifications': {
17
+ if (!doesBrowserSupportNotifications()) return 'unsupported';
18
+ return normalizePermissionStatus(window.Notification.permission);
19
+ }
20
+ case 'camera':
21
+ case 'microphone': {
22
+ if (!doesBrowserSupportMediaCapture()) return 'unsupported';
23
+ if (!navigator.permissions?.query) return 'unsupported';
24
+ const status = await navigator.permissions.query({
25
+ name: permission === 'camera' ? 'camera' : 'microphone',
26
+ });
27
+ return normalizePermissionStatus(status.state);
28
+ }
29
+ case 'location': {
30
+ if (!doesBrowserSupportGeolocation()) return 'unsupported';
31
+ if (!navigator.permissions?.query) return 'unsupported';
32
+ const status = await navigator.permissions.query({ name: 'geolocation' });
33
+ return normalizePermissionStatus(status.state);
34
+ }
35
+ case 'photos':
36
+ return 'prompt';
37
+ case 'contacts':
38
+ return 'unsupported';
39
+ default:
40
+ return 'unsupported';
41
+ }
42
+ } catch {
43
+ return 'unsupported';
44
+ }
45
+ }
46
+
47
+ export async function requestPermission(permission: PermissionType): Promise<PermissionStatus> {
48
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') return 'unsupported';
49
+
50
+ try {
51
+ switch (permission) {
52
+ case 'notifications': {
53
+ if (!doesBrowserSupportNotifications()) return 'unsupported';
54
+ const result = await window.Notification.requestPermission();
55
+ return normalizePermissionStatus(result);
56
+ }
57
+ case 'camera':
58
+ case 'microphone': {
59
+ if (!doesBrowserSupportMediaCapture()) return 'unsupported';
60
+ const constraints: MediaStreamConstraints =
61
+ permission === 'camera' ? { video: true } : { audio: true };
62
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
63
+ stream.getTracks().forEach((track) => track.stop());
64
+ return 'granted';
65
+ }
66
+ case 'location': {
67
+ if (!doesBrowserSupportGeolocation()) return 'unsupported';
68
+ return new Promise<PermissionStatus>((resolve) => {
69
+ navigator.geolocation.getCurrentPosition(
70
+ () => resolve('granted'),
71
+ (err) =>
72
+ resolve(err.code === err.PERMISSION_DENIED ? 'denied' : 'unsupported'),
73
+ { timeout: GEOLOCATION_TIMEOUT_MS },
74
+ );
75
+ });
76
+ }
77
+ case 'photos':
78
+ return 'prompt';
79
+ case 'contacts':
80
+ return 'unsupported';
81
+ default:
82
+ return 'unsupported';
83
+ }
84
+ } catch {
85
+ return 'denied';
86
+ }
87
+ }
@@ -0,0 +1,22 @@
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
+ export function checkFlowStructure(screens: ReadonlyArray<OnboardingScreen>): ValidationOutcome {
9
+ if (!Array.isArray(screens) || screens.length < MIN_FLOW_LENGTH) {
10
+ return { ok: false, reason: 'flow must contain at least one screen' };
11
+ }
12
+ const seen = new Set<string>();
13
+ for (const screen of screens) {
14
+ const result = checkScreenConfig(screen);
15
+ if (!result.ok) return result;
16
+ if (seen.has(screen.id)) {
17
+ return { ok: false, reason: `duplicate step id: "${screen.id}"` };
18
+ }
19
+ seen.add(screen.id);
20
+ }
21
+ return { ok: true };
22
+ }
@@ -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,18 @@
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
+ export function isRecognizedPermissionStatus(value: unknown): value is PermissionStatus {
11
+ return typeof value === 'string' && RECOGNIZED_STATUSES.has(value as PermissionStatus);
12
+ }
13
+
14
+ export function normalizePermissionStatus(rawValue: string | null | undefined): PermissionStatus {
15
+ if (rawValue === 'granted' || rawValue === 'denied') return rawValue;
16
+ if (rawValue === 'default' || rawValue === 'prompt') return 'prompt';
17
+ return 'unsupported';
18
+ }
@@ -0,0 +1,100 @@
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
+ export function checkScreenConfig(screen: OnboardingScreen): ValidationOutcome {
70
+ if (!isPlainObject(screen)) {
71
+ return { ok: false, reason: 'screen must be an object' };
72
+ }
73
+ const id = (screen as { id?: unknown }).id;
74
+ if (typeof id !== 'string' || id.length === 0) {
75
+ return { ok: false, reason: 'screen.id must be a non-empty string' };
76
+ }
77
+ const type = (screen as { type?: unknown }).type;
78
+ if (typeof type !== 'string' || !RECOGNIZED_SCREEN_TYPES.has(type as OnboardingScreenType)) {
79
+ return { ok: false, reason: `screen.type "${String(type)}" is not a recognized screen type` };
80
+ }
81
+
82
+ switch (type) {
83
+ case 'goal-question':
84
+ return checkGoalQuestion(screen);
85
+ case 'pain-points':
86
+ return checkPainPoints(screen);
87
+ case 'tinder-cards':
88
+ return checkTinderCards(screen);
89
+ case 'permission-priming':
90
+ return checkPermissionPriming(screen);
91
+ case 'paywall':
92
+ return checkPaywall(screen);
93
+ default:
94
+ return { ok: true };
95
+ }
96
+ }
97
+
98
+ export function getRecognizedScreenTypes(): ReadonlySet<OnboardingScreenType> {
99
+ return RECOGNIZED_SCREEN_TYPES;
100
+ }