@nim.zone/ui 0.6.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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +681 -0
  3. package/dist/components/admin-shell.d.ts +121 -0
  4. package/dist/components/app-shell.d.ts +23 -0
  5. package/dist/components/auth-screen.d.ts +33 -0
  6. package/dist/components/avatar.d.ts +10 -0
  7. package/dist/components/badge.d.ts +14 -0
  8. package/dist/components/banner.d.ts +11 -0
  9. package/dist/components/breadcrumb.d.ts +12 -0
  10. package/dist/components/button.d.ts +21 -0
  11. package/dist/components/card.d.ts +15 -0
  12. package/dist/components/chat-composer.d.ts +52 -0
  13. package/dist/components/chat.d.ts +77 -0
  14. package/dist/components/checkout.d.ts +79 -0
  15. package/dist/components/choice.d.ts +15 -0
  16. package/dist/components/combobox.d.ts +31 -0
  17. package/dist/components/date-field.d.ts +58 -0
  18. package/dist/components/dialog.d.ts +20 -0
  19. package/dist/components/empty-state.d.ts +9 -0
  20. package/dist/components/feedback.d.ts +18 -0
  21. package/dist/components/field.d.ts +47 -0
  22. package/dist/components/icon-button.d.ts +12 -0
  23. package/dist/components/icon.d.ts +72 -0
  24. package/dist/components/layout.d.ts +13 -0
  25. package/dist/components/list.d.ts +16 -0
  26. package/dist/components/menu.d.ts +56 -0
  27. package/dist/components/onboarding.d.ts +50 -0
  28. package/dist/components/otp-input.d.ts +29 -0
  29. package/dist/components/pagination.d.ts +12 -0
  30. package/dist/components/password-field.d.ts +33 -0
  31. package/dist/components/phone-field.d.ts +44 -0
  32. package/dist/components/plan-card.d.ts +45 -0
  33. package/dist/components/plan-picker.d.ts +52 -0
  34. package/dist/components/profile-header.d.ts +47 -0
  35. package/dist/components/profile-screen.d.ts +44 -0
  36. package/dist/components/resource-meter.d.ts +22 -0
  37. package/dist/components/section-header.d.ts +8 -0
  38. package/dist/components/segmented.d.ts +18 -0
  39. package/dist/components/sheet.d.ts +18 -0
  40. package/dist/components/sign-in-flow.d.ts +60 -0
  41. package/dist/components/slider.d.ts +10 -0
  42. package/dist/components/stat.d.ts +10 -0
  43. package/dist/components/stepper.d.ts +17 -0
  44. package/dist/components/tab-bar.d.ts +40 -0
  45. package/dist/components/table.d.ts +33 -0
  46. package/dist/components/tabs.d.ts +23 -0
  47. package/dist/components/task-progress.d.ts +38 -0
  48. package/dist/components/theme.d.ts +49 -0
  49. package/dist/components/toast.d.ts +17 -0
  50. package/dist/components/tooltip.d.ts +18 -0
  51. package/dist/components/typography.d.ts +18 -0
  52. package/dist/components/wizard.d.ts +70 -0
  53. package/dist/index.d.ts +104 -0
  54. package/dist/lib/calendars.d.ts +81 -0
  55. package/dist/lib/cn.d.ts +7 -0
  56. package/dist/lib/countries.d.ts +33 -0
  57. package/dist/lib/use-anchor.d.ts +22 -0
  58. package/dist/nim.css +1 -0
  59. package/dist/nim.js +3266 -0
  60. package/package.json +83 -0
  61. package/src/components/admin-shell.tsx +338 -0
  62. package/src/components/app-shell.tsx +36 -0
  63. package/src/components/auth-screen.tsx +71 -0
  64. package/src/components/avatar.tsx +32 -0
  65. package/src/components/badge.tsx +44 -0
  66. package/src/components/banner.tsx +47 -0
  67. package/src/components/breadcrumb.tsx +45 -0
  68. package/src/components/button.tsx +64 -0
  69. package/src/components/card.tsx +46 -0
  70. package/src/components/chat-composer.tsx +331 -0
  71. package/src/components/chat.tsx +352 -0
  72. package/src/components/checkout.tsx +173 -0
  73. package/src/components/choice.tsx +46 -0
  74. package/src/components/combobox.tsx +157 -0
  75. package/src/components/date-field.tsx +394 -0
  76. package/src/components/dialog.tsx +77 -0
  77. package/src/components/empty-state.tsx +23 -0
  78. package/src/components/feedback.tsx +61 -0
  79. package/src/components/field.tsx +177 -0
  80. package/src/components/icon-button.tsx +35 -0
  81. package/src/components/icon.tsx +167 -0
  82. package/src/components/layout.tsx +38 -0
  83. package/src/components/list.tsx +78 -0
  84. package/src/components/menu.tsx +172 -0
  85. package/src/components/onboarding.tsx +157 -0
  86. package/src/components/otp-input.tsx +144 -0
  87. package/src/components/pagination.tsx +86 -0
  88. package/src/components/password-field.tsx +112 -0
  89. package/src/components/phone-field.tsx +242 -0
  90. package/src/components/plan-card.tsx +124 -0
  91. package/src/components/plan-picker.tsx +144 -0
  92. package/src/components/profile-header.tsx +134 -0
  93. package/src/components/profile-screen.tsx +104 -0
  94. package/src/components/resource-meter.tsx +60 -0
  95. package/src/components/section-header.tsx +31 -0
  96. package/src/components/segmented.tsx +53 -0
  97. package/src/components/sheet.tsx +74 -0
  98. package/src/components/sign-in-flow.tsx +322 -0
  99. package/src/components/slider.tsx +50 -0
  100. package/src/components/stat.tsx +30 -0
  101. package/src/components/stepper.tsx +77 -0
  102. package/src/components/tab-bar.tsx +82 -0
  103. package/src/components/table.tsx +81 -0
  104. package/src/components/tabs.tsx +72 -0
  105. package/src/components/task-progress.tsx +111 -0
  106. package/src/components/theme.tsx +112 -0
  107. package/src/components/toast.tsx +87 -0
  108. package/src/components/tooltip.tsx +30 -0
  109. package/src/components/typography.tsx +65 -0
  110. package/src/components/wizard.tsx +192 -0
  111. package/src/index.ts +216 -0
  112. package/src/lib/calendars.ts +227 -0
  113. package/src/lib/cn.ts +8 -0
  114. package/src/lib/countries.ts +110 -0
  115. package/src/lib/use-anchor.ts +92 -0
  116. package/src/theme/colorways/coral.css +62 -0
  117. package/src/theme/colorways/oxblood.css +23 -0
  118. package/src/theme/colorways/paper.css +68 -0
  119. package/src/theme/colorways/teal.css +56 -0
  120. package/src/theme/colorways/vermilion.css +21 -0
  121. package/src/theme/components.css +4606 -0
  122. package/src/theme/contract.css +204 -0
  123. package/src/theme/fonts.css +67 -0
  124. package/src/theme/index.css +33 -0
  125. package/src/theme/persian.css +47 -0
  126. package/src/theme/reset.css +118 -0
  127. package/src/theme/styles/ledger.css +106 -0
  128. package/src/theme/styles/vlora.css +91 -0
  129. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,104 @@
1
+ import type { ReactNode } from 'react'
2
+ import { List, ListRow } from '@/components/list'
3
+ import { ProfileHeader, type ProfileHeaderProps } from '@/components/profile-header'
4
+ import { SectionHeader } from '@/components/section-header'
5
+ import { Switch } from '@/components/choice'
6
+ import { cn } from '@/lib/cn'
7
+ import type { IconName } from '@/components/icon'
8
+ import { Icon } from '@/components/icon'
9
+
10
+ export interface ProfileRow {
11
+ /** A switch instead of a chevron. With `onToggle` the row is the control. */
12
+ checked?: boolean
13
+ danger?: boolean
14
+ href?: string
15
+ icon?: IconName
16
+ key: string
17
+ label: ReactNode
18
+ onSelect?: () => void
19
+ onToggle?: (next: boolean) => void
20
+ subtitle?: ReactNode
21
+ /** A badge, a count, a version string. */
22
+ value?: ReactNode
23
+ }
24
+
25
+ export interface ProfileSection {
26
+ description?: ReactNode
27
+ key: string
28
+ rows: ProfileRow[]
29
+ title?: ReactNode
30
+ }
31
+
32
+ export interface ProfileScreenProps extends ProfileHeaderProps {
33
+ className?: string
34
+ /** Sign out, delete account — whatever ends the session, kept away from the
35
+ rows above so it is never the thing a thumb reaches by accident. */
36
+ footer?: ReactNode
37
+ sections?: ProfileSection[]
38
+ }
39
+
40
+ /**
41
+ * The account screen: the identity plate, then grouped rows of settings.
42
+ *
43
+ * Ready to mount — hand it the header's props and a list of sections and it is
44
+ * the screen. Rows are declared, not composed, so the whole of a settings page
45
+ * is data the app already has: a label, an icon, and either somewhere to go or
46
+ * something to toggle.
47
+ *
48
+ * State stays the caller's. A switch here reports the change and redraws from
49
+ * the prop, because the truth about whether notifications are on lives on a
50
+ * server, and a row that flips optimistically and then disagrees with it is
51
+ * worse than one that waits.
52
+ */
53
+ export function ProfileScreen({
54
+ className,
55
+ footer,
56
+ sections = [],
57
+ ...header
58
+ }: ProfileScreenProps) {
59
+ return (
60
+ <div className={cn('nim-profile-screen', className)}>
61
+ <ProfileHeader {...header} />
62
+
63
+ {sections.map((section) => (
64
+ <section className="nim-profile-screen__section" key={section.key}>
65
+ {section.title ? (
66
+ <SectionHeader description={section.description} title={section.title} />
67
+ ) : null}
68
+ <List>
69
+ {section.rows.map((row) => (
70
+ <ListRow
71
+ className={cn(row.danger && 'nim-list-row--danger')}
72
+ href={row.href}
73
+ key={row.key}
74
+ leading={row.icon ? <Icon name={row.icon} size="md" /> : undefined}
75
+ onClick={row.onToggle ? undefined : row.onSelect}
76
+ subtitle={row.subtitle}
77
+ title={row.label}
78
+ trailing={
79
+ row.onToggle ? (
80
+ // The row's own title names the switch, so the control
81
+ // carries the name rather than repeating the text beside
82
+ // itself. A toggle row is a div, never a button — a switch
83
+ // inside a button is two controls in one target.
84
+ <Switch
85
+ aria-label={typeof row.label === 'string' ? row.label : undefined}
86
+ checked={row.checked ?? false}
87
+ onChange={(event) => row.onToggle?.(event.target.checked)}
88
+ >
89
+ {''}
90
+ </Switch>
91
+ ) : row.value !== undefined ? (
92
+ <span className="nim-profile-screen__value">{row.value}</span>
93
+ ) : undefined
94
+ }
95
+ />
96
+ ))}
97
+ </List>
98
+ </section>
99
+ ))}
100
+
101
+ {footer ? <div className="nim-profile-screen__footer">{footer}</div> : null}
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,60 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ export type ResourceMeterTone = 'accent' | 'danger' | 'success' | 'warning'
5
+
6
+ export interface ResourceMeterProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** A concise description of the capacity being measured. */
8
+ label: ReactNode
9
+ /** A readable measurement such as `18.4 / 32 GiB`; formatting stays in the product. */
10
+ value: ReactNode
11
+ /** Optional secondary context, for example a node's one-minute load. */
12
+ detail?: ReactNode
13
+ /** Percentage used, clamped to the meter's valid 0–100 range. Omit for capacity-only data. */
14
+ percent?: number
15
+ tone?: ResourceMeterTone
16
+ }
17
+
18
+ /**
19
+ * A compact, accessible capacity reading for operator interfaces.
20
+ *
21
+ * `Progress` is intentionally unlabelled composition; a resource figure needs
22
+ * its numerator, denominator, and qualitative state to travel together. The
23
+ * product supplies formatting because bytes, vCPU, and retention windows do
24
+ * not share a unit.
25
+ */
26
+ export function ResourceMeter({
27
+ className,
28
+ detail,
29
+ label,
30
+ percent,
31
+ tone = 'accent',
32
+ value,
33
+ ...props
34
+ }: ResourceMeterProps) {
35
+ const hasMeasurement = typeof percent === 'number'
36
+ const clamped = Math.min(100, Math.max(0, percent ?? 0))
37
+ const labelText = typeof label === 'string' ? label : undefined
38
+
39
+ return (
40
+ <div className={cn('nim-resource-meter', className)} data-tone={tone} {...props}>
41
+ <div className="nim-resource-meter__head">
42
+ <span className="nim-resource-meter__label">{label}</span>
43
+ <span className="nim-resource-meter__value">{value}</span>
44
+ </div>
45
+ {hasMeasurement ? (
46
+ <div
47
+ aria-label={labelText}
48
+ aria-valuemax={100}
49
+ aria-valuemin={0}
50
+ aria-valuenow={clamped}
51
+ className="nim-resource-meter__track"
52
+ role="meter"
53
+ >
54
+ <span className="nim-resource-meter__fill" style={{ inlineSize: `${clamped}%` }} />
55
+ </div>
56
+ ) : null}
57
+ {detail ? <span className="nim-resource-meter__detail">{detail}</span> : null}
58
+ </div>
59
+ )
60
+ }
@@ -0,0 +1,31 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ export interface SectionHeaderProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
5
+ action?: ReactNode
6
+ description?: ReactNode
7
+ eyebrow?: ReactNode
8
+ title: ReactNode
9
+ }
10
+
11
+ export function SectionHeader({
12
+ action,
13
+ className,
14
+ description,
15
+ eyebrow,
16
+ title,
17
+ ...props
18
+ }: SectionHeaderProps) {
19
+ return (
20
+ <header className={cn('nim-section-header', className)} {...props}>
21
+ <div>
22
+ {eyebrow ? <p className="nim-label nim-section-header__eyebrow">{eyebrow}</p> : null}
23
+ <h2 className="nim-title nim-title--md">{title}</h2>
24
+ {description ? (
25
+ <p className="nim-body nim-body--sm nim-section-header__description">{description}</p>
26
+ ) : null}
27
+ </div>
28
+ {action ? <div className="nim-section-header__action">{action}</div> : null}
29
+ </header>
30
+ )
31
+ }
@@ -0,0 +1,53 @@
1
+ import type { HTMLAttributes } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ export interface SegmentedOption<T extends string> {
5
+ disabled?: boolean
6
+ label: string
7
+ value: T
8
+ }
9
+
10
+ export interface SegmentedProps<T extends string> extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
11
+ fullWidth?: boolean
12
+ label: string
13
+ onChange: (value: T) => void
14
+ options: SegmentedOption<T>[]
15
+ value: T
16
+ }
17
+
18
+ /**
19
+ * A tablist rather than a row of buttons: arrow-key navigation and the
20
+ * selected state both come from the platform's tab semantics.
21
+ */
22
+ export function Segmented<T extends string>({
23
+ className,
24
+ fullWidth = false,
25
+ label,
26
+ onChange,
27
+ options,
28
+ value,
29
+ ...props
30
+ }: SegmentedProps<T>) {
31
+ return (
32
+ <div
33
+ aria-label={label}
34
+ className={cn('nim-segmented', fullWidth && 'nim-segmented--full', className)}
35
+ role="tablist"
36
+ {...props}
37
+ >
38
+ {options.map((option) => (
39
+ <button
40
+ aria-selected={option.value === value}
41
+ className="nim-segmented__option"
42
+ disabled={option.disabled}
43
+ key={option.value}
44
+ onClick={() => onChange(option.value)}
45
+ role="tab"
46
+ type="button"
47
+ >
48
+ {option.label}
49
+ </button>
50
+ ))}
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,74 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import type { ReactNode } from 'react'
4
+ import { IconButton } from '@/components/icon-button'
5
+ import { cn } from '@/lib/cn'
6
+
7
+ export interface SheetProps {
8
+ children: ReactNode
9
+ className?: string
10
+ /** Label for the close control; also names the dialog when no title is set. */
11
+ closeLabel?: string
12
+ footer?: ReactNode
13
+ onClose: () => void
14
+ open: boolean
15
+ title?: ReactNode
16
+ }
17
+
18
+ /**
19
+ * The bottom sheet is nim's modal surface. It owns the three things that are
20
+ * always forgotten in hand-rolled sheets: the page behind it must not scroll,
21
+ * Escape must close it, and focus must move into it on open and be restorable
22
+ * on close.
23
+ */
24
+ export function Sheet({ children, className, closeLabel = 'Close', footer, onClose, open, title }: SheetProps) {
25
+ const panelRef = useRef<HTMLDivElement>(null)
26
+ const restoreRef = useRef<HTMLElement | null>(null)
27
+
28
+ useEffect(() => {
29
+ if (!open) return
30
+
31
+ restoreRef.current = document.activeElement as HTMLElement | null
32
+ const previousOverflow = document.body.style.overflow
33
+ document.body.style.overflow = 'hidden'
34
+ panelRef.current?.focus()
35
+
36
+ const onKeyDown = (event: KeyboardEvent) => {
37
+ if (event.key === 'Escape') onClose()
38
+ }
39
+ window.addEventListener('keydown', onKeyDown)
40
+
41
+ return () => {
42
+ document.body.style.overflow = previousOverflow
43
+ window.removeEventListener('keydown', onKeyDown)
44
+ restoreRef.current?.focus?.()
45
+ }
46
+ }, [onClose, open])
47
+
48
+ if (!open || typeof document === 'undefined') return null
49
+
50
+ return createPortal(
51
+ <>
52
+ <div className="nim-sheet__scrim" onClick={onClose} />
53
+ <div
54
+ aria-label={typeof title === 'string' ? title : closeLabel}
55
+ aria-modal="true"
56
+ className={cn('nim-sheet__panel', className)}
57
+ ref={panelRef}
58
+ role="dialog"
59
+ tabIndex={-1}
60
+ >
61
+ <span aria-hidden="true" className="nim-sheet__handle" />
62
+ {title ? (
63
+ <div className="nim-sheet__header">
64
+ <p className="nim-title nim-title--md">{title}</p>
65
+ <IconButton label={closeLabel} name="close" onClick={onClose} size="sm" />
66
+ </div>
67
+ ) : null}
68
+ <div className="nim-sheet__body">{children}</div>
69
+ {footer ? <div className="nim-sheet__footer">{footer}</div> : null}
70
+ </div>
71
+ </>,
72
+ document.body,
73
+ )
74
+ }
@@ -0,0 +1,322 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { AuthScreen } from '@/components/auth-screen'
4
+ import { Banner } from '@/components/banner'
5
+ import { Button } from '@/components/button'
6
+ import { Input } from '@/components/field'
7
+ import { OtpInput } from '@/components/otp-input'
8
+ import { PasswordField } from '@/components/password-field'
9
+ import { PhoneField, toE164 } from '@/components/phone-field'
10
+
11
+ export type SignInMethod = 'code' | 'password'
12
+
13
+ export interface SignInCopy {
14
+ back: string
15
+ codeLabel: string
16
+ codeSubtitle: (destination: string) => ReactNode
17
+ codeTitle: string
18
+ identifierLabel: string
19
+ passwordLabel: string
20
+ passwordSubtitle: ReactNode
21
+ passwordTitle: string
22
+ phoneLabel: string
23
+ phoneSubtitle: ReactNode
24
+ phoneTitle: string
25
+ resend: string
26
+ resendIn: (seconds: number) => string
27
+ sendCode: string
28
+ signIn: string
29
+ usePassword: string
30
+ usePhone: string
31
+ verify: string
32
+ }
33
+
34
+ const DEFAULT_COPY: SignInCopy = {
35
+ back: 'Back',
36
+ codeLabel: 'Verification code',
37
+ codeSubtitle: (destination) => `Enter the code we sent to ${destination}`,
38
+ codeTitle: 'Verification code',
39
+ identifierLabel: 'Email',
40
+ passwordLabel: 'Password',
41
+ passwordSubtitle: 'Use the email and password on your account.',
42
+ passwordTitle: 'Sign in with a password',
43
+ phoneLabel: 'Mobile number',
44
+ phoneSubtitle: 'Enter your mobile number. We will text you a code — no password.',
45
+ phoneTitle: 'Welcome',
46
+ resend: 'Send the code again',
47
+ resendIn: (seconds) => `You can ask for a new code in ${seconds}s`,
48
+ sendCode: 'Send code',
49
+ signIn: 'Sign in',
50
+ usePassword: 'Sign in with a password instead',
51
+ usePhone: 'Sign in with a code instead',
52
+ verify: 'Verify and sign in',
53
+ }
54
+
55
+ export interface SignInFlowProps {
56
+ brand?: ReactNode
57
+ className?: string
58
+ codeLength?: number
59
+ copy?: Partial<SignInCopy>
60
+ /** ISO 3166-1 alpha-2 the phone step opens on. */
61
+ defaultCountry?: string
62
+ /** Which door the flow opens on. With both methods enabled the viewer can
63
+ switch; with one, the other is never offered. */
64
+ defaultMethod?: SignInMethod
65
+ /** Terms line, support link — under the action on every step. */
66
+ footer?: ReactNode
67
+ methods?: SignInMethod[]
68
+ /** Ask the backend for a code. Rejecting shows the message on the step. */
69
+ onRequestCode?: (e164: string) => Promise<void> | void
70
+ /** Verify it. Resolving is success — routing afterwards is the caller's. */
71
+ onVerifyCode?: (e164: string, code: string) => Promise<void> | void
72
+ onPasswordSignIn?: (identifier: string, password: string) => Promise<void> | void
73
+ /** Countries floated to the top of the picker. */
74
+ priority?: string[]
75
+ /** Seconds before "send it again" is offered. */
76
+ resendSeconds?: number
77
+ }
78
+
79
+ const messageOf = (error: unknown, fallback: string) =>
80
+ error instanceof Error && error.message.trim() ? error.message.trim() : fallback
81
+
82
+ /**
83
+ * The whole sign-in, ready to mount: phone → code, or email → password, with
84
+ * the resend countdown, the loading and error states, and the step machine
85
+ * already wired.
86
+ *
87
+ * The caller supplies three async functions and gets a working screen. Nothing
88
+ * here knows about a router or an API client — `onVerifyCode` resolving *is*
89
+ * success, and where the viewer goes next is the app's decision, made in one
90
+ * place instead of at each of the flow's five exits.
91
+ *
92
+ * Compose `AuthScreen`, `PhoneField`, `OtpInput` and `PasswordField` by hand
93
+ * instead when the product's flow differs — an invite code step, a captcha, a
94
+ * tenant picker. This is the common shape, not the only one.
95
+ */
96
+ export function SignInFlow({
97
+ brand,
98
+ className,
99
+ codeLength = 5,
100
+ copy,
101
+ defaultCountry = 'IR',
102
+ defaultMethod = 'code',
103
+ footer,
104
+ methods = ['code', 'password'],
105
+ onPasswordSignIn,
106
+ onRequestCode,
107
+ onVerifyCode,
108
+ priority = ['IR', 'AE', 'TR', 'DE', 'US', 'GB'],
109
+ resendSeconds = 60,
110
+ }: SignInFlowProps) {
111
+ const text = { ...DEFAULT_COPY, ...copy }
112
+ const [method, setMethod] = useState<SignInMethod>(
113
+ methods.includes(defaultMethod) ? defaultMethod : methods[0],
114
+ )
115
+ const [sent, setSent] = useState(false)
116
+ const [country, setCountry] = useState(defaultCountry)
117
+ const [phone, setPhone] = useState('')
118
+ const [code, setCode] = useState('')
119
+ const [identifier, setIdentifier] = useState('')
120
+ const [password, setPassword] = useState('')
121
+ const [busy, setBusy] = useState(false)
122
+ const [error, setError] = useState('')
123
+ const [countdown, setCountdown] = useState(0)
124
+ const verifying = useRef(false)
125
+
126
+ useEffect(() => {
127
+ if (countdown <= 0) return
128
+ const timer = window.setTimeout(() => setCountdown((seconds) => seconds - 1), 1000)
129
+ return () => window.clearTimeout(timer)
130
+ }, [countdown])
131
+
132
+ const e164 = toE164(country, phone)
133
+ const phoneReady = phone.replace(/\D/g, '').length >= 6
134
+
135
+ const requestCode = useCallback(
136
+ async (resend = false) => {
137
+ if (busy || (!resend && !phoneReady)) return
138
+ setBusy(true)
139
+ setError('')
140
+ try {
141
+ await onRequestCode?.(e164)
142
+ setSent(true)
143
+ setCode('')
144
+ setCountdown(resendSeconds)
145
+ } catch (failure) {
146
+ setError(messageOf(failure, text.sendCode))
147
+ } finally {
148
+ setBusy(false)
149
+ }
150
+ },
151
+ [busy, e164, onRequestCode, phoneReady, resendSeconds, text.sendCode],
152
+ )
153
+
154
+ const verify = useCallback(
155
+ async (value: string) => {
156
+ // Auto-submit on the last digit and the button can fire together; the
157
+ // guard is a ref because both land in the same render.
158
+ if (verifying.current || value.length !== codeLength) return
159
+ verifying.current = true
160
+ setBusy(true)
161
+ setError('')
162
+ try {
163
+ await onVerifyCode?.(e164, value)
164
+ } catch (failure) {
165
+ setError(messageOf(failure, text.verify))
166
+ setCode('')
167
+ } finally {
168
+ verifying.current = false
169
+ setBusy(false)
170
+ }
171
+ },
172
+ [codeLength, e164, onVerifyCode, text.verify],
173
+ )
174
+
175
+ const signInWithPassword = useCallback(async () => {
176
+ if (busy || !identifier.trim() || !password) return
177
+ setBusy(true)
178
+ setError('')
179
+ try {
180
+ await onPasswordSignIn?.(identifier.trim(), password)
181
+ } catch (failure) {
182
+ setError(messageOf(failure, text.signIn))
183
+ } finally {
184
+ setBusy(false)
185
+ }
186
+ }, [busy, identifier, onPasswordSignIn, password, text.signIn])
187
+
188
+ const switcher =
189
+ methods.length > 1 ? (
190
+ <Button
191
+ onClick={() => {
192
+ setMethod(method === 'code' ? 'password' : 'code')
193
+ setError('')
194
+ }}
195
+ size="sm"
196
+ variant="ghost"
197
+ >
198
+ {method === 'code' ? text.usePassword : text.usePhone}
199
+ </Button>
200
+ ) : null
201
+
202
+ const notice = error ? <Banner tone="danger">{error}</Banner> : null
203
+
204
+ if (method === 'password') {
205
+ return (
206
+ <AuthScreen
207
+ action={{
208
+ disabled: !identifier.trim() || !password,
209
+ label: text.signIn,
210
+ loading: busy,
211
+ onClick: () => void signInWithPassword(),
212
+ }}
213
+ brand={brand}
214
+ className={className}
215
+ footer={
216
+ <>
217
+ {switcher}
218
+ {footer}
219
+ </>
220
+ }
221
+ subtitle={text.passwordSubtitle}
222
+ title={text.passwordTitle}
223
+ >
224
+ {notice}
225
+ <Input
226
+ autoComplete="username"
227
+ label={text.identifierLabel}
228
+ onChange={(event) => setIdentifier(event.target.value)}
229
+ type="email"
230
+ value={identifier}
231
+ />
232
+ <PasswordField
233
+ autoComplete="current-password"
234
+ label={text.passwordLabel}
235
+ onChange={(event) => setPassword(event.target.value)}
236
+ onKeyDown={(event) => {
237
+ if (event.key === 'Enter') void signInWithPassword()
238
+ }}
239
+ value={password}
240
+ />
241
+ </AuthScreen>
242
+ )
243
+ }
244
+
245
+ if (sent) {
246
+ return (
247
+ <AuthScreen
248
+ action={{
249
+ disabled: code.length !== codeLength,
250
+ label: text.verify,
251
+ loading: busy,
252
+ onClick: () => void verify(code),
253
+ }}
254
+ back={{
255
+ label: text.back,
256
+ onClick: () => {
257
+ setSent(false)
258
+ setCode('')
259
+ setError('')
260
+ },
261
+ }}
262
+ className={className}
263
+ footer={
264
+ <>
265
+ {countdown > 0 ? (
266
+ <p>{text.resendIn(countdown)}</p>
267
+ ) : (
268
+ <Button onClick={() => void requestCode(true)} size="sm" variant="ghost">
269
+ {text.resend}
270
+ </Button>
271
+ )}
272
+ {footer}
273
+ </>
274
+ }
275
+ subtitle={text.codeSubtitle(e164)}
276
+ title={text.codeTitle}
277
+ >
278
+ {notice}
279
+ <OtpInput
280
+ autoFocus
281
+ label={text.codeLabel}
282
+ length={codeLength}
283
+ onChange={setCode}
284
+ onComplete={(value) => void verify(value)}
285
+ value={code}
286
+ />
287
+ </AuthScreen>
288
+ )
289
+ }
290
+
291
+ return (
292
+ <AuthScreen
293
+ action={{
294
+ disabled: !phoneReady,
295
+ label: text.sendCode,
296
+ loading: busy,
297
+ onClick: () => void requestCode(),
298
+ }}
299
+ brand={brand}
300
+ className={className}
301
+ footer={
302
+ <>
303
+ {switcher}
304
+ {footer}
305
+ </>
306
+ }
307
+ subtitle={text.phoneSubtitle}
308
+ title={text.phoneTitle}
309
+ >
310
+ {notice}
311
+ <PhoneField
312
+ country={country}
313
+ label={text.phoneLabel}
314
+ onChange={setPhone}
315
+ onCountryChange={setCountry}
316
+ onSubmit={() => void requestCode()}
317
+ priority={priority}
318
+ value={phone}
319
+ />
320
+ </AuthScreen>
321
+ )
322
+ }
@@ -0,0 +1,50 @@
1
+ import type { CSSProperties, InputHTMLAttributes } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ export interface SliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
5
+ label?: string
6
+ max?: number
7
+ min?: number
8
+ /** Rendered beneath the track, evenly spaced — e.g. ['1M', '8M', '15M']. */
9
+ scale?: string[]
10
+ value: number
11
+ }
12
+
13
+ export function Slider({
14
+ className,
15
+ label,
16
+ max = 100,
17
+ min = 0,
18
+ scale,
19
+ step = 1,
20
+ value,
21
+ ...props
22
+ }: SliderProps) {
23
+ const progress = max === min ? 0 : ((value - min) / (max - min)) * 100
24
+
25
+ return (
26
+ <div className="nim-field">
27
+ {label ? <span className="nim-field__label">{label}</span> : null}
28
+ <input
29
+ aria-label={label}
30
+ className={cn('nim-slider', className)}
31
+ max={max}
32
+ min={min}
33
+ step={step}
34
+ style={{ '--nim-slider-progress': `${progress}%` } as CSSProperties}
35
+ type="range"
36
+ value={value}
37
+ {...props}
38
+ />
39
+ {scale ? (
40
+ <div aria-hidden="true" className="nim-inline" style={{ justifyContent: 'space-between' }}>
41
+ {scale.map((mark) => (
42
+ <span className="nim-caption" key={mark}>
43
+ {mark}
44
+ </span>
45
+ ))}
46
+ </div>
47
+ ) : null}
48
+ </div>
49
+ )
50
+ }