@umituz/web-dashboard 5.0.1 → 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.
Files changed (120) hide show
  1. package/package.json +19 -17
  2. package/src/domains/onboarding/calc/calc.test.ts +181 -0
  3. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +33 -0
  4. package/src/domains/onboarding/calc/count-with-locale.ts +16 -0
  5. package/src/domains/onboarding/calc/duration-equal-split.ts +24 -0
  6. package/src/domains/onboarding/calc/elapsed-millis.ts +12 -0
  7. package/src/domains/onboarding/calc/index.ts +36 -0
  8. package/src/domains/onboarding/calc/progress-percentage.ts +37 -0
  9. package/src/domains/onboarding/calc/slider-value-bounds.ts +44 -0
  10. package/src/domains/onboarding/calc/step-index-clamp.ts +28 -0
  11. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +67 -0
  12. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +481 -0
  13. package/src/domains/onboarding/components/OnboardingFlow.tsx +361 -0
  14. package/src/domains/onboarding/components/OnboardingIntl.tsx +108 -0
  15. package/src/domains/onboarding/components/hooks/index.ts +7 -0
  16. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.test.tsx +74 -0
  17. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.ts +62 -0
  18. package/src/domains/onboarding/components/hooks/useOnboardingProgress.test.tsx +150 -0
  19. package/src/domains/onboarding/components/hooks/useOnboardingProgress.ts +260 -0
  20. package/src/domains/onboarding/components/hooks/usePermissionPriming.ts +52 -0
  21. package/src/domains/onboarding/components/hooks/useReducedMotion.ts +24 -0
  22. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +89 -0
  23. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +39 -0
  24. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +59 -0
  25. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +56 -0
  26. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  27. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +138 -0
  28. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +113 -0
  29. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +53 -0
  30. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +194 -0
  31. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +79 -0
  32. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +116 -0
  33. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +108 -0
  34. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +85 -0
  35. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +52 -0
  36. package/src/domains/onboarding/components/screens/index.ts +28 -0
  37. package/src/domains/onboarding/components/shared/AnimationWrapper.tsx +73 -0
  38. package/src/domains/onboarding/components/shared/OnboardingErrorBoundary.tsx +81 -0
  39. package/src/domains/onboarding/components/shared/OptionCard.tsx +73 -0
  40. package/src/domains/onboarding/components/shared/PrimaryButton.tsx +58 -0
  41. package/src/domains/onboarding/components/shared/ProgressBar.tsx +51 -0
  42. package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +36 -0
  43. package/src/domains/onboarding/components/shared/ScreenContainer.tsx +29 -0
  44. package/src/domains/onboarding/components/shared/SwipeCard.test.tsx +51 -0
  45. package/src/domains/onboarding/components/shared/SwipeCard.tsx +106 -0
  46. package/src/domains/onboarding/components/shared/index.ts +16 -0
  47. package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +20 -0
  48. package/src/domains/onboarding/index.ts +182 -0
  49. package/src/domains/onboarding/onboarding.css +1 -0
  50. package/src/domains/onboarding/predicates/has-handler.ts +25 -0
  51. package/src/domains/onboarding/predicates/icon-name-resolves.ts +113 -0
  52. package/src/domains/onboarding/predicates/index.ts +26 -0
  53. package/src/domains/onboarding/predicates/permission-granted.ts +26 -0
  54. package/src/domains/onboarding/predicates/permission-supports.ts +66 -0
  55. package/src/domains/onboarding/predicates/predicates.test.ts +202 -0
  56. package/src/domains/onboarding/predicates/screen-emits-data.ts +17 -0
  57. package/src/domains/onboarding/predicates/storage-available.ts +22 -0
  58. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +112 -0
  59. package/src/domains/onboarding/templates/art-onboarding.ts +81 -0
  60. package/src/domains/onboarding/templates/health-onboarding.ts +97 -0
  61. package/src/domains/onboarding/templates/index.ts +6 -0
  62. package/src/domains/onboarding/templates/platform-onboarding.ts +210 -0
  63. package/src/domains/onboarding/templates/video-onboarding.ts +100 -0
  64. package/src/domains/onboarding/test/setup.ts +30 -0
  65. package/src/domains/onboarding/theme/cn.ts +10 -0
  66. package/src/domains/onboarding/theme/index.css +131 -0
  67. package/src/domains/onboarding/theme/index.ts +12 -0
  68. package/src/domains/onboarding/theme/tokens.ts +122 -0
  69. package/src/domains/onboarding/types/config.ts +68 -0
  70. package/src/domains/onboarding/types/index.ts +10 -0
  71. package/src/domains/onboarding/types/labels.ts +83 -0
  72. package/src/domains/onboarding/types/screens.ts +193 -0
  73. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  74. package/src/domains/onboarding/utils/analytics-dispatch.ts +92 -0
  75. package/src/domains/onboarding/utils/default-storage-key.ts +2 -0
  76. package/src/domains/onboarding/utils/index.ts +13 -0
  77. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  78. package/src/domains/onboarding/utils/local-storage-bridge.ts +104 -0
  79. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  80. package/src/domains/onboarding/utils/permission-bridge.ts +95 -0
  81. package/src/domains/onboarding/validation/flow-structure-check.ts +28 -0
  82. package/src/domains/onboarding/validation/index.ts +9 -0
  83. package/src/domains/onboarding/validation/permission-status-check.ts +23 -0
  84. package/src/domains/onboarding/validation/screen-config-check.ts +105 -0
  85. package/src/domains/onboarding/validation/stored-progress-check.ts +68 -0
  86. package/src/domains/onboarding/validation/validation.test.ts +175 -0
  87. package/src/index.ts +0 -10
  88. package/src/styles/dashboard.contract.test.ts +1 -0
  89. package/src/styles/dashboard.css +0 -460
  90. package/src/domains/auth/components/AuthLayout.tsx +0 -99
  91. package/src/domains/auth/components/ForgotPasswordForm.tsx +0 -167
  92. package/src/domains/auth/components/LoginForm.tsx +0 -222
  93. package/src/domains/auth/components/RegisterForm.tsx +0 -281
  94. package/src/domains/auth/components/ResetPasswordForm.tsx +0 -173
  95. package/src/domains/auth/components/index.ts +0 -11
  96. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +0 -25
  97. package/src/domains/auth/components/shared/EmailInput.tsx +0 -73
  98. package/src/domains/auth/components/shared/PasswordInput.tsx +0 -100
  99. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +0 -63
  100. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +0 -44
  101. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +0 -48
  102. package/src/domains/auth/components/shared/index.ts +0 -20
  103. package/src/domains/auth/hooks/index.ts +0 -8
  104. package/src/domains/auth/hooks/useAuth.ts +0 -230
  105. package/src/domains/auth/index.ts +0 -62
  106. package/src/domains/auth/types/auth.ts +0 -302
  107. package/src/domains/auth/types/index.ts +0 -25
  108. package/src/domains/auth/utils/auth.test.ts +0 -188
  109. package/src/domains/auth/utils/auth.ts +0 -221
  110. package/src/domains/auth/utils/i18nKeys.ts +0 -109
  111. package/src/domains/auth/utils/index.ts +0 -29
  112. package/src/domains/auth/utils/secureToken.ts +0 -33
  113. package/src/domains/auth/utils/validationKeys.ts +0 -23
  114. package/src/domains/session/DemoLogin.tsx +0 -42
  115. package/src/domains/session/OrgSwitcher.tsx +0 -102
  116. package/src/domains/session/SessionContext.tsx +0 -150
  117. package/src/domains/session/inMemoryAuthAdapter.ts +0 -53
  118. package/src/domains/session/index.ts +0 -18
  119. package/src/domains/session/types.ts +0 -47
  120. package/src/domains/session/utils/i18nKeys.ts +0 -12
@@ -0,0 +1,73 @@
1
+ import { motion } from 'framer-motion';
2
+ import { cn } from '../../theme/cn';
3
+ import { renderBundledIcon } from './renderBundledIcon';
4
+ import { doesIconNameResolve } from '../../predicates/icon-name-resolves';
5
+ import type { OnboardingOption } from '../../types/screens';
6
+
7
+ export interface OptionCardProps {
8
+ option: OnboardingOption;
9
+ selected: boolean;
10
+ onToggle: (id: string) => void;
11
+ multiSelect?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Selectable option card with optional icon.
17
+ * Single responsibility: render one option and emit toggle events.
18
+ */
19
+ export function OptionCard({ option, selected, onToggle, multiSelect = false, className }: OptionCardProps) {
20
+ return (
21
+ <motion.button
22
+ type="button"
23
+ role={multiSelect ? 'checkbox' : 'radio'}
24
+ aria-checked={selected}
25
+ onClick={() => onToggle(option.id)}
26
+ whileTap={{ scale: 0.98 }}
27
+ className={cn(
28
+ 'flex w-full items-center gap-3 rounded-xl border p-4 text-left transition-colors',
29
+ 'focus:outline-none focus:ring-2 focus:ring-[rgb(var(--uob-primary-500))] focus:ring-offset-2',
30
+ selected
31
+ ? 'border-[rgb(var(--uob-primary-500))] bg-[rgb(var(--uob-primary-50))]'
32
+ : 'border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] hover:border-[rgb(var(--uob-primary-100))]',
33
+ className,
34
+ )}
35
+ >
36
+ {doesIconNameResolve(option.iconName) && (
37
+ <span
38
+ className={cn(
39
+ 'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
40
+ selected
41
+ ? 'bg-[rgb(var(--uob-primary-500))] text-white'
42
+ : 'bg-[rgb(var(--uob-surface-muted))] text-[rgb(var(--uob-text-muted))]',
43
+ )}
44
+ >
45
+ {renderBundledIcon(option.iconName, 'h-5 w-5')}
46
+ </span>
47
+ )}
48
+ <span className="flex flex-1 flex-col">
49
+ <span className="text-base font-medium text-[rgb(var(--uob-text-primary))]">
50
+ {option.label}
51
+ </span>
52
+ {option.description && (
53
+ <span className="text-sm text-[rgb(var(--uob-text-muted))]">{option.description}</span>
54
+ )}
55
+ </span>
56
+ <span
57
+ className={cn(
58
+ 'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
59
+ selected
60
+ ? 'border-[rgb(var(--uob-primary-500))] bg-[rgb(var(--uob-primary-500))] text-white'
61
+ : 'border-[rgb(var(--uob-border))]',
62
+ )}
63
+ aria-hidden="true"
64
+ >
65
+ {selected && (
66
+ <svg viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2">
67
+ <path d="M2 6 L5 9 L10 3" />
68
+ </svg>
69
+ )}
70
+ </span>
71
+ </motion.button>
72
+ );
73
+ }
@@ -0,0 +1,58 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
+ import { motion, type HTMLMotionProps } from 'framer-motion';
3
+ import { cn } from '../../theme/cn';
4
+ import { useOnboardingIntl } from '../OnboardingIntl';
5
+
6
+ export interface PrimaryButtonProps
7
+ extends Omit<HTMLMotionProps<'button'>, 'children'> {
8
+ children: ReactNode;
9
+ loading?: boolean;
10
+ fullWidth?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Primary CTA button used across the onboarding flow.
15
+ * Single responsibility: render a tappable, accessible, animated button.
16
+ */
17
+ export function PrimaryButton({
18
+ children,
19
+ loading = false,
20
+ fullWidth = true,
21
+ className,
22
+ disabled,
23
+ type,
24
+ ...rest
25
+ }: PrimaryButtonProps) {
26
+ const { labels } = useOnboardingIntl();
27
+ return (
28
+ <motion.button
29
+ {...rest}
30
+ type={type ?? 'button'}
31
+ disabled={disabled || loading}
32
+ whileTap={{ scale: disabled || loading ? 1 : 0.98 }}
33
+ className={cn(
34
+ 'inline-flex items-center justify-center rounded-xl px-5 py-3 text-base font-semibold',
35
+ 'bg-[rgb(var(--uob-primary-500))] text-white shadow-sm transition-colors',
36
+ 'hover:bg-[rgb(var(--uob-primary-600))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--uob-primary-500))] focus:ring-offset-2',
37
+ 'disabled:cursor-not-allowed disabled:opacity-60',
38
+ fullWidth ? 'w-full' : '',
39
+ className,
40
+ )}
41
+ >
42
+ {loading ? (
43
+ <span className="flex items-center gap-2">
44
+ <span
45
+ className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
46
+ aria-hidden="true"
47
+ />
48
+ <span>{labels.loading}</span>
49
+ </span>
50
+ ) : (
51
+ children
52
+ )}
53
+ </motion.button>
54
+ );
55
+ }
56
+
57
+ // Re-export the HTML button type for consumers that need to wrap a non-motion button.
58
+ export type { ButtonHTMLAttributes };
@@ -0,0 +1,51 @@
1
+ import * as Progress from '@radix-ui/react-progress';
2
+ import { motion } from 'framer-motion';
3
+ import { cn } from '../../theme/cn';
4
+ import { computeProgressPercentage } from '../../calc/progress-percentage';
5
+ import { formatLabel, useOnboardingIntl } from '../OnboardingIntl';
6
+
7
+ export interface ProgressBarProps {
8
+ current: number;
9
+ total: number;
10
+ className?: string;
11
+ showLabel?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Accessible progress bar built on Radix UI primitive.
16
+ * Progress math lives in `calc/progress-percentage`.
17
+ */
18
+ export function ProgressBar({ current, total, className, showLabel = false }: ProgressBarProps) {
19
+ const { labels } = useOnboardingIntl();
20
+ const safeTotal = Math.max(1, total);
21
+ const safeCurrent = Math.max(0, Math.min(current, safeTotal));
22
+ const percentage = computeProgressPercentage(safeCurrent, safeTotal);
23
+
24
+ return (
25
+ <div className={cn('flex w-full flex-col gap-2', className)}>
26
+ {showLabel && (
27
+ <div className="flex items-center justify-between text-sm text-[rgb(var(--uob-text-muted))]">
28
+ <span aria-live="polite">
29
+ {formatLabel(labels.stepOfPattern, { current: safeCurrent, total: safeTotal })}
30
+ </span>
31
+ <span aria-hidden="true">{Math.round(percentage)}%</span>
32
+ </div>
33
+ )}
34
+ <Progress.Root
35
+ value={percentage}
36
+ aria-label={`Onboarding progress: ${Math.round(percentage)} percent`}
37
+ aria-valuemin={0}
38
+ aria-valuemax={100}
39
+ aria-valuenow={Math.round(percentage)}
40
+ className="relative h-2 w-full overflow-hidden rounded-full bg-[rgb(var(--uob-border))]"
41
+ >
42
+ <motion.div
43
+ initial={{ width: 0 }}
44
+ animate={{ width: `${percentage}%` }}
45
+ transition={{ duration: 0.4, ease: 'easeOut' }}
46
+ className="h-full rounded-full bg-[rgb(var(--uob-primary-500))]"
47
+ />
48
+ </Progress.Root>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { cn } from '../../theme/cn';
4
+
5
+ export interface QuestionWrapperProps {
6
+ title: string;
7
+ subtitle?: string;
8
+ children: ReactNode;
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * Consistent framing for question-style screens.
14
+ * Single responsibility: layout the question header + content area.
15
+ */
16
+ export function QuestionWrapper({ title, subtitle, children, className }: QuestionWrapperProps) {
17
+ return (
18
+ <motion.div
19
+ initial={{ opacity: 0, y: 8 }}
20
+ animate={{ opacity: 1, y: 0 }}
21
+ exit={{ opacity: 0, y: -8 }}
22
+ transition={{ duration: 0.25, ease: 'easeOut' }}
23
+ className={cn('flex w-full flex-col gap-6', className)}
24
+ >
25
+ <header className="flex flex-col gap-2">
26
+ <h2 className="text-2xl font-semibold text-[rgb(var(--uob-text-primary))] sm:text-3xl">
27
+ {title}
28
+ </h2>
29
+ {subtitle && (
30
+ <p className="text-base text-[rgb(var(--uob-text-muted))]">{subtitle}</p>
31
+ )}
32
+ </header>
33
+ <div className="flex flex-col gap-3">{children}</div>
34
+ </motion.div>
35
+ );
36
+ }
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '../../theme/cn';
3
+
4
+ export interface ScreenContainerProps {
5
+ children: ReactNode;
6
+ header?: ReactNode;
7
+ footer?: ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * Standard layout for an onboarding screen.
13
+ * Owns safe-area, padding, header/footer slots. Per project rules this is the SOLE
14
+ * layout for authenticated onboarding screens — no per-page layout duplication.
15
+ */
16
+ export function ScreenContainer({ children, header, footer, className }: ScreenContainerProps) {
17
+ return (
18
+ <div
19
+ className={cn(
20
+ 'mx-auto flex min-h-screen w-full max-w-2xl flex-col gap-6 px-4 pb-8 pt-6 sm:px-6',
21
+ className,
22
+ )}
23
+ >
24
+ {header && <div className="flex flex-col gap-3">{header}</div>}
25
+ <main className="flex flex-1 flex-col gap-6">{children}</main>
26
+ {footer && <div className="flex flex-col gap-3">{footer}</div>}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { SwipeCard } from './SwipeCard';
4
+ import { OnboardingIntlProvider } from '../OnboardingIntl';
5
+
6
+ function renderCard(props: Partial<React.ComponentProps<typeof SwipeCard>> = {}) {
7
+ return render(
8
+ <OnboardingIntlProvider>
9
+ <SwipeCard
10
+ text="I want to feel better"
11
+ category="Mood"
12
+ onSwipeLeft={vi.fn()}
13
+ onSwipeRight={vi.fn()}
14
+ {...props}
15
+ />
16
+ </OnboardingIntlProvider>,
17
+ );
18
+ }
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
+ describe('SwipeCard', () => {
23
+ it('triggers onSwipeRight on ArrowRight and onSwipeLeft on ArrowLeft', () => {
24
+ const onSwipeLeft = vi.fn();
25
+ const onSwipeRight = vi.fn();
26
+ renderCard({ onSwipeLeft, onSwipeRight });
27
+ const card = screen.getByRole('group');
28
+ card.focus();
29
+ fireEvent.keyDown(card, { key: 'ArrowRight' });
30
+ expect(onSwipeRight).toHaveBeenCalledTimes(1);
31
+ expect(onSwipeLeft).not.toHaveBeenCalled();
32
+ fireEvent.keyDown(card, { key: 'ArrowLeft' });
33
+ expect(onSwipeLeft).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ it('renders the AGREE / DISAGREE badges and the swipe hint', () => {
37
+ renderCard();
38
+ expect(screen.getByText('Agree')).toBeTruthy();
39
+ expect(screen.getByText('Disagree')).toBeTruthy();
40
+ expect(screen.getByText(/arrow keys/)).toBeTruthy();
41
+ });
42
+
43
+ it('ignores unrelated keys', () => {
44
+ const onSwipeLeft = vi.fn();
45
+ const onSwipeRight = vi.fn();
46
+ renderCard({ onSwipeLeft, onSwipeRight });
47
+ fireEvent.keyDown(screen.getByRole('group'), { key: 'Enter' });
48
+ expect(onSwipeLeft).not.toHaveBeenCalled();
49
+ expect(onSwipeRight).not.toHaveBeenCalled();
50
+ });
51
+ });
@@ -0,0 +1,106 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { motion, useMotionValue, useTransform, type PanInfo } from 'framer-motion';
3
+ import { cn } from '../../theme/cn';
4
+ import {
5
+ didCrossLeftSwipeThreshold,
6
+ didCrossRightSwipeThreshold,
7
+ SWIPE_LEFT_OPACITY_INPUT,
8
+ SWIPE_LEFT_OPACITY_VALUES,
9
+ SWIPE_RIGHT_OPACITY_INPUT,
10
+ SWIPE_RIGHT_OPACITY_VALUES,
11
+ SWIPE_ROTATION_RANGE,
12
+ SWIPE_ROTATION_VALUES,
13
+ } from '../../calc/swipe-gesture-curve';
14
+ import { useOnboardingIntl } from '../OnboardingIntl';
15
+
16
+ export interface SwipeCardProps {
17
+ text: string;
18
+ category?: string;
19
+ onSwipeLeft: () => void;
20
+ onSwipeRight: () => void;
21
+ className?: string;
22
+ }
23
+
24
+ interface GestureOptions {
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.
31
+ const leftRef = useRef(onSwipeLeft);
32
+ const rightRef = useRef(onSwipeRight);
33
+ useEffect(() => {
34
+ leftRef.current = onSwipeLeft;
35
+ rightRef.current = onSwipeRight;
36
+ }, [onSwipeLeft, onSwipeRight]);
37
+
38
+ return {
39
+ onDragEnd(_: unknown, info: PanInfo) {
40
+ if (didCrossRightSwipeThreshold(info.offset.x)) rightRef.current();
41
+ else if (didCrossLeftSwipeThreshold(info.offset.x)) leftRef.current();
42
+ },
43
+ };
44
+ }
45
+
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
+ export function SwipeCard({ text, category, onSwipeLeft, onSwipeRight, className }: SwipeCardProps) {
54
+ const { labels } = useOnboardingIntl();
55
+ const x = useMotionValue(0);
56
+ const rotate = useTransform(x, SWIPE_ROTATION_RANGE, SWIPE_ROTATION_VALUES);
57
+ const leftOpacity = useTransform(x, SWIPE_LEFT_OPACITY_INPUT, SWIPE_LEFT_OPACITY_VALUES);
58
+ const rightOpacity = useTransform(x, SWIPE_RIGHT_OPACITY_INPUT, SWIPE_RIGHT_OPACITY_VALUES);
59
+ const { onDragEnd } = useSwipeGestures({ onSwipeLeft, onSwipeRight });
60
+
61
+ return (
62
+ <div
63
+ className={cn('relative h-72 w-full select-none focus:outline-none', className)}
64
+ role="group"
65
+ aria-label={`${text} — ${labels.swipeHint}`}
66
+ tabIndex={0}
67
+ onKeyDown={(e) => {
68
+ if (e.key === 'ArrowLeft') {
69
+ e.preventDefault();
70
+ onSwipeLeft();
71
+ } else if (e.key === 'ArrowRight') {
72
+ e.preventDefault();
73
+ onSwipeRight();
74
+ }
75
+ }}
76
+ >
77
+ <motion.div
78
+ style={{ x, rotate }}
79
+ drag="x"
80
+ dragConstraints={{ left: 0, right: 0 }}
81
+ onDragEnd={onDragEnd}
82
+ className="flex h-full w-full cursor-grab flex-col items-center justify-center rounded-2xl border border-[rgb(var(--uob-border))] bg-[rgb(var(--uob-surface))] p-8 text-center shadow-lg active:cursor-grabbing"
83
+ >
84
+ {category && (
85
+ <span className="mb-4 text-xs font-semibold uppercase tracking-wide text-[rgb(var(--uob-primary-500))]">
86
+ {category}
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>
91
+ </motion.div>
92
+ <motion.div
93
+ style={{ opacity: leftOpacity }}
94
+ className="pointer-events-none absolute left-4 top-4 rounded-md border-2 border-[rgb(var(--uob-error))] px-3 py-1 text-sm font-bold uppercase text-[rgb(var(--uob-error))]"
95
+ >
96
+ {labels.disagree}
97
+ </motion.div>
98
+ <motion.div
99
+ style={{ opacity: rightOpacity }}
100
+ className="pointer-events-none absolute right-4 top-4 rounded-md border-2 border-[rgb(var(--uob-success))] px-3 py-1 text-sm font-bold uppercase text-[rgb(var(--uob-success))]"
101
+ >
102
+ {labels.agree}
103
+ </motion.div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,16 @@
1
+ export { ProgressBar } from './ProgressBar';
2
+ export type { ProgressBarProps } from './ProgressBar';
3
+ export { QuestionWrapper } from './QuestionWrapper';
4
+ export type { QuestionWrapperProps } from './QuestionWrapper';
5
+ export { OptionCard } from './OptionCard';
6
+ export type { OptionCardProps } from './OptionCard';
7
+ export { SwipeCard } from './SwipeCard';
8
+ export type { SwipeCardProps } from './SwipeCard';
9
+ export { AnimationWrapper } from './AnimationWrapper';
10
+ export type { AnimationWrapperProps, ScreenTransition } from './AnimationWrapper';
11
+ export { ScreenContainer } from './ScreenContainer';
12
+ export type { ScreenContainerProps } from './ScreenContainer';
13
+ export { PrimaryButton } from './PrimaryButton';
14
+ export type { PrimaryButtonProps } from './PrimaryButton';
15
+ export { OnboardingErrorBoundary } from './OnboardingErrorBoundary';
16
+ export type { OnboardingErrorBoundaryProps } from './OnboardingErrorBoundary';
@@ -0,0 +1,20 @@
1
+ import { resolveIconByName } from '../../predicates/icon-name-resolves';
2
+
3
+ /**
4
+ * Render a bundled icon by registry name.
5
+ *
6
+ * Returns null when the name is not in the registry — callers combine it with
7
+ * `doesIconNameResolve` when the surrounding markup must disappear with the icon.
8
+ *
9
+ * Why a helper instead of `const Icon = resolveIconByName(name)` inside the
10
+ * component body? The registry lookup always yields a stable module-level
11
+ * component (nothing is created per render), but assigning it to a variable
12
+ * during render trips eslint-plugin-react-hooks' `static-components` rule at
13
+ * the JSX usage site. A module-scope function returning the element keeps the
14
+ * lint signal meaningful without suppression comments.
15
+ */
16
+ export function renderBundledIcon(name: string | undefined, className?: string) {
17
+ const Icon = resolveIconByName(name);
18
+ if (!Icon) return null;
19
+ return <Icon className={className} aria-hidden="true" />;
20
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @umituz/web-onboarding — Public API surface.
3
+ * Designed for tree-shaking: only import what you use.
4
+ */
5
+
6
+ import './theme/index.css';
7
+
8
+ // Orchestrator (the entry point)
9
+ export { OnboardingFlow } from './components/OnboardingFlow';
10
+ export type { OnboardingFlowProps } from './components/OnboardingFlow';
11
+
12
+ // Localization / labels (i18n hooks for consumers + class-component access
13
+ // to the same context the function components use)
14
+ export {
15
+ OnboardingIntlProvider,
16
+ OnboardingIntlContext,
17
+ useOnboardingIntl,
18
+ defaultOnboardingLabels,
19
+ formatLabel,
20
+ } from './components/OnboardingIntl';
21
+ export type { OnboardingIntl, OnboardingIntlProviderProps } from './components/OnboardingIntl';
22
+
23
+ // Screens
24
+ export {
25
+ WelcomeScreen,
26
+ GoalQuestionScreen,
27
+ PainPointsScreen,
28
+ SocialProofScreen,
29
+ TinderCardsScreen,
30
+ PersonalizedSolutionScreen,
31
+ ComparisonScreen,
32
+ PreferencesScreen,
33
+ PermissionPrimingScreen,
34
+ ProcessingScreen,
35
+ AppDemoScreen,
36
+ ValueDeliveryScreen,
37
+ AccountGateScreen,
38
+ PaywallScreen,
39
+ } from './components/screens';
40
+
41
+ // Shared / atomic
42
+ export {
43
+ ProgressBar,
44
+ QuestionWrapper,
45
+ OptionCard,
46
+ SwipeCard,
47
+ AnimationWrapper,
48
+ ScreenContainer,
49
+ PrimaryButton,
50
+ OnboardingErrorBoundary,
51
+ } from './components/shared';
52
+ export type { ScreenTransition } from './components/shared';
53
+
54
+ // Hooks
55
+ export {
56
+ useOnboardingProgress,
57
+ usePermissionPriming,
58
+ useOnboardingAnalytics,
59
+ useReducedMotion,
60
+ } from './components/hooks';
61
+ export type {
62
+ UseOnboardingProgressReturn,
63
+ UsePermissionPrimingReturn,
64
+ UseOnboardingAnalyticsReturn,
65
+ } from './components/hooks';
66
+
67
+ // Templates
68
+ export {
69
+ healthOnboardingTemplate,
70
+ artOnboardingTemplate,
71
+ videoOnboardingTemplate,
72
+ platformOnboardingTemplate,
73
+ withDefaultDemo,
74
+ defaultDemoResult,
75
+ TemplateDemoFallback,
76
+ } from './templates';
77
+ export type { TemplateDemoResult } from './templates';
78
+
79
+ // Calculations (pure math)
80
+ export {
81
+ computeProgressFraction,
82
+ computeProgressPercentage,
83
+ computeRemainingSteps,
84
+ clampStepIndex,
85
+ advanceStepIndex,
86
+ retreatStepIndex,
87
+ SWIPE_DECISION_THRESHOLD_PX,
88
+ SWIPE_ROTATION_RANGE,
89
+ SWIPE_ROTATION_VALUES,
90
+ SWIPE_LEFT_OPACITY_INPUT,
91
+ SWIPE_LEFT_OPACITY_VALUES,
92
+ SWIPE_RIGHT_OPACITY_INPUT,
93
+ SWIPE_RIGHT_OPACITY_VALUES,
94
+ didCrossRightSwipeThreshold,
95
+ didCrossLeftSwipeThreshold,
96
+ getCardEnterInitial,
97
+ getCardEnterAnimate,
98
+ getCardEnterExit,
99
+ getCardTransitionDurationMs,
100
+ resolveSliderBounds,
101
+ areSliderBoundsValid,
102
+ clampValueToBounds,
103
+ splitTotalDuration,
104
+ getDefaultStepDurationMs,
105
+ formatCountWithLocale,
106
+ elapsedMillis,
107
+ } from './calc';
108
+ export type { CardMotionState } from './calc';
109
+
110
+ // Validation
111
+ export {
112
+ checkFlowStructure,
113
+ checkScreenConfig,
114
+ checkStoredProgress,
115
+ getRecognizedScreenTypes,
116
+ isRecognizedPermissionStatus,
117
+ normalizePermissionStatus,
118
+ } from './validation';
119
+ export type { ValidationOutcome, StoredProgressShape, StoredProgressOutcome } from './validation';
120
+
121
+ // Predicates
122
+ export {
123
+ isPermissionGranted,
124
+ isPermissionDenied,
125
+ isPermissionPrompt,
126
+ isPermissionUnsupported,
127
+ isPermissionDecided,
128
+ doesBrowserSupportNotifications,
129
+ doesBrowserSupportMediaCapture,
130
+ doesBrowserSupportGeolocation,
131
+ isRuntimeSupportMissing,
132
+ isLocalStorageAvailable,
133
+ doesIconNameResolve,
134
+ getRegisteredIconNames,
135
+ resolveIconByName,
136
+ hasOnRequest,
137
+ hasOnAccept,
138
+ hasOnSignIn,
139
+ accountGateAllowsSkip,
140
+ screenEmitsData,
141
+ } from './predicates';
142
+
143
+ // I/O utilities
144
+ export {
145
+ trackOnboardingEvent,
146
+ dispatchAnalyticsEvent,
147
+ saveOnboardingProgress,
148
+ loadOnboardingProgress,
149
+ clearOnboardingProgress,
150
+ buildOnboardingResult,
151
+ queryPermissionStatus,
152
+ requestPermission,
153
+ defaultStorageKey,
154
+ } from './utils';
155
+ export type { RehydratedProgress } from './utils';
156
+
157
+ // Theme tokens (for consumers that want to rebrand)
158
+ export {
159
+ spacing,
160
+ radius,
161
+ typography,
162
+ zIndex,
163
+ motion as motionTokens,
164
+ breakpoint,
165
+ colorCssVar,
166
+ cn,
167
+ } from './theme';
168
+ export type { SpacingToken, RadiusToken, FontSizeToken } from './theme';
169
+
170
+ // Types
171
+ export type {
172
+ OnboardingScreen,
173
+ OnboardingScreenType,
174
+ OnboardingOption,
175
+ OnboardingConfig,
176
+ OnboardingCategory,
177
+ OnboardingLabels,
178
+ AnalyticsConfig,
179
+ AnalyticsProvider,
180
+ OnboardingAnalyticsEvent,
181
+ OnboardingResult,
182
+ } from './types';
@@ -0,0 +1 @@
1
+ .uob-flow{--uob-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--uob-primary-50: 238 242 255;--uob-primary-100: 224 231 255;--uob-primary-500: 99 102 241;--uob-primary-600: 79 70 229;--uob-primary-700: 67 56 202;--uob-surface: 255 255 255;--uob-surface-muted: 249 250 251;--uob-text-primary: 17 24 39;--uob-text-muted: 107 114 128;--uob-border: 229 231 235;--uob-success: 16 185 129;--uob-warning: 245 158 11;--uob-error: 239 68 68}@media (prefers-color-scheme: dark){.uob-flow{--uob-surface: 17 24 39;--uob-surface-muted: 31 41 55;--uob-text-primary: 243 244 246;--uob-text-muted: 156 163 175;--uob-border: 55 65 81}}.uob-flow,.uob-flow *,.uob-flow *:before,.uob-flow *:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:rgb(var(--uob-border))}.uob-flow h1,.uob-flow h2,.uob-flow h3,.uob-flow h4,.uob-flow h5,.uob-flow h6,.uob-flow p,.uob-flow figure,.uob-flow blockquote,.uob-flow pre{margin:0}.uob-flow ol,.uob-flow ul{list-style:none;margin:0;padding:0}.uob-flow button,.uob-flow input,.uob-flow optgroup,.uob-flow select,.uob-flow textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}.uob-flow button,.uob-flow [type=button],.uob-flow [type=reset],.uob-flow [type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}.uob-flow progress{-webkit-appearance:none;-moz-appearance:none;appearance:none}.uob-flow img,.uob-flow svg,.uob-flow video,.uob-flow canvas{display:block;vertical-align:middle}.uob-flow img,.uob-flow video{max-width:100%;height:auto}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.static{position:static}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-4{left:1rem}.right-4{right:1rem}.top-4{top:1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-4{margin-bottom:1rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-3{margin-top:.75rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-20{height:5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-72{height:18rem}.h-8{height:2rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-grab{cursor:grab}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-\[rgb\(var\(--uob-border\)\)\]{border-color:rgb(var(--uob-border))}.border-\[rgb\(var\(--uob-error\)\)\]{border-color:rgb(var(--uob-error))}.border-\[rgb\(var\(--uob-primary-500\)\)\]{border-color:rgb(var(--uob-primary-500))}.border-\[rgb\(var\(--uob-success\)\)\]{border-color:rgb(var(--uob-success))}.border-transparent{border-color:transparent}.border-white{--tw-border-opacity: 1;border-color:rgb(255 255 255 / var(--tw-border-opacity, 1))}.border-t-transparent{border-top-color:transparent}.bg-\[rgb\(var\(--uob-border\)\)\]{background-color:rgb(var(--uob-border))}.bg-\[rgb\(var\(--uob-error\)\)\]\/10{background-color:rgb(var(--uob-error) / .1)}.bg-\[rgb\(var\(--uob-primary-50\)\)\]{background-color:rgb(var(--uob-primary-50))}.bg-\[rgb\(var\(--uob-primary-500\)\)\]{background-color:rgb(var(--uob-primary-500))}.bg-\[rgb\(var\(--uob-success\)\)\]{background-color:rgb(var(--uob-success))}.bg-\[rgb\(var\(--uob-success\)\)\]\/10{background-color:rgb(var(--uob-success) / .1)}.bg-\[rgb\(var\(--uob-surface\)\)\]{background-color:rgb(var(--uob-surface))}.bg-\[rgb\(var\(--uob-surface-muted\)\)\]{background-color:rgb(var(--uob-surface-muted))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.fill-current{fill:currentColor}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0\.5{padding:.125rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-8{padding-bottom:2rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.tracking-wide{letter-spacing:.025em}.text-\[rgb\(var\(--uob-border\)\)\]{color:rgb(var(--uob-border))}.text-\[rgb\(var\(--uob-error\)\)\]{color:rgb(var(--uob-error))}.text-\[rgb\(var\(--uob-primary-500\)\)\]{color:rgb(var(--uob-primary-500))}.text-\[rgb\(var\(--uob-primary-700\)\)\]{color:rgb(var(--uob-primary-700))}.text-\[rgb\(var\(--uob-success\)\)\]{color:rgb(var(--uob-success))}.text-\[rgb\(var\(--uob-text-muted\)\)\]{color:rgb(var(--uob-text-muted))}.text-\[rgb\(var\(--uob-text-primary\)\)\]{color:rgb(var(--uob-text-primary))}.text-\[rgb\(var\(--uob-warning\)\)\]{color:rgb(var(--uob-warning))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-\[rgb\(var\(--uob-border\)\)\]:hover{border-color:rgb(var(--uob-border))}.hover\:border-\[rgb\(var\(--uob-primary-100\)\)\]:hover{border-color:rgb(var(--uob-primary-100))}.hover\:border-\[rgb\(var\(--uob-primary-500\)\)\]:hover{border-color:rgb(var(--uob-primary-500))}.hover\:bg-\[rgb\(var\(--uob-error\)\)\]:hover{background-color:rgb(var(--uob-error))}.hover\:bg-\[rgb\(var\(--uob-primary-600\)\)\]:hover{background-color:rgb(var(--uob-primary-600))}.hover\:bg-\[rgb\(var\(--uob-success\)\)\]:hover{background-color:rgb(var(--uob-success))}.hover\:bg-\[rgb\(var\(--uob-surface-muted\)\)\]:hover{background-color:rgb(var(--uob-surface-muted))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-\[rgb\(var\(--uob-primary-500\)\)\]:focus{--tw-ring-color: rgb(var(--uob-primary-500))}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px}.active\:cursor-grabbing:active{cursor:grabbing}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}@media (min-width: 640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}}
@@ -0,0 +1,25 @@
1
+ import type {
2
+ AccountGateScreenConfig,
3
+ PaywallScreenConfig,
4
+ PermissionPrimingScreenConfig,
5
+ } from '../types/screens';
6
+
7
+ /** Does the permission-priming config expose a usable `onRequest` callback? */
8
+ export function hasOnRequest(config: PermissionPrimingScreenConfig): boolean {
9
+ return typeof config.onRequest === 'function';
10
+ }
11
+
12
+ /** Does the paywall config expose a usable `onAccept` callback? */
13
+ export function hasOnAccept(config: PaywallScreenConfig): boolean {
14
+ return typeof config.onAccept === 'function';
15
+ }
16
+
17
+ /** Does the account-gate config expose a usable `onSignIn` callback? */
18
+ export function hasOnSignIn(config: AccountGateScreenConfig): boolean {
19
+ return typeof config.onSignIn === 'function';
20
+ }
21
+
22
+ /** Is a skip affordance allowed by the account-gate config? */
23
+ export function accountGateAllowsSkip(config: AccountGateScreenConfig): boolean {
24
+ return config.optional === true;
25
+ }