@spree/dashboard-ui 0.13.1 → 1.0.0-beta.1
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/README.md +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- package/src/ui/sonner.tsx +0 -47
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Controller, type UseFormSetError, useForm } from 'react-hook-form'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import {
|
|
5
|
+
Badge,
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
CardAction,
|
|
9
|
+
CardContent,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
Combobox,
|
|
13
|
+
ComboboxContent,
|
|
14
|
+
ComboboxEmpty,
|
|
15
|
+
ComboboxInput,
|
|
16
|
+
ComboboxItem,
|
|
17
|
+
ComboboxList,
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuItem,
|
|
21
|
+
DropdownMenuTrigger,
|
|
22
|
+
Field,
|
|
23
|
+
FieldDescription,
|
|
24
|
+
FieldError,
|
|
25
|
+
FieldGroup,
|
|
26
|
+
FieldLabel,
|
|
27
|
+
Input,
|
|
28
|
+
RelativeTime,
|
|
29
|
+
Sheet,
|
|
30
|
+
SheetContent,
|
|
31
|
+
SheetDescription,
|
|
32
|
+
SheetFooter,
|
|
33
|
+
SheetHeader,
|
|
34
|
+
SheetTitle,
|
|
35
|
+
StatusBadge,
|
|
36
|
+
useConfirm,
|
|
37
|
+
} from '../index'
|
|
38
|
+
import { BadgeCheckIcon, EllipsisVerticalIcon, PencilIcon, PlusIcon, TrashIcon } from './icons'
|
|
39
|
+
|
|
40
|
+
/** One selectable registration kind. */
|
|
41
|
+
interface KindOption {
|
|
42
|
+
value: string
|
|
43
|
+
label: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A registration as the API returns it. Declared here rather than imported
|
|
48
|
+
* from an SDK: this package is headless, and the admin and seller SDKs
|
|
49
|
+
* describe the same row.
|
|
50
|
+
*/
|
|
51
|
+
export interface TaxIdentifier {
|
|
52
|
+
id: string
|
|
53
|
+
kind: string
|
|
54
|
+
value: string
|
|
55
|
+
validation_status?: string | null
|
|
56
|
+
validated_at?: string | null
|
|
57
|
+
validatable?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TaxIdentifierParams {
|
|
61
|
+
kind: string
|
|
62
|
+
value: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface TaxIdentifierFormValues {
|
|
66
|
+
kind: string
|
|
67
|
+
value: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const TAX_IDENTIFIER_DEFAULTS: TaxIdentifierFormValues = { kind: '', value: '' }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The registration kinds Spree ships strings for. Any string is accepted by
|
|
74
|
+
* the API — what a kind means is decided by whichever validator is registered
|
|
75
|
+
* for it — so the merchant can type their own.
|
|
76
|
+
*/
|
|
77
|
+
export const TAX_IDENTIFIER_KINDS = ['eu_vat', 'gb_vat', 'ch_vat', 'au_abn', 'us_ein'] as const
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mutations differ by owner (a company's registrations hang off the company,
|
|
81
|
+
* a customer's off the customer) while the panel itself does not — so the
|
|
82
|
+
* owner supplies them and this stays one component.
|
|
83
|
+
*/
|
|
84
|
+
export interface TaxIdentifierMutations {
|
|
85
|
+
create: (params: TaxIdentifierParams) => Promise<unknown>
|
|
86
|
+
update: (id: string, params: TaxIdentifierParams) => Promise<unknown>
|
|
87
|
+
remove: (id: string) => Promise<unknown>
|
|
88
|
+
validate: (id: string) => Promise<unknown>
|
|
89
|
+
isValidating?: boolean
|
|
90
|
+
/**
|
|
91
|
+
* Routes a rejected save onto the form. Supplied by the host because it is
|
|
92
|
+
* the host's SDK that defines what a validation error looks like; returning
|
|
93
|
+
* false means "not mine", and the error is rethrown.
|
|
94
|
+
*/
|
|
95
|
+
mapErrors: (error: unknown, setError: UseFormSetError<TaxIdentifierFormValues>) => boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The buyer's VAT or business number. What makes EU B2B reverse charge
|
|
100
|
+
* possible, so the verdict matters as much as the number: a row nothing can
|
|
101
|
+
* check reads as such rather than as merely unverified.
|
|
102
|
+
*/
|
|
103
|
+
export function TaxIdentifiersCard({
|
|
104
|
+
identifiers,
|
|
105
|
+
isLoading,
|
|
106
|
+
mutations,
|
|
107
|
+
canEdit,
|
|
108
|
+
}: {
|
|
109
|
+
identifiers: TaxIdentifier[]
|
|
110
|
+
isLoading?: boolean
|
|
111
|
+
mutations: TaxIdentifierMutations
|
|
112
|
+
canEdit: boolean
|
|
113
|
+
}) {
|
|
114
|
+
const { t } = useTranslation()
|
|
115
|
+
const confirm = useConfirm()
|
|
116
|
+
const [addOpen, setAddOpen] = useState(false)
|
|
117
|
+
const [editing, setEditing] = useState<TaxIdentifier | null>(null)
|
|
118
|
+
|
|
119
|
+
async function handleDelete(identifier: TaxIdentifier) {
|
|
120
|
+
const ok = await confirm({
|
|
121
|
+
title: t('admin.tax_identifiers.delete_confirm.title'),
|
|
122
|
+
message: t('admin.tax_identifiers.delete_confirm.message', { value: identifier.value }),
|
|
123
|
+
variant: 'destructive',
|
|
124
|
+
confirmLabel: t('admin.actions.delete'),
|
|
125
|
+
})
|
|
126
|
+
if (!ok) return
|
|
127
|
+
await mutations.remove(identifier.id).catch(() => undefined)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Card>
|
|
132
|
+
<CardHeader>
|
|
133
|
+
<CardTitle>
|
|
134
|
+
{t('admin.tax_identifiers.title')}
|
|
135
|
+
{identifiers.length > 0 && <Badge variant="outline">{identifiers.length}</Badge>}
|
|
136
|
+
</CardTitle>
|
|
137
|
+
{canEdit && (
|
|
138
|
+
<CardAction>
|
|
139
|
+
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
|
140
|
+
<PlusIcon className="size-4" />
|
|
141
|
+
{t('admin.tax_identifiers.add_cta')}
|
|
142
|
+
</Button>
|
|
143
|
+
</CardAction>
|
|
144
|
+
)}
|
|
145
|
+
</CardHeader>
|
|
146
|
+
|
|
147
|
+
{isLoading ? (
|
|
148
|
+
<CardContent>
|
|
149
|
+
<p className="text-muted-foreground text-sm">{t('admin.common.loading')}</p>
|
|
150
|
+
</CardContent>
|
|
151
|
+
) : identifiers.length === 0 ? (
|
|
152
|
+
<CardContent>
|
|
153
|
+
<p className="text-muted-foreground text-sm">{t('admin.tax_identifiers.empty')}</p>
|
|
154
|
+
</CardContent>
|
|
155
|
+
) : (
|
|
156
|
+
<CardContent className="flex flex-col gap-3">
|
|
157
|
+
{identifiers.map((identifier) => (
|
|
158
|
+
<div
|
|
159
|
+
key={identifier.id}
|
|
160
|
+
className="flex items-start justify-between gap-3 rounded-md border p-3"
|
|
161
|
+
>
|
|
162
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
163
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
164
|
+
<span className="font-medium text-sm">{identifier.value}</span>
|
|
165
|
+
<Badge variant="outline">
|
|
166
|
+
{t(`admin.tax_identifiers.kinds.${identifier.kind}`, {
|
|
167
|
+
defaultValue: identifier.kind,
|
|
168
|
+
})}
|
|
169
|
+
</Badge>
|
|
170
|
+
{identifier.validation_status ? (
|
|
171
|
+
<StatusBadge
|
|
172
|
+
status={identifier.validation_status}
|
|
173
|
+
label={t(
|
|
174
|
+
`admin.tax_identifiers.validation_status.${identifier.validation_status}`,
|
|
175
|
+
{ defaultValue: identifier.validation_status },
|
|
176
|
+
)}
|
|
177
|
+
/>
|
|
178
|
+
) : (
|
|
179
|
+
<Badge variant="secondary">
|
|
180
|
+
{t('admin.tax_identifiers.validation_status.not_checked')}
|
|
181
|
+
</Badge>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
{identifier.validated_at && (
|
|
185
|
+
<span className="text-muted-foreground text-xs">
|
|
186
|
+
<RelativeTime
|
|
187
|
+
iso={identifier.validated_at}
|
|
188
|
+
prefix={t('admin.tax_identifiers.checked_prefix')}
|
|
189
|
+
/>
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
{!identifier.validatable && (
|
|
193
|
+
<span className="text-muted-foreground text-xs">
|
|
194
|
+
{t('admin.tax_identifiers.not_validatable')}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{canEdit && (
|
|
200
|
+
<DropdownMenu>
|
|
201
|
+
<DropdownMenuTrigger asChild>
|
|
202
|
+
<Button variant="ghost" size="icon-xs">
|
|
203
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
204
|
+
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
205
|
+
</Button>
|
|
206
|
+
</DropdownMenuTrigger>
|
|
207
|
+
<DropdownMenuContent align="end">
|
|
208
|
+
<DropdownMenuItem
|
|
209
|
+
disabled={!identifier.validatable || mutations.isValidating}
|
|
210
|
+
onClick={() => mutations.validate(identifier.id).catch(() => undefined)}
|
|
211
|
+
>
|
|
212
|
+
<BadgeCheckIcon className="size-4" />
|
|
213
|
+
{t('admin.tax_identifiers.validate_action')}
|
|
214
|
+
</DropdownMenuItem>
|
|
215
|
+
<DropdownMenuItem onClick={() => setEditing(identifier)}>
|
|
216
|
+
<PencilIcon className="size-4" />
|
|
217
|
+
{t('admin.actions.edit')}
|
|
218
|
+
</DropdownMenuItem>
|
|
219
|
+
<DropdownMenuItem
|
|
220
|
+
className="text-destructive focus:text-destructive"
|
|
221
|
+
onClick={() => handleDelete(identifier)}
|
|
222
|
+
>
|
|
223
|
+
<TrashIcon className="size-4" />
|
|
224
|
+
{t('admin.actions.delete')}
|
|
225
|
+
</DropdownMenuItem>
|
|
226
|
+
</DropdownMenuContent>
|
|
227
|
+
</DropdownMenu>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
))}
|
|
231
|
+
</CardContent>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{addOpen && (
|
|
235
|
+
<TaxIdentifierSheet
|
|
236
|
+
open
|
|
237
|
+
onOpenChange={setAddOpen}
|
|
238
|
+
mapErrors={mutations.mapErrors}
|
|
239
|
+
onSubmit={(params) => mutations.create(params)}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
{editing && (
|
|
243
|
+
<TaxIdentifierSheet
|
|
244
|
+
open
|
|
245
|
+
identifier={editing}
|
|
246
|
+
onOpenChange={(next) => {
|
|
247
|
+
if (!next) setEditing(null)
|
|
248
|
+
}}
|
|
249
|
+
mapErrors={mutations.mapErrors}
|
|
250
|
+
onSubmit={(params) => mutations.update(editing.id, params)}
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
</Card>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function TaxIdentifierSheet({
|
|
258
|
+
open,
|
|
259
|
+
onOpenChange,
|
|
260
|
+
identifier,
|
|
261
|
+
onSubmit,
|
|
262
|
+
mapErrors,
|
|
263
|
+
}: {
|
|
264
|
+
open: boolean
|
|
265
|
+
onOpenChange: (open: boolean) => void
|
|
266
|
+
identifier?: TaxIdentifier
|
|
267
|
+
onSubmit: (params: TaxIdentifierParams) => Promise<unknown>
|
|
268
|
+
mapErrors: (error: unknown, setError: UseFormSetError<TaxIdentifierFormValues>) => boolean
|
|
269
|
+
}) {
|
|
270
|
+
const { t } = useTranslation()
|
|
271
|
+
const form = useForm<TaxIdentifierFormValues>({ defaultValues: TAX_IDENTIFIER_DEFAULTS })
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (open) {
|
|
275
|
+
form.reset(
|
|
276
|
+
identifier ? { kind: identifier.kind, value: identifier.value } : TAX_IDENTIFIER_DEFAULTS,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}, [open, identifier, form])
|
|
280
|
+
|
|
281
|
+
async function handleSubmit(values: TaxIdentifierFormValues) {
|
|
282
|
+
try {
|
|
283
|
+
await onSubmit({ kind: values.kind, value: values.value })
|
|
284
|
+
onOpenChange(false)
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// The host maps its own SDK's errors: this package knows nothing about
|
|
287
|
+
// either SDK, and a thrown error it cannot read is the host's to report.
|
|
288
|
+
if (!mapErrors(err, form.setError)) throw err
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const { errors } = form.formState
|
|
293
|
+
// The mapper always seeds `root` with the server's summary, as a safety net
|
|
294
|
+
// for errors whose field is not on screen. Here both fields are, so showing
|
|
295
|
+
// it as well repeats the same sentence twice in one small form.
|
|
296
|
+
const rootIsRepeated = Boolean(errors.kind || errors.value)
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
300
|
+
<SheetContent>
|
|
301
|
+
<SheetHeader>
|
|
302
|
+
<SheetTitle>
|
|
303
|
+
{identifier
|
|
304
|
+
? t('admin.tax_identifiers.edit_title')
|
|
305
|
+
: t('admin.tax_identifiers.add_title')}
|
|
306
|
+
</SheetTitle>
|
|
307
|
+
<SheetDescription>{t('admin.tax_identifiers.dialog_description')}</SheetDescription>
|
|
308
|
+
</SheetHeader>
|
|
309
|
+
<form
|
|
310
|
+
onSubmit={(event) => {
|
|
311
|
+
// This sheet can be rendered inside another form; stopping the
|
|
312
|
+
// bubbled submit keeps the outer form from also submitting.
|
|
313
|
+
form.handleSubmit(handleSubmit)(event)
|
|
314
|
+
event.stopPropagation()
|
|
315
|
+
}}
|
|
316
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
317
|
+
>
|
|
318
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
319
|
+
<FieldGroup>
|
|
320
|
+
{errors.root?.message && !rootIsRepeated && (
|
|
321
|
+
<p className="text-destructive text-sm" role="alert">
|
|
322
|
+
{errors.root.message}
|
|
323
|
+
</p>
|
|
324
|
+
)}
|
|
325
|
+
<Field>
|
|
326
|
+
<FieldLabel htmlFor="tax-identifier-kind">
|
|
327
|
+
{t('admin.fields.tax_identifier.kind.label')}
|
|
328
|
+
</FieldLabel>
|
|
329
|
+
{/* The list seeds the picker rather than closing it: any string
|
|
330
|
+
is a valid kind, and only the validator registered for one
|
|
331
|
+
decides what it means. */}
|
|
332
|
+
<Controller
|
|
333
|
+
name="kind"
|
|
334
|
+
control={form.control}
|
|
335
|
+
rules={{ required: t('admin.fields.tax_identifier.kind.required') }}
|
|
336
|
+
render={({ field }) => {
|
|
337
|
+
// Combobox rather than Select: this card renders inside a
|
|
338
|
+
// page card, and Base UI's Select popup does not survive
|
|
339
|
+
// that portal nesting (the country picker below is a
|
|
340
|
+
// Combobox for the same reason).
|
|
341
|
+
const kindOptions = TAX_IDENTIFIER_KINDS.map((kind) => ({
|
|
342
|
+
value: kind,
|
|
343
|
+
label: t(`admin.tax_identifiers.kinds.${kind}`, { defaultValue: kind }),
|
|
344
|
+
}))
|
|
345
|
+
const selected = kindOptions.find((o) => o.value === field.value) ?? null
|
|
346
|
+
return (
|
|
347
|
+
<Combobox
|
|
348
|
+
items={kindOptions}
|
|
349
|
+
value={selected}
|
|
350
|
+
onValueChange={(option: KindOption | null) =>
|
|
351
|
+
field.onChange(option?.value ?? '')
|
|
352
|
+
}
|
|
353
|
+
// The list seeds the picker rather than closing it, so
|
|
354
|
+
// a kind matching no item has to be read from the input
|
|
355
|
+
// itself. Only genuine typing: picking an option also
|
|
356
|
+
// rewrites the input, to the label rather than the key.
|
|
357
|
+
onInputValueChange={(input: string, details: { reason?: string }) => {
|
|
358
|
+
if (details.reason === 'input-change') field.onChange(input)
|
|
359
|
+
}}
|
|
360
|
+
itemToStringLabel={(option: KindOption | null) => option?.label ?? ''}
|
|
361
|
+
itemToStringValue={(option: KindOption | null) => option?.value ?? ''}
|
|
362
|
+
>
|
|
363
|
+
<ComboboxInput
|
|
364
|
+
id="tax-identifier-kind"
|
|
365
|
+
placeholder={t('admin.tax_identifiers.select_kind')}
|
|
366
|
+
/>
|
|
367
|
+
<ComboboxContent>
|
|
368
|
+
<ComboboxEmpty>{t('admin.common.no_results')}</ComboboxEmpty>
|
|
369
|
+
<ComboboxList>
|
|
370
|
+
{(option: KindOption) => (
|
|
371
|
+
<ComboboxItem key={option.value} value={option}>
|
|
372
|
+
{option.label}
|
|
373
|
+
</ComboboxItem>
|
|
374
|
+
)}
|
|
375
|
+
</ComboboxList>
|
|
376
|
+
</ComboboxContent>
|
|
377
|
+
</Combobox>
|
|
378
|
+
)
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
<FieldDescription>{t('admin.fields.tax_identifier.kind.help')}</FieldDescription>
|
|
382
|
+
<FieldError errors={[errors.kind]} />
|
|
383
|
+
</Field>
|
|
384
|
+
|
|
385
|
+
<Field>
|
|
386
|
+
<FieldLabel htmlFor="tax-identifier-value">
|
|
387
|
+
{t('admin.fields.tax_identifier.value.label')}
|
|
388
|
+
</FieldLabel>
|
|
389
|
+
<Input
|
|
390
|
+
id="tax-identifier-value"
|
|
391
|
+
placeholder={t('admin.fields.tax_identifier.value.placeholder')}
|
|
392
|
+
aria-invalid={!!errors.value || undefined}
|
|
393
|
+
{...form.register('value', {
|
|
394
|
+
required: t('admin.fields.tax_identifier.value.required'),
|
|
395
|
+
})}
|
|
396
|
+
/>
|
|
397
|
+
<FieldError errors={[errors.value]} />
|
|
398
|
+
</Field>
|
|
399
|
+
</FieldGroup>
|
|
400
|
+
</div>
|
|
401
|
+
<SheetFooter>
|
|
402
|
+
<Button
|
|
403
|
+
type="button"
|
|
404
|
+
variant="outline"
|
|
405
|
+
onClick={() => onOpenChange(false)}
|
|
406
|
+
disabled={form.formState.isSubmitting}
|
|
407
|
+
>
|
|
408
|
+
{t('admin.actions.cancel')}
|
|
409
|
+
</Button>
|
|
410
|
+
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
411
|
+
{form.formState.isSubmitting ? t('admin.actions.saving') : t('admin.actions.save')}
|
|
412
|
+
</Button>
|
|
413
|
+
</SheetFooter>
|
|
414
|
+
</form>
|
|
415
|
+
</SheetContent>
|
|
416
|
+
</Sheet>
|
|
417
|
+
)
|
|
418
|
+
}
|
|
@@ -1,35 +1,48 @@
|
|
|
1
|
-
import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from 'lucide-react'
|
|
2
1
|
import { useTranslation } from 'react-i18next'
|
|
3
|
-
import {
|
|
2
|
+
import { cn } from '../lib/utils'
|
|
3
|
+
import { MonitorIcon, MoonStarIcon, SunIcon } from './icons'
|
|
4
4
|
import { useTheme } from './theme-provider'
|
|
5
5
|
|
|
6
|
+
const OPTIONS = [
|
|
7
|
+
{ value: 'system', icon: MonitorIcon, labelKey: 'admin.components.theme_toggle.system' },
|
|
8
|
+
{ value: 'light', icon: SunIcon, labelKey: 'admin.components.theme_toggle.light' },
|
|
9
|
+
{ value: 'dark', icon: MoonStarIcon, labelKey: 'admin.components.theme_toggle.dark' },
|
|
10
|
+
] as const
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
|
-
* Theme picker rendered inside the user dropdown
|
|
8
|
-
* /
|
|
13
|
+
* Theme picker rendered inside the user dropdown's Preferences section as a
|
|
14
|
+
* compact segmented control (System / Light / Dark). System follows
|
|
15
|
+
* `prefers-color-scheme`. The buttons are plain (not menu items) so activating
|
|
16
|
+
* one switches the theme without closing the dropdown.
|
|
9
17
|
*/
|
|
10
18
|
export function ThemeMenuItems() {
|
|
11
19
|
const { mode, setMode } = useTheme()
|
|
12
20
|
const { t } = useTranslation()
|
|
13
21
|
|
|
14
|
-
const items = [
|
|
15
|
-
{ value: 'light', label: t('admin.components.theme_toggle.light'), icon: SunIcon },
|
|
16
|
-
{ value: 'dark', label: t('admin.components.theme_toggle.dark'), icon: MoonIcon },
|
|
17
|
-
{ value: 'system', label: t('admin.components.theme_toggle.system'), icon: MonitorIcon },
|
|
18
|
-
] as const
|
|
19
|
-
|
|
20
22
|
return (
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
23
|
+
<div className="flex items-center justify-between gap-2 px-2.5 py-1.5">
|
|
24
|
+
<span className="text-sm text-foreground">{t('admin.components.theme_toggle.label')}</span>
|
|
25
|
+
<div className="flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5">
|
|
26
|
+
{OPTIONS.map(({ value, icon: Icon, labelKey }) => {
|
|
27
|
+
const active = mode === value
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
key={value}
|
|
31
|
+
type="button"
|
|
32
|
+
aria-pressed={active}
|
|
33
|
+
aria-label={t(labelKey)}
|
|
34
|
+
title={t(labelKey)}
|
|
35
|
+
onClick={() => setMode(value)}
|
|
36
|
+
className={cn(
|
|
37
|
+
'flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent cursor-pointer',
|
|
38
|
+
active && 'bg-accent-strong text-foreground shadow-sm',
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<Icon className="size-4" />
|
|
42
|
+
</button>
|
|
43
|
+
)
|
|
44
|
+
})}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
34
47
|
)
|
|
35
48
|
}
|