@spree/dashboard-ui 1.0.0-rc.3 → 1.0.0-rc.5

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.
@@ -1,10 +1,11 @@
1
1
  import {
2
+ type ColumnDef,
2
3
  flexRender,
3
- getCoreRowModel,
4
4
  type Row,
5
+ type RowData,
5
6
  type Table,
6
- type TableOptions,
7
- useReactTable,
7
+ tableFeatures,
8
+ useTable,
8
9
  } from '@tanstack/react-table'
9
10
  import { useCallback, useMemo, useRef, useState } from 'react'
10
11
  import { cn } from '../../lib/utils'
@@ -14,9 +15,18 @@ import type { CellCoords, CellKey, CellRegistration, RenderSectionHeader } from
14
15
  import { cellKey } from './types'
15
16
  import { useDataGridKeyboard } from './use-data-grid-keyboard'
16
17
 
17
- interface DataGridProps<T> {
18
+ // The grid renders rows as given — no sorting, filtering or column state — so
19
+ // it needs only the table's core features.
20
+ const dataGridFeatures = tableFeatures({})
21
+ type DataGridFeatures = typeof dataGridFeatures
22
+
23
+ /** A column of a `DataGrid`. */
24
+ // biome-ignore lint/suspicious/noExplicitAny: each column reads a value of its own type
25
+ export type DataGridColumn<T extends RowData> = ColumnDef<DataGridFeatures, T, any>
26
+
27
+ interface DataGridProps<T extends RowData> {
18
28
  rows: T[]
19
- columns: TableOptions<T>['columns']
29
+ columns: DataGridColumn<T>[]
20
30
  getRowId: (row: T) => string
21
31
  /** Optional grouping. Return a node spanning all columns to render this row
22
32
  * as a (non-editable) section header. Return null to render the row as
@@ -39,7 +49,7 @@ interface DataGridProps<T> {
39
49
  scrollable?: boolean
40
50
  }
41
51
 
42
- export function DataGrid<T>({
52
+ export function DataGrid<T extends RowData>({
43
53
  rows,
44
54
  columns,
45
55
  getRowId,
@@ -48,11 +58,11 @@ export function DataGrid<T>({
48
58
  'aria-label': ariaLabel,
49
59
  scrollable = false,
50
60
  }: DataGridProps<T>) {
51
- const table = useReactTable<T>({
61
+ const table = useTable({
62
+ features: dataGridFeatures,
52
63
  data: rows,
53
64
  columns,
54
65
  getRowId,
55
- getCoreRowModel: getCoreRowModel(),
56
66
  })
57
67
 
58
68
  return (
@@ -66,14 +76,14 @@ export function DataGrid<T>({
66
76
  )
67
77
  }
68
78
 
69
- function DataGridShell<T>({
79
+ function DataGridShell<T extends RowData>({
70
80
  table,
71
81
  renderSectionHeader,
72
82
  className,
73
83
  ariaLabel,
74
84
  scrollable,
75
85
  }: {
76
- table: Table<T>
86
+ table: Table<DataGridFeatures, T>
77
87
  renderSectionHeader?: RenderSectionHeader<T>
78
88
  className?: string
79
89
  ariaLabel?: string
@@ -324,12 +334,12 @@ function DataGridShell<T>({
324
334
  )
325
335
  }
326
336
 
327
- function DataGridRow<T>({
337
+ function DataGridRow<T extends RowData>({
328
338
  row,
329
339
  renderSectionHeader,
330
340
  columnCount,
331
341
  }: {
332
- row: Row<T>
342
+ row: Row<DataGridFeatures, T>
333
343
  renderSectionHeader?: RenderSectionHeader<T>
334
344
  columnCount: number
335
345
  }) {
@@ -345,7 +355,7 @@ function DataGridRow<T>({
345
355
  }
346
356
  return (
347
357
  <tr>
348
- {row.getVisibleCells().map((cell) => (
358
+ {row.getAllCells().map((cell) => (
349
359
  <td key={cell.id} className="h-9 p-0 align-middle">
350
360
  {flexRender(cell.column.columnDef.cell, cell.getContext())}
351
361
  </td>
@@ -8,6 +8,6 @@ export {
8
8
  TextCell,
9
9
  } from './cells'
10
10
  export { useDataGridContext } from './context'
11
- export { DataGrid } from './data-grid'
11
+ export { DataGrid, type DataGridColumn } from './data-grid'
12
12
  export { editableRowIndex } from './helpers'
13
13
  export type { CellCoords, RenderSectionHeader } from './types'
@@ -22,16 +22,22 @@ export function FormSaveButton<TFieldValues extends FieldValues>({
22
22
  form,
23
23
  label,
24
24
  savingLabel,
25
+ dirty = false,
25
26
  }: {
26
27
  form: UseFormReturn<TFieldValues, any, any>
27
28
  label?: string
28
29
  savingLabel?: string
30
+ /**
31
+ * Unsaved changes held outside the form — custom field drafts the page
32
+ * saves from its own submit — counted as dirty alongside the form's own.
33
+ */
34
+ dirty?: boolean
29
35
  }) {
30
36
  const { t } = useTranslation()
31
37
  const { isDirty, isSubmitting } = useFormState({ control: form.control })
32
38
 
33
39
  return (
34
- <Button type="submit" disabled={!isDirty || isSubmitting}>
40
+ <Button type="submit" disabled={!(isDirty || dirty) || isSubmitting}>
35
41
  {isSubmitting && <Loader2Icon className="animate-spin" />}
36
42
  {isSubmitting
37
43
  ? (savingLabel ?? t('admin.actions.saving'))
@@ -52,15 +58,22 @@ export function FormDiscardButton<TFieldValues extends FieldValues>({
52
58
  form,
53
59
  label,
54
60
  onDiscard,
61
+ dirty = false,
55
62
  }: {
56
63
  form: UseFormReturn<TFieldValues, any, any>
57
64
  label?: string
58
65
  onDiscard?: () => void
66
+ /**
67
+ * Unsaved changes held outside the form — custom field drafts the page
68
+ * saves from its own submit — counted as dirty alongside the form's own.
69
+ * Pass `onDiscard` too, so discarding drops them.
70
+ */
71
+ dirty?: boolean
59
72
  }) {
60
73
  const { t } = useTranslation()
61
74
  const { isDirty, isSubmitting } = useFormState({ control: form.control })
62
75
 
63
- if (!isDirty) return null
76
+ if (!isDirty && !dirty) return null
64
77
 
65
78
  return (
66
79
  <Button
@@ -83,17 +96,23 @@ export function FormActions<TFieldValues extends FieldValues>({
83
96
  savingLabel,
84
97
  discardLabel,
85
98
  onDiscard,
99
+ dirty,
86
100
  }: {
87
101
  form: UseFormReturn<TFieldValues, any, any>
88
102
  saveLabel?: string
89
103
  savingLabel?: string
90
104
  discardLabel?: string
91
105
  onDiscard?: () => void
106
+ /**
107
+ * Unsaved changes held outside the form — custom field drafts the page
108
+ * saves from its own submit — counted as dirty alongside the form's own.
109
+ */
110
+ dirty?: boolean
92
111
  }) {
93
112
  return (
94
113
  <>
95
- <FormDiscardButton form={form} label={discardLabel} onDiscard={onDiscard} />
96
- <FormSaveButton form={form} label={saveLabel} savingLabel={savingLabel} />
114
+ <FormDiscardButton form={form} label={discardLabel} onDiscard={onDiscard} dirty={dirty} />
115
+ <FormSaveButton form={form} label={saveLabel} savingLabel={savingLabel} dirty={dirty} />
97
116
  </>
98
117
  )
99
118
  }
@@ -113,8 +132,11 @@ export function FormActions<TFieldValues extends FieldValues>({
113
132
  export function useFormSubmitShortcut<TFieldValues extends FieldValues>(
114
133
  form: UseFormReturn<TFieldValues, any, any>,
115
134
  onSubmit: (values: TFieldValues) => void | Promise<void>,
135
+ /** Unsaved changes held outside the form, as on `FormActions`. */
136
+ { dirty: externalDirty = false }: { dirty?: boolean } = {},
116
137
  ) {
117
- const { isDirty, isSubmitting } = useFormState({ control: form.control })
138
+ const { isDirty: formDirty, isSubmitting } = useFormState({ control: form.control })
139
+ const isDirty = formDirty || externalDirty
118
140
 
119
141
  useHotkey(
120
142
  'Mod+S',
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react'
2
+ import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from '../ui/item'
2
3
  import { Thumbnail } from '../ui/thumbnail'
3
4
  import { PackageIcon } from './icons'
4
5
 
@@ -30,17 +31,17 @@ export type FulfillmentItemRowData = {
30
31
  */
31
32
  function ItemRow({ row }: { row: FulfillmentItemRowData }) {
32
33
  return (
33
- <div className="flex items-center gap-3 p-3">
34
+ <Item className="flex-nowrap gap-3 rounded-none border-0 p-3">
34
35
  <Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
35
36
 
36
- <div className="min-w-0 flex-1">
37
- <div className="truncate text-sm font-medium">{row.name}</div>
37
+ <ItemContent className="min-w-0 gap-0">
38
+ <ItemTitle className="block truncate">{row.name}</ItemTitle>
38
39
  {row.optionsText && (
39
- <div className="truncate text-xs text-muted-foreground">{row.optionsText}</div>
40
+ <ItemDescription className="line-clamp-1 text-xs">{row.optionsText}</ItemDescription>
40
41
  )}
41
- </div>
42
+ </ItemContent>
42
43
 
43
- <div className="min-w-0 shrink text-right text-muted-foreground text-sm">
44
+ <ItemActions className="min-w-0 shrink flex-col items-end gap-0 text-right text-muted-foreground text-sm">
44
45
  {row.displayPrice ? (
45
46
  <span className="whitespace-nowrap">
46
47
  {row.displayPrice} × {row.quantity}
@@ -48,9 +49,9 @@ function ItemRow({ row }: { row: FulfillmentItemRowData }) {
48
49
  ) : (
49
50
  <span className="whitespace-nowrap">× {row.quantity}</span>
50
51
  )}
51
- {row.priceSource && <div className="truncate text-xs">{row.priceSource}</div>}
52
- </div>
53
- </div>
52
+ {row.priceSource && <div className="max-w-full truncate text-xs">{row.priceSource}</div>}
53
+ </ItemActions>
54
+ </Item>
54
55
  )
55
56
  }
56
57
 
@@ -21,9 +21,11 @@ export {
21
21
  BellIcon,
22
22
  BoldIcon,
23
23
  BookOpenIcon,
24
+ BotIcon,
24
25
  BoxesIcon,
25
26
  BracesIcon,
26
27
  Building2Icon,
28
+ CalculatorIcon,
27
29
  CalendarIcon,
28
30
  CalendarOffIcon,
29
31
  ChartColumnIcon,
@@ -36,6 +38,7 @@ export {
36
38
  ChevronsLeftIcon,
37
39
  ChevronsRightIcon,
38
40
  ChevronsUpDownIcon,
41
+ CircleAlertIcon,
39
42
  CircleCheckIcon,
40
43
  CircleDotIcon,
41
44
  CircleIcon,
@@ -1,6 +1,7 @@
1
1
  import { useTranslation } from 'react-i18next'
2
2
  import { Field, FieldLabel } from '../ui/field'
3
3
  import { Input } from '../ui/input'
4
+ import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
4
5
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
5
6
 
6
7
  /** A unit on the order that a return, exchange or claim can name. */
@@ -31,26 +32,27 @@ export function QuantityPicker({
31
32
  return (
32
33
  <div className="flex flex-col gap-2">
33
34
  {units.map((unit) => (
34
- <div
35
- key={unit.id}
36
- className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle p-3"
37
- >
38
- <span className="min-w-0 truncate text-sm">{unit.label}</span>
39
- <Input
40
- type="number"
41
- min={0}
42
- max={unit.quantity}
43
- className="w-20"
44
- value={selection[unit.id] ?? 0}
45
- aria-label={unit.label}
46
- onChange={(event) =>
47
- onChange({
48
- ...selection,
49
- [unit.id]: Math.max(0, Math.min(Number(event.target.value), unit.quantity)),
50
- })
51
- }
52
- />
53
- </div>
35
+ <Item key={unit.id} variant="outline" className="flex-nowrap border-border-subtle">
36
+ <ItemContent className="min-w-0">
37
+ <ItemTitle className="block truncate font-normal">{unit.label}</ItemTitle>
38
+ </ItemContent>
39
+ <ItemActions>
40
+ <Input
41
+ type="number"
42
+ min={0}
43
+ max={unit.quantity}
44
+ className="w-20"
45
+ value={selection[unit.id] ?? 0}
46
+ aria-label={unit.label}
47
+ onChange={(event) =>
48
+ onChange({
49
+ ...selection,
50
+ [unit.id]: Math.max(0, Math.min(Number(event.target.value), unit.quantity)),
51
+ })
52
+ }
53
+ />
54
+ </ItemActions>
55
+ </Item>
54
56
  ))}
55
57
  </div>
56
58
  )
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { type ReactNode, useState } from 'react'
2
2
  import {
3
3
  Combobox,
4
4
  ComboboxContent,
@@ -84,14 +84,30 @@ export function ResourceCombobox<T extends ComboboxOption>({
84
84
  disabled,
85
85
  id,
86
86
  }: ResourceComboboxProps<T>) {
87
- const selected = items.find((r) => r.id === value) ?? null
87
+ // The picked record is remembered so it stays selected while `items` is
88
+ // between fetches. Without it, the selection drops out of the list for a
89
+ // moment, Base UI reads that as the value being cleared, empties the input,
90
+ // and the field flickers between labels until the queries settle.
91
+ const [picked, setPicked] = useState<T | null>(null)
92
+ const selected =
93
+ items.find((r) => r.id === value) ?? (picked && picked.id === value ? picked : null)
88
94
 
89
95
  return (
90
96
  <Combobox
91
97
  items={items}
92
98
  value={selected}
93
- onValueChange={(record: T | null) => onChange(record?.id, record)}
94
- onInputValueChange={onInputChange}
99
+ // Compared by id: a refetch hands back equal records as new objects.
100
+ isItemEqualToValue={(item: T, current: T) => item.id === current.id}
101
+ onValueChange={(record: T | null) => {
102
+ setPicked(record)
103
+ onChange(record?.id, record)
104
+ }}
105
+ // Picking an option writes its label into the input. That is not a
106
+ // search, so the query goes back to the empty list instead of searching
107
+ // for the full label of what was just chosen.
108
+ onInputValueChange={(next, details) =>
109
+ onInputChange?.(details.reason === 'item-press' ? '' : next)
110
+ }
95
111
  itemToStringLabel={(record: T | null) => (record ? getOptionLabel(record) : '')}
96
112
  itemToStringValue={(record: T | null) => record?.id ?? ''}
97
113
  // Filtering is server-side: the caller's `onInputChange` already narrows
@@ -2,7 +2,8 @@ import { useState } from 'react'
2
2
  import { useTranslation } from 'react-i18next'
3
3
  import { Button } from '../ui/button'
4
4
  import { Field, FieldLabel } from '../ui/field'
5
- import { Input } from '../ui/input'
5
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group'
6
+ import { EyeIcon, EyeOffIcon } from './icons'
6
7
 
7
8
  // Server masks stored secrets as `••••…` on read so admins can see "yes,
8
9
  // something is stored" without exposing the actual value. Detecting that
@@ -35,7 +36,9 @@ interface SecretInputProps {
35
36
  * mask (`••••3K9z`) and renders as a read-only badge with a "Replace"
36
37
  * button — never as an input, and never with a reveal toggle, because the
37
38
  * server does not send the secret and there is nothing to unhide. Replace
38
- * switches to an empty password input so the admin opts in to rotation;
39
+ * switches to an empty password input so the admin opts in to rotation; that
40
+ * input does carry a reveal toggle, since what it hides is what the admin is
41
+ * typing right now;
39
42
  * Cancel reverts to the badge so the next save preserves the existing
40
43
  * secret via the backend's masked round-trip guard.
41
44
  *
@@ -57,6 +60,7 @@ export function SecretInput({
57
60
  // after `onChange('')` has cleared the parent value.
58
61
  const [pendingMask, setPendingMask] = useState<string | null>(null)
59
62
  const replacing = pendingMask !== null
63
+ const [revealed, setRevealed] = useState(false)
60
64
 
61
65
  // A stored secret always renders as the badge: the server sent a mask,
62
66
  // not the value, so there is nothing a reveal toggle could show.
@@ -93,17 +97,29 @@ export function SecretInput({
93
97
  <Field>
94
98
  <FieldLabel htmlFor={id}>{label}</FieldLabel>
95
99
  <div className="flex items-center gap-2">
96
- <Input
97
- id={id}
98
- type="password"
99
- autoComplete="new-password"
100
- placeholder={
101
- replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
102
- }
103
- value={(value as string) ?? ''}
104
- onChange={(e) => onChange(e.target.value)}
105
- className="flex-1"
106
- />
100
+ <InputGroup className="flex-1">
101
+ <InputGroupInput
102
+ id={id}
103
+ type={revealed ? 'text' : 'password'}
104
+ autoComplete="new-password"
105
+ spellCheck={false}
106
+ placeholder={
107
+ replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
108
+ }
109
+ value={(value as string) ?? ''}
110
+ onChange={(e) => onChange(e.target.value)}
111
+ />
112
+ <InputGroupAddon align="inline-end">
113
+ <InputGroupButton
114
+ size="icon-xs"
115
+ aria-label={revealed ? t('admin.actions.hide') : t('admin.actions.show')}
116
+ aria-pressed={revealed}
117
+ onClick={() => setRevealed((visible) => !visible)}
118
+ >
119
+ {revealed ? <EyeOffIcon /> : <EyeIcon />}
120
+ </InputGroupButton>
121
+ </InputGroupAddon>
122
+ </InputGroup>
107
123
  {replacing && (
108
124
  <Button
109
125
  type="button"
@@ -15,17 +15,20 @@ import {
15
15
  ComboboxInput,
16
16
  ComboboxItem,
17
17
  ComboboxList,
18
- DropdownMenu,
19
- DropdownMenuContent,
20
- DropdownMenuItem,
21
- DropdownMenuTrigger,
22
18
  Field,
23
19
  FieldDescription,
24
20
  FieldError,
25
21
  FieldGroup,
26
22
  FieldLabel,
27
23
  Input,
24
+ Item,
25
+ ItemActions,
26
+ ItemContent,
27
+ ItemDescription,
28
+ ItemGroup,
29
+ ItemTitle,
28
30
  RelativeTime,
31
+ RowActions,
29
32
  Sheet,
30
33
  SheetContent,
31
34
  SheetDescription,
@@ -35,7 +38,7 @@ import {
35
38
  StatusBadge,
36
39
  useConfirm,
37
40
  } from '../index'
38
- import { BadgeCheckIcon, EllipsisVerticalIcon, PencilIcon, PlusIcon, TrashIcon } from './icons'
41
+ import { BadgeCheckIcon, PlusIcon } from './icons'
39
42
 
40
43
  /** One selectable registration kind. */
41
44
  interface KindOption {
@@ -153,81 +156,71 @@ export function TaxIdentifiersCard({
153
156
  <p className="text-muted-foreground text-sm">{t('admin.tax_identifiers.empty')}</p>
154
157
  </CardContent>
155
158
  ) : (
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')}
159
+ <CardContent>
160
+ <ItemGroup className="gap-3">
161
+ {identifiers.map((identifier) => (
162
+ <Item key={identifier.id} role="listitem" variant="outline" className="items-start">
163
+ <ItemContent className="min-w-0">
164
+ <ItemTitle className="flex-wrap">
165
+ {identifier.value}
166
+ <Badge variant="outline">
167
+ {t(`admin.tax_identifiers.kinds.${identifier.kind}`, {
168
+ defaultValue: identifier.kind,
169
+ })}
181
170
  </Badge>
171
+ {identifier.validation_status ? (
172
+ <StatusBadge
173
+ status={identifier.validation_status}
174
+ label={t(
175
+ `admin.tax_identifiers.validation_status.${identifier.validation_status}`,
176
+ { defaultValue: identifier.validation_status },
177
+ )}
178
+ />
179
+ ) : (
180
+ <Badge variant="secondary">
181
+ {t('admin.tax_identifiers.validation_status.not_checked')}
182
+ </Badge>
183
+ )}
184
+ </ItemTitle>
185
+ {identifier.validated_at && (
186
+ <ItemDescription className="text-xs">
187
+ <RelativeTime
188
+ iso={identifier.validated_at}
189
+ prefix={t('admin.tax_identifiers.checked_prefix')}
190
+ />
191
+ </ItemDescription>
192
+ )}
193
+ {!identifier.validatable && (
194
+ <ItemDescription className="text-xs">
195
+ {t('admin.tax_identifiers.not_validatable')}
196
+ </ItemDescription>
182
197
  )}
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')}
198
+ </ItemContent>
199
+
200
+ {canEdit && (
201
+ <ItemActions>
202
+ <RowActions
203
+ actions={[
204
+ {
205
+ key: 'validate',
206
+ label: t('admin.tax_identifiers.validate_action'),
207
+ icon: <BadgeCheckIcon className="size-4" />,
208
+ disabled: !identifier.validatable || mutations.isValidating,
209
+ onSelect: () => mutations.validate(identifier.id).catch(() => undefined),
210
+ },
211
+ { key: 'edit', onSelect: () => setEditing(identifier) },
212
+ {
213
+ key: 'delete',
214
+ destructive: true,
215
+ onSelect: () => handleDelete(identifier),
216
+ },
217
+ ]}
189
218
  />
190
- </span>
191
- )}
192
- {!identifier.validatable && (
193
- <span className="text-muted-foreground text-xs">
194
- {t('admin.tax_identifiers.not_validatable')}
195
- </span>
219
+ </ItemActions>
196
220
  )}
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
- ))}
221
+ </Item>
222
+ ))}
223
+ </ItemGroup>
231
224
  </CardContent>
232
225
  )}
233
226
 
@@ -188,13 +188,7 @@ export function Wizard({
188
188
  }}
189
189
  modal
190
190
  >
191
- <DialogContent
192
- // Edge-to-edge minus a gutter, like the import wizard and the bulk
193
- // price editor — every inset/translate/max is overridden.
194
- className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
195
- style={{ maxHeight: 'none' }}
196
- showCloseButton={false}
197
- >
191
+ <DialogContent fullScreen showCloseButton={false}>
198
192
  <DialogHeader className="h-14 shrink-0 flex-row items-center gap-3 space-y-0 border-b border-border-subtle bg-background px-4 py-0">
199
193
  <div className="flex min-w-0 items-center gap-3">
200
194
  <DialogTitle className="truncate text-base">{title}</DialogTitle>
package/src/ui/badge.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BuiltInStatus } from '@spree/core'
1
2
  import { cva, type VariantProps } from 'class-variance-authority'
2
3
  import type * as React from 'react'
3
4
  import { useTranslation } from 'react-i18next'
@@ -80,6 +81,7 @@ const statusToneMap: Record<string, StatusTone> = {
80
81
  authorized: 'info',
81
82
  complete: 'success',
82
83
  completed: 'success',
84
+ placed: 'success',
83
85
  paid: 'success',
84
86
  fulfilled: 'success',
85
87
  delivered: 'success',
@@ -157,18 +159,36 @@ const statusToneMap: Record<string, StatusTone> = {
157
159
  ready_to_ship: 'warning',
158
160
  ordered: 'info',
159
161
  partially_received: 'info',
162
+ // More arrived than was ordered: done, but worth a look.
163
+ over_received: 'warning',
160
164
  // Gift cards.
161
165
  partially_redeemed: 'info',
162
166
  redeemed: 'neutral',
163
167
  // Price lists — `scheduled` is live-but-not-yet, which is the same shape as
164
168
  // any other "waiting on the clock" state.
165
169
  scheduled: 'info',
170
+ // The rest of the API's own statuses, on the same rule as above.
171
+ accepted: 'success',
172
+ waived: 'neutral',
173
+ withdrawn: 'neutral',
174
+ awaiting_confirmation: 'warning',
175
+ lapsed: 'neutral',
176
+ mapping: 'warning',
177
+ completed_mapping: 'info',
178
+ unresolved: 'destructive',
179
+ on_hand: 'success',
180
+ backordered: 'warning',
181
+ shipped: 'success',
182
+ checkout: 'neutral',
183
+ invalid: 'destructive',
184
+ purchased: 'success',
185
+ refund_requested: 'warning',
166
186
  // Boolean filter values. A yes/no pair is a state like any other — "in
167
187
  // stock: no" is the row an operator is looking for, and reading it in the
168
188
  // same green/red the rest of the app uses saves them parsing the word.
169
189
  true: 'success',
170
190
  false: 'destructive',
171
- }
191
+ } satisfies Record<BuiltInStatus, StatusTone> & Record<string, StatusTone>
172
192
 
173
193
  // Dot fills, by tone. These read the same `--status-*` ramps the tinted Badge
174
194
  // variants use, but take the *reading* step rather than the fill step: a 6px