@umituz/web-dashboard 6.1.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -73
- package/package.json +5 -9
- package/src/config/DashboardConfig.ts +19 -68
- package/src/domains/analytics/components/index.ts +0 -6
- package/src/domains/analytics/hooks/index.ts +0 -6
- package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
- package/src/domains/analytics/index.ts +0 -10
- package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
- package/src/domains/analytics/services/PerformanceService.ts +27 -138
- package/src/domains/analytics/services/index.ts +0 -4
- package/src/domains/analytics/types/analytics.ts +100 -270
- package/src/domains/analytics/types/index.ts +0 -6
- package/src/domains/analytics/utils/analytics.test.ts +2 -9
- package/src/domains/analytics/utils/analytics.ts +1 -22
- package/src/domains/analytics/utils/index.ts +0 -6
- package/src/domains/calendar/config.ts +0 -7
- package/src/domains/calendar/hooks/index.ts +0 -4
- package/src/domains/calendar/hooks/useCalendar.ts +7 -27
- package/src/domains/calendar/index.ts +0 -4
- package/src/domains/calendar/services/CalendarService.ts +0 -21
- package/src/domains/calendar/services/index.ts +0 -4
- package/src/domains/calendar/types/calendar.types.ts +9 -53
- package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
- package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
- package/src/domains/calendar/utils/index.ts +0 -26
- package/src/domains/layouts/components/index.ts +0 -4
- package/src/domains/layouts/hooks/dashboard.ts +0 -21
- package/src/domains/layouts/hooks/index.ts +0 -4
- package/src/domains/layouts/index.ts +0 -6
- package/src/domains/layouts/theme/default.ts +0 -12
- package/src/domains/layouts/theme/index.ts +0 -4
- package/src/domains/layouts/theme/presets.ts +0 -9
- package/src/domains/layouts/theme/utils.ts +0 -15
- package/src/domains/layouts/types/index.ts +0 -4
- package/src/domains/layouts/types/layout.ts +18 -56
- package/src/domains/layouts/types/notification.ts +4 -17
- package/src/domains/layouts/types/sidebar.ts +8 -28
- package/src/domains/layouts/types/theme.ts +23 -59
- package/src/domains/layouts/types/user.ts +9 -30
- package/src/domains/layouts/utils/dashboard.ts +4 -48
- package/src/domains/layouts/utils/index.ts +0 -4
- package/src/domains/modules/build.tsx +2 -16
- package/src/domains/modules/types.ts +8 -23
- package/src/domains/onboarding/calc/calc.test.ts +1 -9
- package/src/domains/onboarding/calc/card-stack-motion-curve.ts +0 -6
- package/src/domains/onboarding/calc/count-with-locale.ts +0 -8
- package/src/domains/onboarding/calc/duration-equal-split.ts +0 -12
- package/src/domains/onboarding/calc/elapsed-millis.ts +0 -6
- package/src/domains/onboarding/calc/progress-percentage.ts +1 -11
- package/src/domains/onboarding/calc/slider-value-bounds.ts +1 -11
- package/src/domains/onboarding/calc/step-index-clamp.ts +0 -9
- package/src/domains/onboarding/calc/swipe-gesture-curve.ts +0 -13
- package/src/domains/onboarding/components/{shared/AnimationWrapper.tsx → AnimationWrapper.tsx} +0 -4
- package/src/domains/onboarding/components/{shared/OnboardingErrorBoundary.tsx → OnboardingErrorBoundary.tsx} +7 -27
- package/src/domains/onboarding/components/OnboardingFlow.test.tsx +8 -30
- package/src/domains/onboarding/components/OnboardingFlow.tsx +13 -61
- package/src/domains/onboarding/components/OnboardingIntl.tsx +1 -11
- package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
- package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
- package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
- package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
- package/src/domains/onboarding/components/{shared/SwipeCard.test.tsx → SwipeCard.test.tsx} +1 -3
- package/src/domains/onboarding/components/{shared/SwipeCard.tsx → SwipeCard.tsx} +10 -28
- package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
- package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +7 -17
- package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +4 -10
- package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +12 -22
- package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +9 -6
- package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +10 -10
- package/src/domains/onboarding/components/screens/PaywallScreen.tsx +22 -38
- package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +14 -29
- package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +10 -23
- package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +13 -27
- package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +11 -27
- package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +22 -51
- package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +13 -30
- package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +13 -29
- package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +12 -32
- package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingAnalytics.test.tsx +1 -1
- package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingAnalytics.ts +3 -16
- package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingProgress.test.tsx +2 -2
- package/src/domains/onboarding/{components/hooks → hooks}/useOnboardingProgress.ts +10 -21
- package/src/domains/onboarding/{components/hooks → hooks}/usePermissionPriming.ts +3 -10
- package/src/domains/onboarding/{components/hooks → hooks}/useReducedMotion.ts +0 -5
- package/src/domains/onboarding/index.ts +16 -45
- package/src/domains/onboarding/predicates/has-handler.ts +0 -4
- package/src/domains/onboarding/predicates/icon-name-resolves.ts +1 -17
- package/src/domains/onboarding/predicates/permission-granted.ts +0 -5
- package/src/domains/onboarding/predicates/permission-supports.ts +1 -11
- package/src/domains/onboarding/predicates/predicates.test.ts +6 -20
- package/src/domains/onboarding/predicates/screen-emits-data.ts +0 -4
- package/src/domains/onboarding/predicates/storage-available.ts +0 -8
- package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +19 -39
- package/src/domains/onboarding/templates/art-onboarding.ts +1 -11
- package/src/domains/onboarding/templates/health-onboarding.ts +0 -5
- package/src/domains/onboarding/templates/platform-onboarding.ts +0 -9
- package/src/domains/onboarding/templates/video-onboarding.ts +0 -5
- package/src/domains/onboarding/test/setup.ts +1 -8
- package/src/domains/onboarding/types/config.ts +7 -18
- package/src/domains/onboarding/types/labels.ts +25 -59
- package/src/domains/onboarding/types/screens.ts +3 -11
- package/src/domains/onboarding/utils/analytics-dispatch.ts +2 -21
- package/src/domains/onboarding/utils/default-storage-key.ts +0 -1
- package/src/domains/onboarding/utils/index.ts +0 -1
- package/src/domains/onboarding/utils/local-storage-bridge.ts +1 -21
- package/src/domains/onboarding/utils/permission-bridge.ts +0 -8
- package/src/domains/onboarding/validation/flow-structure-check.ts +0 -6
- package/src/domains/onboarding/validation/permission-status-check.ts +0 -5
- package/src/domains/onboarding/validation/screen-config-check.ts +0 -5
- package/src/domains/onboarding/validation/stored-progress-check.ts +1 -10
- package/src/domains/onboarding/validation/validation.test.ts +1 -6
- package/src/domains/permissions/hasPermission.ts +0 -8
- package/src/domains/permissions/types.ts +0 -20
- package/src/domains/settings/components/index.ts +0 -4
- package/src/domains/settings/hooks/index.ts +0 -4
- package/src/domains/settings/hooks/useSettings.ts +1 -26
- package/src/domains/settings/index.ts +0 -6
- package/src/domains/settings/types/index.ts +0 -4
- package/src/domains/settings/types/settings.ts +23 -67
- package/src/domains/settings/utils/index.ts +0 -4
- package/src/domains/settings/utils/settings.ts +0 -45
- package/src/styles/dashboard.css +818 -1
- package/src/domains/onboarding/components/shared/OptionCard.tsx +0 -73
- package/src/domains/onboarding/components/shared/PrimaryButton.tsx +0 -58
- package/src/domains/onboarding/components/shared/ProgressBar.tsx +0 -51
- package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +0 -36
- package/src/domains/onboarding/components/shared/ScreenContainer.tsx +0 -29
- package/src/domains/onboarding/components/shared/index.ts +0 -16
- package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +0 -20
- package/src/domains/onboarding/onboarding.css +0 -1
- package/src/domains/onboarding/theme/cn.ts +0 -10
- package/src/domains/onboarding/theme/index.css +0 -131
- package/src/domains/onboarding/theme/index.ts +0 -12
- package/src/domains/onboarding/theme/tokens.ts +0 -122
- /package/src/domains/onboarding/{components/hooks → hooks}/index.ts +0 -0
|
@@ -47,14 +47,12 @@ const minimalConfig = (overrides: Partial<OnboardingConfig> = {}): OnboardingCon
|
|
|
47
47
|
|
|
48
48
|
type FlowProps = Omit<Partial<React.ComponentProps<typeof OnboardingFlow>>, 'config'>;
|
|
49
49
|
|
|
50
|
-
/** Ephemeral render — keeps tests isolated from localStorage unless persistence is under test. */
|
|
51
50
|
function renderFlow(config: OnboardingConfig, props: FlowProps = {}) {
|
|
52
51
|
return render(
|
|
53
52
|
<OnboardingFlow config={{ ...config, storageKey: 'test:flow' }} ephemeral {...props} />,
|
|
54
53
|
);
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
/** Advance past the account gate. `onSignIn` is async — wait for the next screen. */
|
|
58
56
|
async function passAccountGate(nextTitle: string) {
|
|
59
57
|
fireEvent.click(screen.getByRole('button', { name: 'Continue with email' }));
|
|
60
58
|
await waitFor(() => expect(screen.getByText(nextTitle)).toBeTruthy());
|
|
@@ -62,16 +60,13 @@ async function passAccountGate(nextTitle: string) {
|
|
|
62
60
|
|
|
63
61
|
beforeEach(() => {
|
|
64
62
|
window.localStorage.clear();
|
|
65
|
-
|
|
66
|
-
// is asserted behaviorally, not via the spy.
|
|
67
|
-
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
63
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
68
64
|
});
|
|
69
65
|
|
|
70
66
|
afterEach(() => {
|
|
71
67
|
vi.restoreAllMocks();
|
|
72
68
|
});
|
|
73
69
|
|
|
74
|
-
// TEST_SCENARIOS.md §1.1–§1.3
|
|
75
70
|
describe('OnboardingFlow — navigation', () => {
|
|
76
71
|
it('renders the first screen with the progress label "Step 1 of 3"', () => {
|
|
77
72
|
renderFlow(minimalConfig());
|
|
@@ -116,7 +111,6 @@ describe('OnboardingFlow — navigation', () => {
|
|
|
116
111
|
});
|
|
117
112
|
});
|
|
118
113
|
|
|
119
|
-
// TEST_SCENARIOS.md §17
|
|
120
114
|
describe('OnboardingFlow — analytics funnel', () => {
|
|
121
115
|
it('emits started, per-step completed, and completed — each exactly once', async () => {
|
|
122
116
|
const tracker = vi.fn();
|
|
@@ -145,8 +139,7 @@ describe('OnboardingFlow — analytics funnel', () => {
|
|
|
145
139
|
const tracker = vi.fn();
|
|
146
140
|
const analytics = { trackEvents: true, provider: 'custom', customTracker: tracker } as const;
|
|
147
141
|
const first = renderFlow(minimalConfig({ analytics }));
|
|
148
|
-
first.unmount();
|
|
149
|
-
expect(tracker.mock.calls.map((c) => c[0].name)).not.toContain('onboarding_abandoned');
|
|
142
|
+
first.unmount(); expect(tracker.mock.calls.map((c) => c[0].name)).not.toContain('onboarding_abandoned');
|
|
150
143
|
|
|
151
144
|
const second = renderFlow(minimalConfig({ analytics }));
|
|
152
145
|
fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
|
|
@@ -167,7 +160,6 @@ describe('OnboardingFlow — analytics funnel', () => {
|
|
|
167
160
|
});
|
|
168
161
|
});
|
|
169
162
|
|
|
170
|
-
// TEST_SCENARIOS.md §1.11, §1.7, §1.8
|
|
171
163
|
describe('OnboardingFlow — validation & error boundary', () => {
|
|
172
164
|
it('fails fast on duplicate step ids (§1.11)', () => {
|
|
173
165
|
const bad = minimalConfig({ screens: [welcome, { ...welcome }] });
|
|
@@ -213,7 +205,6 @@ describe('OnboardingFlow — validation & error boundary', () => {
|
|
|
213
205
|
});
|
|
214
206
|
});
|
|
215
207
|
|
|
216
|
-
// TEST_SCENARIOS.md §1.4, §1.5
|
|
217
208
|
describe('OnboardingFlow — persistence', () => {
|
|
218
209
|
it('restores progress across remounts (non-ephemeral)', () => {
|
|
219
210
|
const config = { ...minimalConfig(), storageKey: 'persist:1' };
|
|
@@ -259,8 +250,6 @@ describe('OnboardingFlow — labels & locale', () => {
|
|
|
259
250
|
});
|
|
260
251
|
});
|
|
261
252
|
|
|
262
|
-
// TEST_SCENARIOS.md §14, §15 — with the REVISED paywall error contract:
|
|
263
|
-
// a failed purchase attempt must NOT advance the user.
|
|
264
253
|
describe('screens — account gate & paywall contracts', () => {
|
|
265
254
|
it('account gate: no skip affordance when required (§14.1)', () => {
|
|
266
255
|
renderFlow(minimalConfig());
|
|
@@ -296,8 +285,7 @@ describe('screens — account gate & paywall contracts', () => {
|
|
|
296
285
|
await waitFor(() =>
|
|
297
286
|
expect(screen.getByRole('alert').textContent).toContain('provider down'),
|
|
298
287
|
);
|
|
299
|
-
expect(screen.getByText('Create your account')).toBeTruthy();
|
|
300
|
-
});
|
|
288
|
+
expect(screen.getByText('Create your account')).toBeTruthy(); });
|
|
301
289
|
|
|
302
290
|
it('paywall: accept advances and fires paywall_accepted (§15.1)', async () => {
|
|
303
291
|
const tracker = vi.fn();
|
|
@@ -356,8 +344,7 @@ describe('screens — account gate & paywall contracts', () => {
|
|
|
356
344
|
await waitFor(() =>
|
|
357
345
|
expect(screen.getByRole('alert').textContent).toContain('charge failed'),
|
|
358
346
|
);
|
|
359
|
-
|
|
360
|
-
expect(screen.getByText('Go Premium')).toBeTruthy();
|
|
347
|
+
expect(screen.getByText('Go Premium')).toBeTruthy();
|
|
361
348
|
expect(onComplete).not.toHaveBeenCalled();
|
|
362
349
|
});
|
|
363
350
|
|
|
@@ -378,7 +365,6 @@ describe('screens — account gate & paywall contracts', () => {
|
|
|
378
365
|
});
|
|
379
366
|
});
|
|
380
367
|
|
|
381
|
-
// TEST_SCENARIOS.md §10
|
|
382
368
|
describe('permission priming screen — funnel events', () => {
|
|
383
369
|
const priming: OnboardingScreen = {
|
|
384
370
|
id: 'perm',
|
|
@@ -423,12 +409,10 @@ describe('permission priming screen — funnel events', () => {
|
|
|
423
409
|
await waitFor(() =>
|
|
424
410
|
expect(screen.getByRole('alert').textContent).toContain('user cancelled'),
|
|
425
411
|
);
|
|
426
|
-
expect(screen.getByText('Stay on track')).toBeTruthy();
|
|
427
|
-
expect(tracker.mock.calls.map((c) => c[0].name)).toContain('permission_denied');
|
|
412
|
+
expect(screen.getByText('Stay on track')).toBeTruthy(); expect(tracker.mock.calls.map((c) => c[0].name)).toContain('permission_denied');
|
|
428
413
|
});
|
|
429
414
|
});
|
|
430
415
|
|
|
431
|
-
// TEST_SCENARIOS.md §11
|
|
432
416
|
describe('processing screen — auto-advance', () => {
|
|
433
417
|
it('completes each step on its timer, then advances the flow (§11.1)', async () => {
|
|
434
418
|
vi.useFakeTimers();
|
|
@@ -438,30 +422,24 @@ describe('processing screen — auto-advance', () => {
|
|
|
438
422
|
type: 'processing',
|
|
439
423
|
title: 'Building your plan',
|
|
440
424
|
steps: ['A', 'B', 'C'],
|
|
441
|
-
durationMs: 300,
|
|
442
|
-
onComplete: () => undefined,
|
|
425
|
+
durationMs: 300, onComplete: () => undefined,
|
|
443
426
|
};
|
|
444
427
|
renderFlow(minimalConfig({ screens: [welcome, processing, accountGate] }));
|
|
445
428
|
fireEvent.click(screen.getByRole('button', { name: 'Get started' }));
|
|
446
429
|
expect(screen.getByText('Building your plan')).toBeTruthy();
|
|
447
430
|
|
|
448
|
-
|
|
449
|
-
// timeout inside an effect, which only runs once act flushes the update.
|
|
450
|
-
for (let i = 0; i < 3; i++) {
|
|
431
|
+
for (let i = 0; i < 3; i++) {
|
|
451
432
|
await act(async () => {
|
|
452
433
|
vi.advanceTimersByTime(100);
|
|
453
434
|
});
|
|
454
435
|
}
|
|
455
|
-
|
|
456
|
-
expect(screen.getByText('Create your account')).toBeTruthy();
|
|
436
|
+
expect(screen.getByText('Create your account')).toBeTruthy();
|
|
457
437
|
} finally {
|
|
458
438
|
vi.useRealTimers();
|
|
459
439
|
}
|
|
460
440
|
});
|
|
461
441
|
});
|
|
462
442
|
|
|
463
|
-
// TEST_SCENARIOS.md §19 — template regression: every template must mount
|
|
464
|
-
// without an analytics crash (the old `provider: 'custom'` with no tracker).
|
|
465
443
|
describe('templates — mount regression (§19)', () => {
|
|
466
444
|
const cases: Array<[string, OnboardingConfig]> = [
|
|
467
445
|
['health', healthOnboardingTemplate],
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
2
|
import type { OnboardingScreen } from '../types/screens';
|
|
3
3
|
import type { OnboardingConfig } from '../types/config';
|
|
4
|
-
import { useOnboardingProgress } from '
|
|
5
|
-
import { useOnboardingAnalytics } from '
|
|
4
|
+
import { useOnboardingProgress } from '../hooks/useOnboardingProgress';
|
|
5
|
+
import { useOnboardingAnalytics } from '../hooks/useOnboardingAnalytics';
|
|
6
6
|
import { OnboardingIntlProvider } from './OnboardingIntl';
|
|
7
|
-
import { ProgressBar } from './
|
|
8
|
-
import { ScreenContainer } from './
|
|
9
|
-
import { AnimationWrapper, type ScreenTransition } from './
|
|
10
|
-
import { OnboardingErrorBoundary } from './
|
|
7
|
+
import { ProgressBar } from './ProgressBar';
|
|
8
|
+
import { ScreenContainer } from './ScreenContainer';
|
|
9
|
+
import { AnimationWrapper, type ScreenTransition } from './AnimationWrapper';
|
|
10
|
+
import { OnboardingErrorBoundary } from './OnboardingErrorBoundary';
|
|
11
11
|
import { useOnboardingIntl } from './OnboardingIntl';
|
|
12
12
|
import { checkFlowStructure } from '../validation/flow-structure-check';
|
|
13
13
|
import {
|
|
@@ -29,26 +29,15 @@ import {
|
|
|
29
29
|
|
|
30
30
|
export interface OnboardingFlowProps {
|
|
31
31
|
config: OnboardingConfig;
|
|
32
|
-
/** Test/Storybook hook: skip persistence. */
|
|
33
32
|
ephemeral?: boolean;
|
|
34
|
-
/** Show progress bar — defaults to true. */
|
|
35
33
|
showProgress?: boolean;
|
|
36
|
-
/** Show back button on non-first screens — defaults to true. */
|
|
37
34
|
allowBack?: boolean;
|
|
38
|
-
/** Show skip button — defaults to true. Calls config.onSkip on click. */
|
|
39
35
|
allowSkip?: boolean;
|
|
40
|
-
/** Custom label for the skip button (overrides `labels.skip`). */
|
|
41
36
|
skipLabel?: string;
|
|
42
|
-
/** Transition style between screens — defaults to 'slide'. */
|
|
43
37
|
transition?: ScreenTransition;
|
|
44
|
-
/** Override the default error UI from inside the boundary. */
|
|
45
38
|
renderError?: (error: Error, retry: () => void) => React.ReactNode;
|
|
46
39
|
}
|
|
47
40
|
|
|
48
|
-
/**
|
|
49
|
-
* Factory + dispatch table: maps a screen type to its component.
|
|
50
|
-
* Each screen is responsible for emitting its own data shape via onNext.
|
|
51
|
-
*/
|
|
52
41
|
type ProgressReturn = ReturnType<typeof useOnboardingProgress>;
|
|
53
42
|
|
|
54
43
|
function renderScreen(
|
|
@@ -85,9 +74,6 @@ function renderScreen(
|
|
|
85
74
|
case 'tinder-cards':
|
|
86
75
|
return <TinderCardsScreen config={screen} onNext={(data) => onStepCompleted(data)} />;
|
|
87
76
|
case 'personalized-solution': {
|
|
88
|
-
// Deterministic: personalization is driven by the pain-points screen's
|
|
89
|
-
// collected selections — never by "whichever screen happens to have a
|
|
90
|
-
// `selected` field".
|
|
91
77
|
const painScreen = config.screens.find((s) => s.type === 'pain-points');
|
|
92
78
|
const painIds = painScreen
|
|
93
79
|
? ((progress.collected[painScreen.id] as { selected?: string[] } | undefined)
|
|
@@ -129,14 +115,12 @@ function renderScreen(
|
|
|
129
115
|
/>
|
|
130
116
|
);
|
|
131
117
|
default: {
|
|
132
|
-
// Exhaustive check — TypeScript will flag missing cases at compile time.
|
|
133
118
|
const _exhaustive: never = screen;
|
|
134
119
|
return _exhaustive;
|
|
135
120
|
}
|
|
136
121
|
}
|
|
137
122
|
}
|
|
138
123
|
|
|
139
|
-
/** Back/skip affordances. Rendered inside the intl provider so label overrides apply. */
|
|
140
124
|
function FlowFooterNavigation({
|
|
141
125
|
allowBack,
|
|
142
126
|
allowSkip,
|
|
@@ -157,24 +141,16 @@ function FlowFooterNavigation({
|
|
|
157
141
|
const { labels } = useOnboardingIntl();
|
|
158
142
|
if (!visible) return null;
|
|
159
143
|
return (
|
|
160
|
-
<div className="
|
|
144
|
+
<div className="udb-onb-nav">
|
|
161
145
|
{allowBack && !isFirst ? (
|
|
162
|
-
<button
|
|
163
|
-
type="button"
|
|
164
|
-
onClick={onBack}
|
|
165
|
-
className="text-sm font-semibold text-[rgb(var(--uob-text-muted))] underline-offset-4 hover:underline"
|
|
166
|
-
>
|
|
146
|
+
<button type="button" onClick={onBack} className="udb-onb-link">
|
|
167
147
|
{labels.back}
|
|
168
148
|
</button>
|
|
169
149
|
) : (
|
|
170
150
|
<span />
|
|
171
151
|
)}
|
|
172
152
|
{allowSkip ? (
|
|
173
|
-
<button
|
|
174
|
-
type="button"
|
|
175
|
-
onClick={onSkip}
|
|
176
|
-
className="text-sm font-semibold text-[rgb(var(--uob-text-muted))] underline-offset-4 hover:underline"
|
|
177
|
-
>
|
|
153
|
+
<button type="button" onClick={onSkip} className="udb-onb-link">
|
|
178
154
|
{skipLabel ?? labels.skip}
|
|
179
155
|
</button>
|
|
180
156
|
) : (
|
|
@@ -184,22 +160,6 @@ function FlowFooterNavigation({
|
|
|
184
160
|
);
|
|
185
161
|
}
|
|
186
162
|
|
|
187
|
-
/**
|
|
188
|
-
* OnboardingFlow — the orchestrator.
|
|
189
|
-
* Responsibilities (SRP):
|
|
190
|
-
* 1. Validate the configured flow at mount (fail fast on bad config).
|
|
191
|
-
* 2. Own the progress state machine via useOnboardingProgress.
|
|
192
|
-
* 3. Dispatch the active screen via the factory above.
|
|
193
|
-
* 4. Emit lifecycle analytics (started / step / completed / abandoned — each exactly once).
|
|
194
|
-
* 5. Provide skip/back affordances and progress.
|
|
195
|
-
* 6. Wrap everything in an Error Boundary so a single broken screen never breaks the flow.
|
|
196
|
-
*
|
|
197
|
-
* Does NOT own:
|
|
198
|
-
* - Styling tokens (theme/cn)
|
|
199
|
-
* - Analytics provider internals (useOnboardingAnalytics)
|
|
200
|
-
* - Storage (useOnboardingProgress)
|
|
201
|
-
* - Screen-specific UI (rendered components)
|
|
202
|
-
*/
|
|
203
163
|
export function OnboardingFlow({
|
|
204
164
|
config,
|
|
205
165
|
ephemeral = false,
|
|
@@ -217,16 +177,13 @@ export function OnboardingFlow({
|
|
|
217
177
|
});
|
|
218
178
|
const analytics = useOnboardingAnalytics(config.analytics);
|
|
219
179
|
|
|
220
|
-
// Validate configuration once at mount. Surface errors rather than render broken UI.
|
|
221
180
|
const validation = useMemo(() => checkFlowStructure(config.screens), [config.screens]);
|
|
222
181
|
useEffect(() => {
|
|
223
182
|
if (!validation.ok) {
|
|
224
|
-
throw new Error(`[
|
|
183
|
+
throw new Error(`[onboarding] invalid flow: ${validation.reason}`);
|
|
225
184
|
}
|
|
226
185
|
}, [validation]);
|
|
227
186
|
|
|
228
|
-
// Lifecycle analytics — started fires exactly once, even if the consumer
|
|
229
|
-
// recreates the config object on every render.
|
|
230
187
|
const startedRef = useRef(false);
|
|
231
188
|
useEffect(() => {
|
|
232
189
|
if (startedRef.current) return;
|
|
@@ -238,7 +195,6 @@ export function OnboardingFlow({
|
|
|
238
195
|
});
|
|
239
196
|
}, [analytics, config.category]);
|
|
240
197
|
|
|
241
|
-
// Completion — fires exactly once per finished run.
|
|
242
198
|
const completedRef = useRef(false);
|
|
243
199
|
useEffect(() => {
|
|
244
200
|
if (!progress.isFinished || !progress.result) return;
|
|
@@ -252,10 +208,8 @@ export function OnboardingFlow({
|
|
|
252
208
|
config.onComplete?.(progress.result);
|
|
253
209
|
}, [progress.isFinished, progress.result, analytics, config]);
|
|
254
210
|
|
|
255
|
-
// Abandonment
|
|
256
|
-
//
|
|
257
|
-
// React StrictMode dev double-mounts happen before engagement, so they do not
|
|
258
|
-
// produce spurious events.
|
|
211
|
+
// Abandonment: unmount without finishing AND after engaging at least one step.
|
|
212
|
+
// StrictMode dev double-mounts happen before engagement, so no spurious events.
|
|
259
213
|
const abandonSnapshotRef = useRef({ engaged: false, finished: false, currentStep: 0, startedAt: new Date() });
|
|
260
214
|
useEffect(() => {
|
|
261
215
|
abandonSnapshotRef.current = {
|
|
@@ -276,8 +230,6 @@ export function OnboardingFlow({
|
|
|
276
230
|
timestamp: Date.now(),
|
|
277
231
|
});
|
|
278
232
|
};
|
|
279
|
-
// Config/analytics are read via closure on unmount; the effect intentionally
|
|
280
|
-
// installs once for the lifetime of the flow.
|
|
281
233
|
}, []);
|
|
282
234
|
|
|
283
235
|
const handleStepCompleted = useCallback(
|
|
@@ -321,7 +273,7 @@ export function OnboardingFlow({
|
|
|
321
273
|
return (
|
|
322
274
|
<OnboardingIntlProvider labels={config.labels} locale={config.locale}>
|
|
323
275
|
<OnboardingErrorBoundary {...(renderError ? { renderError } : {})}>
|
|
324
|
-
<div className="
|
|
276
|
+
<div className="udb-onb">
|
|
325
277
|
<ScreenContainer
|
|
326
278
|
header={
|
|
327
279
|
<>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
2
2
|
import type { OnboardingLabels } from '../types/labels';
|
|
3
3
|
|
|
4
|
-
/** English source strings. Override any subset via `OnboardingConfig.labels`. */
|
|
5
4
|
export const defaultOnboardingLabels: Readonly<OnboardingLabels> = {
|
|
6
5
|
continueAction: 'Continue',
|
|
7
6
|
back: 'Back',
|
|
@@ -52,7 +51,6 @@ export const defaultOnboardingLabels: Readonly<OnboardingLabels> = {
|
|
|
52
51
|
tryAgain: 'Try again',
|
|
53
52
|
};
|
|
54
53
|
|
|
55
|
-
/** Replace `{placeholder}` tokens in a pattern label. */
|
|
56
54
|
export function formatLabel(
|
|
57
55
|
pattern: string,
|
|
58
56
|
vars: Record<string, string | number>,
|
|
@@ -62,14 +60,11 @@ export function formatLabel(
|
|
|
62
60
|
);
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
/** Labels + locale bundled for React context. */
|
|
66
63
|
export interface OnboardingIntl {
|
|
67
64
|
labels: Readonly<OnboardingLabels>;
|
|
68
|
-
|
|
69
|
-
locale: string | undefined;
|
|
65
|
+
locale: string | undefined;
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
/** Exported for class-based consumers (error boundaries) that cannot call hooks. */
|
|
73
68
|
export const OnboardingIntlContext = createContext<OnboardingIntl>({
|
|
74
69
|
labels: defaultOnboardingLabels,
|
|
75
70
|
locale: undefined,
|
|
@@ -81,10 +76,6 @@ export interface OnboardingIntlProviderProps {
|
|
|
81
76
|
children: ReactNode;
|
|
82
77
|
}
|
|
83
78
|
|
|
84
|
-
/**
|
|
85
|
-
* Provides label/locale overrides to every screen in the flow. Screens used
|
|
86
|
-
* standalone (outside `OnboardingFlow`) fall back to the English defaults.
|
|
87
|
-
*/
|
|
88
79
|
export function OnboardingIntlProvider({
|
|
89
80
|
labels,
|
|
90
81
|
locale,
|
|
@@ -102,7 +93,6 @@ export function OnboardingIntlProvider({
|
|
|
102
93
|
);
|
|
103
94
|
}
|
|
104
95
|
|
|
105
|
-
/** Access the active labels and locale. English defaults when no provider. */
|
|
106
96
|
export function useOnboardingIntl(): OnboardingIntl {
|
|
107
97
|
return useContext(OnboardingIntlContext);
|
|
108
98
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Button, Spinner, type ButtonProps } from '@umituz/web-design-system/atoms';
|
|
3
|
+
import { useOnboardingIntl } from './OnboardingIntl';
|
|
4
|
+
|
|
5
|
+
export interface PrimaryButtonProps extends ButtonProps {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
loading?: boolean;
|
|
8
|
+
fullWidth?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function PrimaryButton({
|
|
12
|
+
children,
|
|
13
|
+
loading = false,
|
|
14
|
+
fullWidth = true,
|
|
15
|
+
disabled,
|
|
16
|
+
type,
|
|
17
|
+
block,
|
|
18
|
+
...rest
|
|
19
|
+
}: PrimaryButtonProps) {
|
|
20
|
+
const { labels } = useOnboardingIntl();
|
|
21
|
+
return (
|
|
22
|
+
<Button
|
|
23
|
+
{...rest}
|
|
24
|
+
type={type ?? 'button'}
|
|
25
|
+
variant="primary"
|
|
26
|
+
size="lg"
|
|
27
|
+
block={block ?? fullWidth}
|
|
28
|
+
disabled={disabled || loading}
|
|
29
|
+
>
|
|
30
|
+
{loading ? (
|
|
31
|
+
<>
|
|
32
|
+
<Spinner size="sm" />
|
|
33
|
+
<span>{labels.loading}</span>
|
|
34
|
+
</>
|
|
35
|
+
) : (
|
|
36
|
+
children
|
|
37
|
+
)}
|
|
38
|
+
</Button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Progress } from '@umituz/web-design-system/atoms';
|
|
2
|
+
import { cn } from '@umituz/web-design-system/utils/cn';
|
|
3
|
+
import { computeProgressPercentage } from '../calc/progress-percentage';
|
|
4
|
+
import { formatLabel, useOnboardingIntl } from './OnboardingIntl';
|
|
5
|
+
|
|
6
|
+
export interface ProgressBarProps {
|
|
7
|
+
current: number;
|
|
8
|
+
total: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
showLabel?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ProgressBar({ current, total, className, showLabel = false }: ProgressBarProps) {
|
|
14
|
+
const { labels } = useOnboardingIntl();
|
|
15
|
+
const safeTotal = Math.max(1, total);
|
|
16
|
+
const safeCurrent = Math.max(0, Math.min(current, safeTotal));
|
|
17
|
+
const percentage = Math.round(computeProgressPercentage(safeCurrent, safeTotal));
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className={cn('udb-onb-progress', className)}>
|
|
21
|
+
{showLabel && (
|
|
22
|
+
<div className="udb-onb-progress__meta">
|
|
23
|
+
<span aria-live="polite">
|
|
24
|
+
{formatLabel(labels.stepOfPattern, { current: safeCurrent, total: safeTotal })}
|
|
25
|
+
</span>
|
|
26
|
+
<span aria-hidden="true">{percentage}%</span>
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
<Progress value={percentage} label="Onboarding progress" />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { cn } from '@umituz/web-design-system/utils/cn';
|
|
4
|
+
|
|
5
|
+
export interface QuestionWrapperProps {
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function QuestionWrapper({ title, subtitle, children, className }: QuestionWrapperProps) {
|
|
13
|
+
return (
|
|
14
|
+
<motion.div
|
|
15
|
+
initial={{ opacity: 0, y: 8 }}
|
|
16
|
+
animate={{ opacity: 1, y: 0 }}
|
|
17
|
+
exit={{ opacity: 0, y: -8 }}
|
|
18
|
+
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
19
|
+
className={cn('udb-onb-question', className)}
|
|
20
|
+
>
|
|
21
|
+
<header className="udb-onb-question__head">
|
|
22
|
+
<h2 className="udb-onb-title">{title}</h2>
|
|
23
|
+
{subtitle && <p className="udb-onb-lead">{subtitle}</p>}
|
|
24
|
+
</header>
|
|
25
|
+
<div className="udb-onb-question__body">{children}</div>
|
|
26
|
+
</motion.div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from '@umituz/web-design-system/utils/cn';
|
|
3
|
+
|
|
4
|
+
export interface ScreenContainerProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
header?: ReactNode;
|
|
7
|
+
footer?: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ScreenContainer({ children, header, footer, className }: ScreenContainerProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className={cn('udb-onb-screen', className)}>
|
|
14
|
+
{header && <div className="udb-onb-screen__header">{header}</div>}
|
|
15
|
+
<main className="udb-onb-screen__main">{children}</main>
|
|
16
|
+
{footer && <div className="udb-onb-screen__footer">{footer}</div>}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
3
|
import { SwipeCard } from './SwipeCard';
|
|
4
|
-
import { OnboardingIntlProvider } from '
|
|
4
|
+
import { OnboardingIntlProvider } from './OnboardingIntl';
|
|
5
5
|
|
|
6
6
|
function renderCard(props: Partial<React.ComponentProps<typeof SwipeCard>> = {}) {
|
|
7
7
|
return render(
|
|
@@ -17,8 +17,6 @@ function renderCard(props: Partial<React.ComponentProps<typeof SwipeCard>> = {})
|
|
|
17
17
|
);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// TEST_SCENARIOS.md §6.4 — the gesture math itself is covered in calc tests;
|
|
21
|
-
// here we verify the keyboard contract and label rendering.
|
|
22
20
|
describe('SwipeCard', () => {
|
|
23
21
|
it('triggers onSwipeRight on ArrowRight and onSwipeLeft on ArrowLeft', () => {
|
|
24
22
|
const onSwipeLeft = vi.fn();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { motion, useMotionValue, useTransform, type PanInfo } from 'framer-motion';
|
|
3
|
-
import { cn } from '../../theme/cn';
|
|
4
3
|
import {
|
|
5
4
|
didCrossLeftSwipeThreshold,
|
|
6
5
|
didCrossRightSwipeThreshold,
|
|
@@ -10,8 +9,8 @@ import {
|
|
|
10
9
|
SWIPE_RIGHT_OPACITY_VALUES,
|
|
11
10
|
SWIPE_ROTATION_RANGE,
|
|
12
11
|
SWIPE_ROTATION_VALUES,
|
|
13
|
-
} from '
|
|
14
|
-
import { useOnboardingIntl } from '
|
|
12
|
+
} from '../calc/swipe-gesture-curve';
|
|
13
|
+
import { useOnboardingIntl } from './OnboardingIntl';
|
|
15
14
|
|
|
16
15
|
export interface SwipeCardProps {
|
|
17
16
|
text: string;
|
|
@@ -21,13 +20,7 @@ export interface SwipeCardProps {
|
|
|
21
20
|
className?: string;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
onSwipeLeft: () => void;
|
|
26
|
-
onSwipeRight: () => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function useSwipeGestures({ onSwipeLeft, onSwipeRight }: GestureOptions) {
|
|
30
|
-
// Refs avoid stale closures — the gesture handlers always see the latest callbacks.
|
|
23
|
+
function useSwipeGestures({ onSwipeLeft, onSwipeRight }: { onSwipeLeft: () => void; onSwipeRight: () => void }) {
|
|
31
24
|
const leftRef = useRef(onSwipeLeft);
|
|
32
25
|
const rightRef = useRef(onSwipeRight);
|
|
33
26
|
useEffect(() => {
|
|
@@ -43,13 +36,6 @@ function useSwipeGestures({ onSwipeLeft, onSwipeRight }: GestureOptions) {
|
|
|
43
36
|
};
|
|
44
37
|
}
|
|
45
38
|
|
|
46
|
-
/**
|
|
47
|
-
* Tinder-style swipeable card.
|
|
48
|
-
* Single responsibility: gesture handling and visual feedback for one statement.
|
|
49
|
-
* Keyboard-accessible: ← / → keys trigger the same actions as swipe gestures.
|
|
50
|
-
*
|
|
51
|
-
* All numeric thresholds and curve points live in `calc/swipe-gesture-curve`.
|
|
52
|
-
*/
|
|
53
39
|
export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className }: SwipeCardProps) {
|
|
54
40
|
const { labels } = useOnboardingIntl();
|
|
55
41
|
const x = useMotionValue(0);
|
|
@@ -60,7 +46,7 @@ export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className
|
|
|
60
46
|
|
|
61
47
|
return (
|
|
62
48
|
<div
|
|
63
|
-
className={
|
|
49
|
+
className={className ? `udb-onb-swipe ${className}` : 'udb-onb-swipe'}
|
|
64
50
|
role="group"
|
|
65
51
|
aria-label={`${text} — ${labels.swipeHint}`}
|
|
66
52
|
tabIndex={0}
|
|
@@ -79,25 +65,21 @@ export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className
|
|
|
79
65
|
drag="x"
|
|
80
66
|
dragConstraints={{ left: 0, right: 0 }}
|
|
81
67
|
onDragEnd={onDragEnd}
|
|
82
|
-
className="
|
|
68
|
+
className="udb-onb-swipe__card"
|
|
83
69
|
>
|
|
84
|
-
{category &&
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
</span>
|
|
88
|
-
)}
|
|
89
|
-
<p className="text-xl font-medium text-[rgb(var(--uob-text-primary))]">{text}</p>
|
|
90
|
-
<p className="mt-6 text-sm text-[rgb(var(--uob-text-muted))]">{labels.swipeHint}</p>
|
|
70
|
+
{category && <span className="udb-onb-swipe__category">{category}</span>}
|
|
71
|
+
<p className="udb-onb-swipe__text">{text}</p>
|
|
72
|
+
<p className="udb-onb-swipe__hint">{labels.swipeHint}</p>
|
|
91
73
|
</motion.div>
|
|
92
74
|
<motion.div
|
|
93
75
|
style={{ opacity: leftOpacity }}
|
|
94
|
-
className="
|
|
76
|
+
className="udb-onb-swipe__stamp udb-onb-swipe__stamp--no"
|
|
95
77
|
>
|
|
96
78
|
{labels.disagree}
|
|
97
79
|
</motion.div>
|
|
98
80
|
<motion.div
|
|
99
81
|
style={{ opacity: rightOpacity }}
|
|
100
|
-
className="
|
|
82
|
+
className="udb-onb-swipe__stamp udb-onb-swipe__stamp--yes"
|
|
101
83
|
>
|
|
102
84
|
{labels.agree}
|
|
103
85
|
</motion.div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { resolveIconByName } from '../predicates/icon-name-resolves';
|
|
2
|
+
|
|
3
|
+
export function renderBundledIcon(name: string | undefined, className?: string) {
|
|
4
|
+
const Icon = resolveIconByName(name);
|
|
5
|
+
if (!Icon) return null;
|
|
6
|
+
return <Icon className={className} aria-hidden="true" />;
|
|
7
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { PrimaryButton } from '../
|
|
2
|
+
import { PrimaryButton } from '../PrimaryButton';
|
|
3
3
|
import {
|
|
4
4
|
accountGateAllowsSkip,
|
|
5
5
|
hasOnSignIn,
|
|
@@ -31,9 +31,6 @@ export function AccountGateScreen({ config, onNext }: AccountGateScreenProps) {
|
|
|
31
31
|
const [busy, setBusy] = useState(false);
|
|
32
32
|
const [error, setError] = useState<string | null>(null);
|
|
33
33
|
|
|
34
|
-
// Step lifecycle analytics (`onboarding_step_completed`) are owned by the
|
|
35
|
-
// orchestrator — the screen stays presentation-only.
|
|
36
|
-
|
|
37
34
|
async function handleSignIn(method: SignInMethod) {
|
|
38
35
|
setBusy(true);
|
|
39
36
|
setError(null);
|
|
@@ -49,22 +46,15 @@ export function AccountGateScreen({ config, onNext }: AccountGateScreenProps) {
|
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
return (
|
|
52
|
-
<div className="
|
|
53
|
-
{config.title &&
|
|
54
|
-
|
|
55
|
-
)}
|
|
56
|
-
{config.subtitle && (
|
|
57
|
-
<p className="text-base text-[rgb(var(--uob-text-muted))]">{config.subtitle}</p>
|
|
58
|
-
)}
|
|
49
|
+
<div className="udb-onb-stage">
|
|
50
|
+
{config.title && <h2 className="udb-onb-title udb-onb-title--display">{config.title}</h2>}
|
|
51
|
+
{config.subtitle && <p className="udb-onb-lead">{config.subtitle}</p>}
|
|
59
52
|
{error && (
|
|
60
|
-
<p
|
|
61
|
-
role="alert"
|
|
62
|
-
className="w-full rounded-md border border-[rgb(var(--uob-error))] bg-[rgb(var(--uob-error))]/10 p-3 text-sm text-[rgb(var(--uob-error))]"
|
|
63
|
-
>
|
|
53
|
+
<p role="alert" className="udb-onb-error">
|
|
64
54
|
{error}
|
|
65
55
|
</p>
|
|
66
56
|
)}
|
|
67
|
-
<div className="
|
|
57
|
+
<div className="udb-onb-question__body">
|
|
68
58
|
{config.signInMethod && hasOnSignIn(config) && (
|
|
69
59
|
<PrimaryButton
|
|
70
60
|
onClick={() => handleSignIn(config.signInMethod as SignInMethod)}
|
|
@@ -78,7 +68,7 @@ export function AccountGateScreen({ config, onNext }: AccountGateScreenProps) {
|
|
|
78
68
|
type="button"
|
|
79
69
|
disabled={busy}
|
|
80
70
|
onClick={() => onNext({ signedIn: false })}
|
|
81
|
-
className="
|
|
71
|
+
className="udb-onb-link"
|
|
82
72
|
>
|
|
83
73
|
{config.skipText ?? labels.skipForNow}
|
|
84
74
|
</button>
|