@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,242 @@
|
|
|
1
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
import { COUNTRIES, countryByIso2, countryNamer, toAsciiDigits } from '@/lib/countries'
|
|
5
|
+
|
|
6
|
+
export interface PhoneFieldProps {
|
|
7
|
+
className?: string
|
|
8
|
+
/** ISO 3166-1 alpha-2 of the selected country, e.g. `IR`. */
|
|
9
|
+
country: string
|
|
10
|
+
error?: string
|
|
11
|
+
hint?: string
|
|
12
|
+
id?: string
|
|
13
|
+
label?: string
|
|
14
|
+
/** Locale the country names are shown in. Defaults to the document's. */
|
|
15
|
+
locale?: string
|
|
16
|
+
onChange: (value: string) => void
|
|
17
|
+
onCountryChange: (iso2: string) => void
|
|
18
|
+
onSubmit?: () => void
|
|
19
|
+
placeholder?: string
|
|
20
|
+
/** Countries to float above the alphabetical list — the ones most viewers
|
|
21
|
+
of this product will pick. */
|
|
22
|
+
priority?: string[]
|
|
23
|
+
required?: boolean
|
|
24
|
+
/** Accessible names for the picker. */
|
|
25
|
+
labels?: { pickCountry: string; search: string; noMatch: string }
|
|
26
|
+
/** The national number, ASCII digits only, without the dialling code. */
|
|
27
|
+
value: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_LABELS = {
|
|
31
|
+
noMatch: 'No country matches',
|
|
32
|
+
pickCountry: 'Country code',
|
|
33
|
+
search: 'Search countries',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Phone number entry: a country picker welded to a number input.
|
|
38
|
+
*
|
|
39
|
+
* The two halves are separate props on purpose. A field that owns one E.164
|
|
40
|
+
* string has to re-parse it on every keystroke to know which flag to draw, and
|
|
41
|
+
* gets it wrong the moment someone types a `+` themselves; keeping the country
|
|
42
|
+
* and the national digits apart means `+${dial}${value}` is the whole
|
|
43
|
+
* serialisation and there is nothing to guess.
|
|
44
|
+
*
|
|
45
|
+
* The picker lists every ISO country, named in the viewer's locale, searchable
|
|
46
|
+
* by name, code, or ISO letters. The input is `dir="ltr"` in every direction —
|
|
47
|
+
* a phone number reads left to right in Persian too — and Persian digits are
|
|
48
|
+
* normalised to ASCII on the way in.
|
|
49
|
+
*/
|
|
50
|
+
export function PhoneField({
|
|
51
|
+
className,
|
|
52
|
+
country,
|
|
53
|
+
error,
|
|
54
|
+
hint,
|
|
55
|
+
id,
|
|
56
|
+
label,
|
|
57
|
+
labels,
|
|
58
|
+
locale,
|
|
59
|
+
onChange,
|
|
60
|
+
onCountryChange,
|
|
61
|
+
onSubmit,
|
|
62
|
+
placeholder,
|
|
63
|
+
priority = [],
|
|
64
|
+
required,
|
|
65
|
+
value,
|
|
66
|
+
}: PhoneFieldProps) {
|
|
67
|
+
const generated = useId()
|
|
68
|
+
const controlId = id ?? `nim-${generated}`
|
|
69
|
+
const hintId = hint ? `${controlId}-hint` : undefined
|
|
70
|
+
const errorId = error ? `${controlId}-error` : undefined
|
|
71
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
72
|
+
|
|
73
|
+
const [open, setOpen] = useState(false)
|
|
74
|
+
const [query, setQuery] = useState('')
|
|
75
|
+
const shell = useRef<HTMLDivElement>(null)
|
|
76
|
+
const trigger = useRef<HTMLButtonElement>(null)
|
|
77
|
+
const search = useRef<HTMLInputElement>(null)
|
|
78
|
+
|
|
79
|
+
const resolvedLocale =
|
|
80
|
+
locale ?? (typeof document === 'undefined' ? 'en' : document.documentElement.lang || 'en')
|
|
81
|
+
const nameOf = useMemo(() => countryNamer(resolvedLocale), [resolvedLocale])
|
|
82
|
+
const selected = countryByIso2(country) ?? COUNTRIES[0]
|
|
83
|
+
|
|
84
|
+
const options = useMemo(() => {
|
|
85
|
+
const collator = new Intl.Collator(resolvedLocale)
|
|
86
|
+
const named = COUNTRIES.map((item) => ({ ...item, name: nameOf(item.iso2) }))
|
|
87
|
+
const rank = (iso2: string) => {
|
|
88
|
+
const index = priority.indexOf(iso2)
|
|
89
|
+
return index === -1 ? priority.length : index
|
|
90
|
+
}
|
|
91
|
+
return named.sort(
|
|
92
|
+
(a, b) => rank(a.iso2) - rank(b.iso2) || collator.compare(a.name, b.name),
|
|
93
|
+
)
|
|
94
|
+
}, [nameOf, priority, resolvedLocale])
|
|
95
|
+
|
|
96
|
+
const matches = useMemo(() => {
|
|
97
|
+
const needle = query.trim().toLocaleLowerCase(resolvedLocale)
|
|
98
|
+
if (!needle) return options
|
|
99
|
+
const digits = toAsciiDigits(needle)
|
|
100
|
+
return options.filter(
|
|
101
|
+
(item) =>
|
|
102
|
+
item.name.toLocaleLowerCase(resolvedLocale).includes(needle) ||
|
|
103
|
+
item.iso2.toLowerCase().includes(needle) ||
|
|
104
|
+
(digits ? item.dial.startsWith(digits) : false),
|
|
105
|
+
)
|
|
106
|
+
}, [options, query, resolvedLocale])
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!open) return
|
|
110
|
+
search.current?.focus()
|
|
111
|
+
const dismiss = (event: MouseEvent) => {
|
|
112
|
+
if (!shell.current?.contains(event.target as Node)) setOpen(false)
|
|
113
|
+
}
|
|
114
|
+
const escape = (event: globalThis.KeyboardEvent) => {
|
|
115
|
+
if (event.key !== 'Escape') return
|
|
116
|
+
setOpen(false)
|
|
117
|
+
trigger.current?.focus()
|
|
118
|
+
}
|
|
119
|
+
document.addEventListener('mousedown', dismiss)
|
|
120
|
+
document.addEventListener('keydown', escape)
|
|
121
|
+
return () => {
|
|
122
|
+
document.removeEventListener('mousedown', dismiss)
|
|
123
|
+
document.removeEventListener('keydown', escape)
|
|
124
|
+
}
|
|
125
|
+
}, [open])
|
|
126
|
+
|
|
127
|
+
const choose = (iso2: string) => {
|
|
128
|
+
onCountryChange(iso2)
|
|
129
|
+
setOpen(false)
|
|
130
|
+
setQuery('')
|
|
131
|
+
trigger.current?.focus()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className={cn('nim-field', error && 'nim-field--invalid', className)}>
|
|
136
|
+
{label ? (
|
|
137
|
+
<label className="nim-field__label" htmlFor={controlId}>
|
|
138
|
+
{label}
|
|
139
|
+
{required ? (
|
|
140
|
+
<span aria-hidden="true" className="nim-field__required">
|
|
141
|
+
*
|
|
142
|
+
</span>
|
|
143
|
+
) : null}
|
|
144
|
+
</label>
|
|
145
|
+
) : null}
|
|
146
|
+
|
|
147
|
+
<div className="nim-phone" ref={shell}>
|
|
148
|
+
<div className="nim-phone__shell" dir="ltr">
|
|
149
|
+
<button
|
|
150
|
+
aria-expanded={open}
|
|
151
|
+
aria-haspopup="listbox"
|
|
152
|
+
aria-label={`${text.pickCountry}: ${nameOf(selected.iso2)} +${selected.dial}`}
|
|
153
|
+
className="nim-phone__country"
|
|
154
|
+
onClick={() => setOpen((was) => !was)}
|
|
155
|
+
ref={trigger}
|
|
156
|
+
type="button"
|
|
157
|
+
>
|
|
158
|
+
<span aria-hidden="true" className="nim-phone__flag">
|
|
159
|
+
{selected.flag}
|
|
160
|
+
</span>
|
|
161
|
+
<span className="nim-phone__dial">+{selected.dial}</span>
|
|
162
|
+
<Icon className="nim-phone__caret" name="chevron-down" size="xs" />
|
|
163
|
+
</button>
|
|
164
|
+
|
|
165
|
+
<input
|
|
166
|
+
aria-describedby={[errorId, hintId].filter(Boolean).join(' ') || undefined}
|
|
167
|
+
aria-invalid={error ? true : undefined}
|
|
168
|
+
autoComplete="tel-national"
|
|
169
|
+
className="nim-phone__input"
|
|
170
|
+
enterKeyHint="go"
|
|
171
|
+
id={controlId}
|
|
172
|
+
inputMode="tel"
|
|
173
|
+
onChange={(event) => onChange(toAsciiDigits(event.target.value))}
|
|
174
|
+
onKeyDown={(event) => {
|
|
175
|
+
if (event.key === 'Enter') onSubmit?.()
|
|
176
|
+
}}
|
|
177
|
+
placeholder={placeholder}
|
|
178
|
+
required={required}
|
|
179
|
+
type="tel"
|
|
180
|
+
value={value}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{open ? (
|
|
185
|
+
<div className="nim-phone__picker">
|
|
186
|
+
<div className="nim-phone__search">
|
|
187
|
+
<Icon name="search" size="sm" />
|
|
188
|
+
<input
|
|
189
|
+
aria-label={text.search}
|
|
190
|
+
className="nim-phone__search-input"
|
|
191
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
192
|
+
placeholder={text.search}
|
|
193
|
+
ref={search}
|
|
194
|
+
type="search"
|
|
195
|
+
value={query}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
<ul className="nim-phone__list" role="listbox">
|
|
199
|
+
{matches.map((item) => (
|
|
200
|
+
<li key={item.iso2}>
|
|
201
|
+
<button
|
|
202
|
+
aria-selected={item.iso2 === selected.iso2}
|
|
203
|
+
className="nim-phone__option"
|
|
204
|
+
onClick={() => choose(item.iso2)}
|
|
205
|
+
role="option"
|
|
206
|
+
type="button"
|
|
207
|
+
>
|
|
208
|
+
<span aria-hidden="true" className="nim-phone__flag">
|
|
209
|
+
{item.flag}
|
|
210
|
+
</span>
|
|
211
|
+
<span className="nim-phone__name">{item.name}</span>
|
|
212
|
+
<span className="nim-phone__option-dial" dir="ltr">
|
|
213
|
+
+{item.dial}
|
|
214
|
+
</span>
|
|
215
|
+
</button>
|
|
216
|
+
</li>
|
|
217
|
+
))}
|
|
218
|
+
{matches.length === 0 ? <li className="nim-phone__empty">{text.noMatch}</li> : null}
|
|
219
|
+
</ul>
|
|
220
|
+
</div>
|
|
221
|
+
) : null}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{error ? (
|
|
225
|
+
<p className="nim-field__error" id={errorId}>
|
|
226
|
+
{error}
|
|
227
|
+
</p>
|
|
228
|
+
) : null}
|
|
229
|
+
{hint && !error ? (
|
|
230
|
+
<p className="nim-field__hint" id={hintId}>
|
|
231
|
+
{hint}
|
|
232
|
+
</p>
|
|
233
|
+
) : null}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `+${dial}${national}` — what an auth API actually wants. */
|
|
239
|
+
export function toE164(country: string, national: string): string {
|
|
240
|
+
const dial = countryByIso2(country)?.dial ?? ''
|
|
241
|
+
return `+${dial}${toAsciiDigits(national).replace(/^0+/, '')}`
|
|
242
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface PlanFeature {
|
|
6
|
+
label: ReactNode
|
|
7
|
+
/** `included` is shipped, `pending` is bought but not released yet, and
|
|
8
|
+
`excluded` is what this plan does not get. All three are worth showing:
|
|
9
|
+
a plan card that lists only wins tells the viewer nothing to choose on. */
|
|
10
|
+
state?: 'excluded' | 'included' | 'pending'
|
|
11
|
+
/** A short status word beside a pending row — "soon", "beta". */
|
|
12
|
+
note?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PlanCardProps {
|
|
16
|
+
/** The one plan being recommended. At most one card in a set. */
|
|
17
|
+
badge?: string
|
|
18
|
+
className?: string
|
|
19
|
+
features?: PlanFeature[]
|
|
20
|
+
icon?: IconName
|
|
21
|
+
/** The headline figure, already formatted and localised by the caller. */
|
|
22
|
+
price: ReactNode
|
|
23
|
+
/** What the price buys — "per month", "6 months". */
|
|
24
|
+
priceCaption?: ReactNode
|
|
25
|
+
/** The comparable unit price, so plans of different lengths can be read
|
|
26
|
+
against each other. */
|
|
27
|
+
secondary?: { caption: ReactNode; value: ReactNode }
|
|
28
|
+
name: ReactNode
|
|
29
|
+
onSelect?: () => void
|
|
30
|
+
selected?: boolean
|
|
31
|
+
tagline?: ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STATE_ICON: Record<NonNullable<PlanFeature['state']>, IconName> = {
|
|
35
|
+
excluded: 'minus',
|
|
36
|
+
included: 'check',
|
|
37
|
+
pending: 'clock',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One subscription tier, as a card the viewer chooses.
|
|
42
|
+
*
|
|
43
|
+
* The whole card is the control — a radio hidden behind a tap target the size
|
|
44
|
+
* of a fingernail is the reason plan pickers feel fussy — so it renders as a
|
|
45
|
+
* `<button>` with `aria-pressed` when selectable, and as a plain plate when it
|
|
46
|
+
* is only being displayed.
|
|
47
|
+
*
|
|
48
|
+
* Prices are `ReactNode` rather than numbers: currency, digit shaping and
|
|
49
|
+
* grouping are the product's locale decisions, and a kit that formats them
|
|
50
|
+
* would be wrong in Persian first.
|
|
51
|
+
*/
|
|
52
|
+
export function PlanCard({
|
|
53
|
+
badge,
|
|
54
|
+
className,
|
|
55
|
+
features = [],
|
|
56
|
+
icon,
|
|
57
|
+
name,
|
|
58
|
+
onSelect,
|
|
59
|
+
price,
|
|
60
|
+
priceCaption,
|
|
61
|
+
secondary,
|
|
62
|
+
selected = false,
|
|
63
|
+
tagline,
|
|
64
|
+
}: PlanCardProps) {
|
|
65
|
+
const body = (
|
|
66
|
+
<>
|
|
67
|
+
<div className="nim-plan__top">
|
|
68
|
+
{icon ? (
|
|
69
|
+
<span className="nim-plan__icon">
|
|
70
|
+
<Icon name={icon} size="md" />
|
|
71
|
+
</span>
|
|
72
|
+
) : null}
|
|
73
|
+
<div className="nim-plan__heading">
|
|
74
|
+
<span className="nim-plan__name">{name}</span>
|
|
75
|
+
{tagline ? <span className="nim-plan__tagline">{tagline}</span> : null}
|
|
76
|
+
</div>
|
|
77
|
+
{badge ? <span className="nim-plan__badge">{badge}</span> : null}
|
|
78
|
+
{onSelect ? (
|
|
79
|
+
<span aria-hidden="true" className="nim-plan__radio">
|
|
80
|
+
{selected ? <Icon name="check" size="xs" /> : null}
|
|
81
|
+
</span>
|
|
82
|
+
) : null}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div className="nim-plan__price-box">
|
|
86
|
+
<div>
|
|
87
|
+
{priceCaption ? <span className="nim-plan__price-caption">{priceCaption}</span> : null}
|
|
88
|
+
<strong className="nim-plan__price">{price}</strong>
|
|
89
|
+
</div>
|
|
90
|
+
{secondary ? (
|
|
91
|
+
<div className="nim-plan__secondary">
|
|
92
|
+
<span className="nim-plan__price-caption">{secondary.caption}</span>
|
|
93
|
+
<strong className="nim-plan__secondary-value">{secondary.value}</strong>
|
|
94
|
+
</div>
|
|
95
|
+
) : null}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{features.length ? (
|
|
99
|
+
<ul className="nim-plan__features">
|
|
100
|
+
{features.map((feature, index) => {
|
|
101
|
+
const state = feature.state ?? 'included'
|
|
102
|
+
return (
|
|
103
|
+
<li className="nim-plan__feature" data-state={state} key={index}>
|
|
104
|
+
<Icon name={STATE_ICON[state]} size="xs" />
|
|
105
|
+
<span className="nim-plan__feature-label">{feature.label}</span>
|
|
106
|
+
{feature.note ? <span className="nim-plan__feature-note">{feature.note}</span> : null}
|
|
107
|
+
</li>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</ul>
|
|
111
|
+
) : null}
|
|
112
|
+
</>
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const classes = cn('nim-plan', selected && 'nim-plan--selected', className)
|
|
116
|
+
|
|
117
|
+
return onSelect ? (
|
|
118
|
+
<button aria-pressed={selected} className={classes} onClick={onSelect} type="button">
|
|
119
|
+
{body}
|
|
120
|
+
</button>
|
|
121
|
+
) : (
|
|
122
|
+
<article className={classes}>{body}</article>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Button } from '@/components/button'
|
|
4
|
+
import { PlanCard, type PlanCardProps } from '@/components/plan-card'
|
|
5
|
+
import { Segmented } from '@/components/segmented'
|
|
6
|
+
import { cn } from '@/lib/cn'
|
|
7
|
+
|
|
8
|
+
export interface BillingCycle {
|
|
9
|
+
id: string
|
|
10
|
+
label: string
|
|
11
|
+
/** "Save 15%" — the reason to pick the longer commitment. */
|
|
12
|
+
note?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PlanOffer
|
|
16
|
+
extends Omit<PlanCardProps, 'onSelect' | 'price' | 'secondary' | 'selected'> {
|
|
17
|
+
id: string
|
|
18
|
+
/** Price per billing cycle, keyed by cycle id. Already formatted: currency
|
|
19
|
+
and digit shaping are the product's locale decision, not the kit's. */
|
|
20
|
+
prices: Record<string, { monthly?: ReactNode; price: ReactNode }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PlanPickerProps {
|
|
24
|
+
className?: string
|
|
25
|
+
/** Billing periods. One cycle, or none at all, hides the switch. */
|
|
26
|
+
cycles?: BillingCycle[]
|
|
27
|
+
cycle?: string
|
|
28
|
+
defaultCycle?: string
|
|
29
|
+
defaultPlan?: string
|
|
30
|
+
labels?: { cycle: string; monthly: string; price: string }
|
|
31
|
+
/** Fine print under the action — renewal terms, store rules. */
|
|
32
|
+
note?: ReactNode
|
|
33
|
+
onCycleChange?: (cycle: string) => void
|
|
34
|
+
onPlanChange?: (plan: string) => void
|
|
35
|
+
/** Fires with the chosen plan and cycle. Payment is the app's business. */
|
|
36
|
+
onSubmit?: (plan: string, cycle: string) => void
|
|
37
|
+
plans: PlanOffer[]
|
|
38
|
+
plan?: string
|
|
39
|
+
submitLabel?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_LABELS = {
|
|
43
|
+
cycle: 'Billing period',
|
|
44
|
+
monthly: 'Per month',
|
|
45
|
+
price: 'This package',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The subscription screen: billing period, the tiers, and one action.
|
|
50
|
+
*
|
|
51
|
+
* Ready to mount — it holds the selection, keeps the cycle and the prices in
|
|
52
|
+
* step, and hands `onSubmit` the pair the checkout needs. Pass `plan`/`cycle`
|
|
53
|
+
* to drive it from outside instead, e.g. when a deep link opens the screen on
|
|
54
|
+
* a specific tier.
|
|
55
|
+
*
|
|
56
|
+
* It deliberately does not take a payment handler, a store SDK, or a currency:
|
|
57
|
+
* a plan picker that also knows how to charge is two screens welded together,
|
|
58
|
+
* and only one of them is the same across products.
|
|
59
|
+
*/
|
|
60
|
+
export function PlanPicker({
|
|
61
|
+
className,
|
|
62
|
+
cycle,
|
|
63
|
+
cycles = [],
|
|
64
|
+
defaultCycle,
|
|
65
|
+
defaultPlan,
|
|
66
|
+
labels,
|
|
67
|
+
note,
|
|
68
|
+
onCycleChange,
|
|
69
|
+
onPlanChange,
|
|
70
|
+
onSubmit,
|
|
71
|
+
plan,
|
|
72
|
+
plans,
|
|
73
|
+
submitLabel,
|
|
74
|
+
}: PlanPickerProps) {
|
|
75
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
76
|
+
const [ownCycle, setOwnCycle] = useState(defaultCycle ?? cycles[0]?.id ?? '')
|
|
77
|
+
const [ownPlan, setOwnPlan] = useState(defaultPlan ?? plans[0]?.id ?? '')
|
|
78
|
+
const activeCycle = cycle ?? ownCycle
|
|
79
|
+
const activePlan = plan ?? ownPlan
|
|
80
|
+
|
|
81
|
+
const choose = (next: string) => {
|
|
82
|
+
setOwnPlan(next)
|
|
83
|
+
onPlanChange?.(next)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const switchCycle = (next: string) => {
|
|
87
|
+
setOwnCycle(next)
|
|
88
|
+
onCycleChange?.(next)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const current = cycles.find((item) => item.id === activeCycle)
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<section className={cn('nim-plan-picker', className)}>
|
|
95
|
+
{cycles.length > 1 ? (
|
|
96
|
+
<div className="nim-plan-picker__cycles">
|
|
97
|
+
<Segmented
|
|
98
|
+
fullWidth
|
|
99
|
+
label={text.cycle}
|
|
100
|
+
onChange={switchCycle}
|
|
101
|
+
options={cycles.map((item) => ({ label: item.label, value: item.id }))}
|
|
102
|
+
value={activeCycle}
|
|
103
|
+
/>
|
|
104
|
+
{current?.note ? <p className="nim-plan-picker__save">{current.note}</p> : null}
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
<div className="nim-plan-picker__plans">
|
|
109
|
+
{plans.map(({ id, prices, ...card }) => {
|
|
110
|
+
const price = prices[activeCycle] ?? Object.values(prices)[0]
|
|
111
|
+
return (
|
|
112
|
+
<PlanCard
|
|
113
|
+
{...card}
|
|
114
|
+
key={id}
|
|
115
|
+
onSelect={() => choose(id)}
|
|
116
|
+
price={price?.price ?? ''}
|
|
117
|
+
priceCaption={text.price}
|
|
118
|
+
secondary={
|
|
119
|
+
price?.monthly === undefined
|
|
120
|
+
? undefined
|
|
121
|
+
: { caption: text.monthly, value: price.monthly }
|
|
122
|
+
}
|
|
123
|
+
selected={id === activePlan}
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{submitLabel ? (
|
|
130
|
+
<div className="nim-plan-picker__foot">
|
|
131
|
+
<Button
|
|
132
|
+
fullWidth
|
|
133
|
+
onClick={() => onSubmit?.(activePlan, activeCycle)}
|
|
134
|
+
size="lg"
|
|
135
|
+
variant="accent"
|
|
136
|
+
>
|
|
137
|
+
{submitLabel}
|
|
138
|
+
</Button>
|
|
139
|
+
{note ? <p className="nim-plan-picker__note">{note}</p> : null}
|
|
140
|
+
</div>
|
|
141
|
+
) : null}
|
|
142
|
+
</section>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export interface AvatarRingProps {
|
|
5
|
+
className?: string
|
|
6
|
+
/** What the ring is measuring, for assistive tech. */
|
|
7
|
+
label: string
|
|
8
|
+
/** Initials, or a small caption under them. */
|
|
9
|
+
caption?: ReactNode
|
|
10
|
+
/** Image URL. Falls back to `initials` when absent or broken. */
|
|
11
|
+
src?: string
|
|
12
|
+
initials: string
|
|
13
|
+
size?: number
|
|
14
|
+
/** 0–100. The ring is the progress; the number belongs in the caption. */
|
|
15
|
+
value: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An avatar wearing a progress ring: profile completion, a skin score, a
|
|
20
|
+
* streak — one figure the viewer is meant to want to finish.
|
|
21
|
+
*
|
|
22
|
+
* The ring is an SVG arc rather than a conic gradient so it keeps a rounded
|
|
23
|
+
* cap and stays crisp at any size, and it carries `role="img"` with the label,
|
|
24
|
+
* because the arc is the only place the value is stated for a sighted viewer.
|
|
25
|
+
*/
|
|
26
|
+
export function AvatarRing({
|
|
27
|
+
caption,
|
|
28
|
+
className,
|
|
29
|
+
initials,
|
|
30
|
+
label,
|
|
31
|
+
size = 96,
|
|
32
|
+
src,
|
|
33
|
+
value,
|
|
34
|
+
}: AvatarRingProps) {
|
|
35
|
+
const stroke = Math.max(4, Math.round(size * 0.05))
|
|
36
|
+
const radius = (size - stroke) / 2
|
|
37
|
+
const circumference = 2 * Math.PI * radius
|
|
38
|
+
const filled = (Math.min(100, Math.max(0, value)) / 100) * circumference
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
aria-label={label}
|
|
43
|
+
className={cn('nim-avatar-ring', className)}
|
|
44
|
+
role="img"
|
|
45
|
+
style={{ '--nim-ring-size': `${size}px`, '--nim-ring-stroke': `${stroke}px` } as CSSProperties}
|
|
46
|
+
>
|
|
47
|
+
<svg aria-hidden="true" className="nim-avatar-ring__arc" viewBox={`0 0 ${size} ${size}`}>
|
|
48
|
+
<circle
|
|
49
|
+
className="nim-avatar-ring__track"
|
|
50
|
+
cx={size / 2}
|
|
51
|
+
cy={size / 2}
|
|
52
|
+
fill="none"
|
|
53
|
+
r={radius}
|
|
54
|
+
strokeWidth={stroke}
|
|
55
|
+
/>
|
|
56
|
+
<circle
|
|
57
|
+
className="nim-avatar-ring__fill"
|
|
58
|
+
cx={size / 2}
|
|
59
|
+
cy={size / 2}
|
|
60
|
+
fill="none"
|
|
61
|
+
r={radius}
|
|
62
|
+
strokeDasharray={`${filled} ${circumference}`}
|
|
63
|
+
strokeLinecap="round"
|
|
64
|
+
strokeWidth={stroke}
|
|
65
|
+
/>
|
|
66
|
+
</svg>
|
|
67
|
+
<span className="nim-avatar-ring__face">
|
|
68
|
+
{src ? (
|
|
69
|
+
<img alt="" className="nim-avatar-ring__image" src={src} />
|
|
70
|
+
) : (
|
|
71
|
+
<span className="nim-avatar-ring__initials">{initials}</span>
|
|
72
|
+
)}
|
|
73
|
+
{caption && !src ? <span className="nim-avatar-ring__caption">{caption}</span> : null}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ProfileHeaderProps {
|
|
80
|
+
/** The row of quick figures under the identity block. */
|
|
81
|
+
stats?: { label: ReactNode; value: ReactNode }[]
|
|
82
|
+
/** Small pills: plan, skin type, billing cycle. */
|
|
83
|
+
chips?: ReactNode
|
|
84
|
+
actions?: ReactNode
|
|
85
|
+
avatar: ReactNode
|
|
86
|
+
className?: string
|
|
87
|
+
/** The line above the name — "vlora account", the tenant, the role. */
|
|
88
|
+
eyebrow?: ReactNode
|
|
89
|
+
name: ReactNode
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The plate at the top of a profile: who this is, what they have, and the two
|
|
94
|
+
* things they should do next.
|
|
95
|
+
*
|
|
96
|
+
* It is a header, not a dashboard. Anything that needs a chart or a list goes
|
|
97
|
+
* in the sections beneath it — this block stays readable at a glance, which is
|
|
98
|
+
* the only reason it earns the space it takes.
|
|
99
|
+
*/
|
|
100
|
+
export function ProfileHeader({
|
|
101
|
+
actions,
|
|
102
|
+
avatar,
|
|
103
|
+
chips,
|
|
104
|
+
className,
|
|
105
|
+
eyebrow,
|
|
106
|
+
name,
|
|
107
|
+
stats = [],
|
|
108
|
+
}: ProfileHeaderProps) {
|
|
109
|
+
return (
|
|
110
|
+
<section className={cn('nim-profile-header', className)}>
|
|
111
|
+
<div className="nim-profile-header__identity">
|
|
112
|
+
{avatar}
|
|
113
|
+
<div className="nim-profile-header__who">
|
|
114
|
+
{eyebrow ? <p className="nim-profile-header__eyebrow">{eyebrow}</p> : null}
|
|
115
|
+
<h1 className="nim-profile-header__name">{name}</h1>
|
|
116
|
+
{chips ? <div className="nim-profile-header__chips">{chips}</div> : null}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{stats.length ? (
|
|
121
|
+
<dl className="nim-profile-header__stats">
|
|
122
|
+
{stats.map((stat, index) => (
|
|
123
|
+
<div className="nim-profile-header__stat" key={index}>
|
|
124
|
+
<dt className="nim-profile-header__stat-label">{stat.label}</dt>
|
|
125
|
+
<dd className="nim-profile-header__stat-value">{stat.value}</dd>
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</dl>
|
|
129
|
+
) : null}
|
|
130
|
+
|
|
131
|
+
{actions ? <div className="nim-profile-header__actions">{actions}</div> : null}
|
|
132
|
+
</section>
|
|
133
|
+
)
|
|
134
|
+
}
|