@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.
- package/LICENSE +21 -0
- package/README.md +681 -0
- package/dist/components/admin-shell.d.ts +121 -0
- package/dist/components/app-shell.d.ts +23 -0
- package/dist/components/auth-screen.d.ts +33 -0
- package/dist/components/avatar.d.ts +10 -0
- package/dist/components/badge.d.ts +14 -0
- package/dist/components/banner.d.ts +11 -0
- package/dist/components/breadcrumb.d.ts +12 -0
- package/dist/components/button.d.ts +21 -0
- package/dist/components/card.d.ts +15 -0
- package/dist/components/chat-composer.d.ts +52 -0
- package/dist/components/chat.d.ts +77 -0
- package/dist/components/checkout.d.ts +79 -0
- package/dist/components/choice.d.ts +15 -0
- package/dist/components/combobox.d.ts +31 -0
- package/dist/components/date-field.d.ts +58 -0
- package/dist/components/dialog.d.ts +20 -0
- package/dist/components/empty-state.d.ts +9 -0
- package/dist/components/feedback.d.ts +18 -0
- package/dist/components/field.d.ts +47 -0
- package/dist/components/icon-button.d.ts +12 -0
- package/dist/components/icon.d.ts +72 -0
- package/dist/components/layout.d.ts +13 -0
- package/dist/components/list.d.ts +16 -0
- package/dist/components/menu.d.ts +56 -0
- package/dist/components/onboarding.d.ts +50 -0
- package/dist/components/otp-input.d.ts +29 -0
- package/dist/components/pagination.d.ts +12 -0
- package/dist/components/password-field.d.ts +33 -0
- package/dist/components/phone-field.d.ts +44 -0
- package/dist/components/plan-card.d.ts +45 -0
- package/dist/components/plan-picker.d.ts +52 -0
- package/dist/components/profile-header.d.ts +47 -0
- package/dist/components/profile-screen.d.ts +44 -0
- package/dist/components/resource-meter.d.ts +22 -0
- package/dist/components/section-header.d.ts +8 -0
- package/dist/components/segmented.d.ts +18 -0
- package/dist/components/sheet.d.ts +18 -0
- package/dist/components/sign-in-flow.d.ts +60 -0
- package/dist/components/slider.d.ts +10 -0
- package/dist/components/stat.d.ts +10 -0
- package/dist/components/stepper.d.ts +17 -0
- package/dist/components/tab-bar.d.ts +40 -0
- package/dist/components/table.d.ts +33 -0
- package/dist/components/tabs.d.ts +23 -0
- package/dist/components/task-progress.d.ts +38 -0
- package/dist/components/theme.d.ts +49 -0
- package/dist/components/toast.d.ts +17 -0
- package/dist/components/tooltip.d.ts +18 -0
- package/dist/components/typography.d.ts +18 -0
- package/dist/components/wizard.d.ts +70 -0
- package/dist/index.d.ts +104 -0
- package/dist/lib/calendars.d.ts +81 -0
- package/dist/lib/cn.d.ts +7 -0
- package/dist/lib/countries.d.ts +33 -0
- package/dist/lib/use-anchor.d.ts +22 -0
- package/dist/nim.css +1 -0
- package/dist/nim.js +3266 -0
- package/package.json +83 -0
- package/src/components/admin-shell.tsx +338 -0
- package/src/components/app-shell.tsx +36 -0
- package/src/components/auth-screen.tsx +71 -0
- package/src/components/avatar.tsx +32 -0
- package/src/components/badge.tsx +44 -0
- package/src/components/banner.tsx +47 -0
- package/src/components/breadcrumb.tsx +45 -0
- package/src/components/button.tsx +64 -0
- package/src/components/card.tsx +46 -0
- package/src/components/chat-composer.tsx +331 -0
- package/src/components/chat.tsx +352 -0
- package/src/components/checkout.tsx +173 -0
- package/src/components/choice.tsx +46 -0
- package/src/components/combobox.tsx +157 -0
- package/src/components/date-field.tsx +394 -0
- package/src/components/dialog.tsx +77 -0
- package/src/components/empty-state.tsx +23 -0
- package/src/components/feedback.tsx +61 -0
- package/src/components/field.tsx +177 -0
- package/src/components/icon-button.tsx +35 -0
- package/src/components/icon.tsx +167 -0
- package/src/components/layout.tsx +38 -0
- package/src/components/list.tsx +78 -0
- package/src/components/menu.tsx +172 -0
- package/src/components/onboarding.tsx +157 -0
- package/src/components/otp-input.tsx +144 -0
- package/src/components/pagination.tsx +86 -0
- package/src/components/password-field.tsx +112 -0
- package/src/components/phone-field.tsx +242 -0
- package/src/components/plan-card.tsx +124 -0
- package/src/components/plan-picker.tsx +144 -0
- package/src/components/profile-header.tsx +134 -0
- package/src/components/profile-screen.tsx +104 -0
- package/src/components/resource-meter.tsx +60 -0
- package/src/components/section-header.tsx +31 -0
- package/src/components/segmented.tsx +53 -0
- package/src/components/sheet.tsx +74 -0
- package/src/components/sign-in-flow.tsx +322 -0
- package/src/components/slider.tsx +50 -0
- package/src/components/stat.tsx +30 -0
- package/src/components/stepper.tsx +77 -0
- package/src/components/tab-bar.tsx +82 -0
- package/src/components/table.tsx +81 -0
- package/src/components/tabs.tsx +72 -0
- package/src/components/task-progress.tsx +111 -0
- package/src/components/theme.tsx +112 -0
- package/src/components/toast.tsx +87 -0
- package/src/components/tooltip.tsx +30 -0
- package/src/components/typography.tsx +65 -0
- package/src/components/wizard.tsx +192 -0
- package/src/index.ts +216 -0
- package/src/lib/calendars.ts +227 -0
- package/src/lib/cn.ts +8 -0
- package/src/lib/countries.ts +110 -0
- package/src/lib/use-anchor.ts +92 -0
- package/src/theme/colorways/coral.css +62 -0
- package/src/theme/colorways/oxblood.css +23 -0
- package/src/theme/colorways/paper.css +68 -0
- package/src/theme/colorways/teal.css +56 -0
- package/src/theme/colorways/vermilion.css +21 -0
- package/src/theme/components.css +4606 -0
- package/src/theme/contract.css +204 -0
- package/src/theme/fonts.css +67 -0
- package/src/theme/index.css +33 -0
- package/src/theme/persian.css +47 -0
- package/src/theme/reset.css +118 -0
- package/src/theme/styles/ledger.css +106 -0
- package/src/theme/styles/vlora.css +91 -0
- 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
|
+
}
|