@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,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
+ }