@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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-ui",
3
3
  "license": "MIT",
4
- "version": "1.0.0-rc.3",
4
+ "version": "1.0.0-rc.5",
5
5
  "description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
@@ -37,7 +37,6 @@
37
37
  "LICENSE"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@tanstack/react-hotkeys": "^0.10.0",
41
40
  "@tanstack/react-router": "^1.169.2",
42
41
  "i18next": "^26.2.0",
43
42
  "react": "^19.2.6",
@@ -60,37 +59,41 @@
60
59
  "@dnd-kit/core": "6.3.1",
61
60
  "@dnd-kit/sortable": "10.0.0",
62
61
  "@dnd-kit/utilities": "3.2.2",
63
- "@fontsource-variable/geist": "5.2.8",
64
- "@fontsource-variable/geist-mono": "5.2.7",
65
- "@tanstack/react-table": "8.21.3",
66
- "@tiptap/core": "3.31.3",
67
- "@tiptap/extension-image": "3.31.3",
68
- "@tiptap/extension-link": "3.31.3",
69
- "@tiptap/extension-placeholder": "3.31.3",
70
- "@tiptap/pm": "3.31.3",
71
- "@tiptap/react": "3.31.3",
72
- "@tiptap/starter-kit": "3.31.3",
62
+ "@fontsource-variable/geist": "5.3.0",
63
+ "@fontsource-variable/geist-mono": "5.3.0",
64
+ "@tanstack/react-hotkeys": "^0.14.3",
65
+ "@tanstack/react-table": "9.3.1",
66
+ "@tiptap/core": "3.32.0",
67
+ "@tiptap/extension-bubble-menu": "3.32.0",
68
+ "@tiptap/extension-floating-menu": "3.32.0",
69
+ "@tiptap/extension-image": "3.32.0",
70
+ "@tiptap/extension-link": "3.32.0",
71
+ "@tiptap/extension-placeholder": "3.32.0",
72
+ "@tiptap/pm": "3.32.0",
73
+ "@tiptap/react": "3.32.0",
74
+ "@tiptap/starter-kit": "3.32.0",
73
75
  "@uiw/react-json-view": "1.12.2",
74
76
  "class-variance-authority": "0.7.1",
75
77
  "clsx": "2.1.1",
76
78
  "cmdk": "1.1.1",
77
- "date-fns": "4.1.0",
79
+ "date-fns": "4.4.0",
78
80
  "date-fns-tz": "3.2.0",
79
- "flag-icons": "7.5.0",
80
- "lucide-react": "1.47.0",
81
- "react-colorful": "5.7.0",
82
- "react-day-picker": "9.14.0",
81
+ "flag-icons": "7.6.0",
82
+ "lucide-react": "1.58.0",
83
+ "react-colorful": "5.8.1",
84
+ "react-day-picker": "10.0.2",
83
85
  "recharts": "3.10.1",
84
- "shadcn": "4.21.0",
85
- "tailwind-merge": "3.5.0",
86
- "tw-animate-css": "1.4.0"
86
+ "shadcn": "4.22.0",
87
+ "tailwind-merge": "3.7.0",
88
+ "tw-animate-css": "1.4.0",
89
+ "@spree/core": "^0.2.0"
87
90
  },
88
91
  "devDependencies": {
89
92
  "@types/react": "^19.2.14",
90
93
  "@types/react-dom": "^19.2.3",
91
94
  "tailwindcss": "^4.2.4",
92
- "typescript": "~6.0.3",
93
- "vite": "^8.0.16"
95
+ "typescript": "~7.0.2",
96
+ "vite": "^8.3.4"
94
97
  },
95
98
  "scripts": {
96
99
  "lint": "biome check src",
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export { decimalPlacesMessage, requiredMessage } from './lib/validation-messages
32
32
  // ---------------------------------------------------------------------------
33
33
  export * from './spree/address-block'
34
34
  export * from './spree/address-book-row'
35
+ export * from './spree/address-select'
35
36
  export * from './spree/back-button'
36
37
  export * from './spree/bulk-dialog'
37
38
  export * from './spree/bulk-price-table'
@@ -117,6 +118,7 @@ export * from './ui/empty'
117
118
  export * from './ui/field'
118
119
  export * from './ui/input'
119
120
  export * from './ui/input-group'
121
+ export * from './ui/item'
120
122
  export * from './ui/label'
121
123
  export * from './ui/media-preview'
122
124
  export * from './ui/pagination'
@@ -1,3 +1,4 @@
1
+ import { intlDisplayName } from '@spree/core'
1
2
  import { useTranslation } from 'react-i18next'
2
3
 
3
4
  /**
@@ -28,24 +29,20 @@ function localizedCountryName(
28
29
  iso?: string | null,
29
30
  name?: string | null,
30
31
  ): string | null {
31
- if (iso) {
32
- try {
33
- const localized = new Intl.DisplayNames([locale, 'en'], { type: 'region' }).of(iso)
34
- if (localized) return localized
35
- } catch {
36
- // Runtime without Intl.DisplayNames coverage — fall back below.
37
- }
38
- }
39
- return name ?? iso ?? null
32
+ const localized = iso ? intlDisplayName('region', locale, iso) : undefined
33
+ return localized || (name ?? iso ?? null)
40
34
  }
41
35
 
42
36
  export function AddressBlock({
43
37
  title,
44
38
  address,
39
+ emptyText,
45
40
  }: {
46
41
  /** Omitted where the surrounding card already names the address. */
47
42
  title?: string
48
43
  address: AddressBlockValue | null | undefined
44
+ /** Shown in place of the address when there is none. Defaults to "Not provided". */
45
+ emptyText?: string
49
46
  }) {
50
47
  const { t, i18n } = useTranslation()
51
48
  return (
@@ -67,7 +64,7 @@ export function AddressBlock({
67
64
  </div>
68
65
  ) : (
69
66
  <span className="text-sm text-muted-foreground">
70
- {t('admin.components.address_block.not_provided')}
67
+ {emptyText ?? t('admin.components.address_block.not_provided')}
71
68
  </span>
72
69
  )}
73
70
  </div>
@@ -1,14 +1,8 @@
1
1
  import { useTranslation } from 'react-i18next'
2
2
  import { Badge } from '../ui/badge'
3
- import { Button } from '../ui/button'
4
- import {
5
- DropdownMenu,
6
- DropdownMenuContent,
7
- DropdownMenuItem,
8
- DropdownMenuTrigger,
9
- } from '../ui/dropdown-menu'
3
+ import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
10
4
  import { AddressBlock, type AddressBlockValue } from './address-block'
11
- import { EllipsisVerticalIcon, PencilIcon, TrashIcon } from './icons'
5
+ import { RowActions } from './row-actions'
12
6
 
13
7
  /** What every address book row needs, whoever owns the book. */
14
8
  export interface AddressBookEntry extends AddressBlockValue {
@@ -29,6 +23,7 @@ export interface AddressBookEntry extends AddressBlockValue {
29
23
  export function AddressBookRow({
30
24
  address,
31
25
  canEdit = true,
26
+ inheritedFrom,
32
27
  onEdit,
33
28
  onRemove,
34
29
  onSetDefaultBilling,
@@ -36,6 +31,11 @@ export function AddressBookRow({
36
31
  }: {
37
32
  address: AddressBookEntry
38
33
  canEdit?: boolean
34
+ /**
35
+ * Names the book this entry is inherited from. An inherited entry is read
36
+ * only here, and its defaults are its owner's, so neither is shown.
37
+ */
38
+ inheritedFrom?: string | null
39
39
  onEdit: () => void
40
40
  onRemove: () => void
41
41
  /** Omit either to hide that action — a book with one address has no choice. */
@@ -43,54 +43,54 @@ export function AddressBookRow({
43
43
  onSetDefaultShipping?: () => void
44
44
  }) {
45
45
  const { t } = useTranslation()
46
+ const inherited = inheritedFrom != null
47
+ const isDefaultBilling = !inherited && address.is_default_billing
48
+ const isDefaultShipping = !inherited && address.is_default_shipping
46
49
 
47
50
  return (
48
- <div className="flex items-start justify-between gap-2 rounded-md border p-3">
49
- <div className="flex min-w-0 flex-col gap-1">
50
- <span className="flex flex-wrap items-center gap-2">
51
- {address.label && (
52
- <span className="font-medium text-foreground text-sm">{address.label}</span>
53
- )}
54
- {address.is_default_billing && (
55
- <Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
56
- )}
57
- {address.is_default_shipping && (
58
- <Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
59
- )}
60
- </span>
61
- <AddressBlock address={address} />
62
- </div>
63
-
64
- {canEdit && (
65
- <DropdownMenu>
66
- <DropdownMenuTrigger asChild>
67
- <Button variant="ghost" size="icon-xs" className="shrink-0">
68
- <EllipsisVerticalIcon className="size-4" />
69
- <span className="sr-only">{t('admin.actions.actions_menu')}</span>
70
- </Button>
71
- </DropdownMenuTrigger>
72
- <DropdownMenuContent align="end">
73
- <DropdownMenuItem onClick={onEdit}>
74
- <PencilIcon className="size-4" />
75
- {t('admin.actions.edit')}
76
- </DropdownMenuItem>
77
- {onSetDefaultBilling && !address.is_default_billing && (
78
- <DropdownMenuItem onClick={onSetDefaultBilling}>
79
- {t('admin.address_book.set_default_billing')}
80
- </DropdownMenuItem>
51
+ <Item variant="outline" className="items-start">
52
+ <ItemContent className="min-w-0">
53
+ {(address.label || inherited || isDefaultBilling || isDefaultShipping) && (
54
+ <ItemTitle className="flex-wrap">
55
+ {address.label}
56
+ {inherited && (
57
+ <Badge variant="secondary">
58
+ {t('admin.address_book.inherited_from', { name: inheritedFrom })}
59
+ </Badge>
60
+ )}
61
+ {isDefaultBilling && (
62
+ <Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
81
63
  )}
82
- {onSetDefaultShipping && !address.is_default_shipping && (
83
- <DropdownMenuItem onClick={onSetDefaultShipping}>
84
- {t('admin.address_book.set_default_shipping')}
85
- </DropdownMenuItem>
64
+ {isDefaultShipping && (
65
+ <Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
86
66
  )}
87
- <DropdownMenuItem variant="destructive" onClick={onRemove}>
88
- <TrashIcon className="size-4" />
89
- {t('admin.actions.delete')}
90
- </DropdownMenuItem>
91
- </DropdownMenuContent>
92
- </DropdownMenu>
67
+ </ItemTitle>
68
+ )}
69
+ <AddressBlock address={address} />
70
+ </ItemContent>
71
+
72
+ {canEdit && !inherited && (
73
+ <ItemActions>
74
+ <RowActions
75
+ actions={[
76
+ { key: 'edit', onSelect: onEdit },
77
+ {
78
+ key: 'set_default_billing',
79
+ label: t('admin.address_book.set_default_billing'),
80
+ visible: !!onSetDefaultBilling && !address.is_default_billing,
81
+ onSelect: () => onSetDefaultBilling?.(),
82
+ },
83
+ {
84
+ key: 'set_default_shipping',
85
+ label: t('admin.address_book.set_default_shipping'),
86
+ visible: !!onSetDefaultShipping && !address.is_default_shipping,
87
+ onSelect: () => onSetDefaultShipping?.(),
88
+ },
89
+ { key: 'delete', destructive: true, onSelect: onRemove },
90
+ ]}
91
+ />
92
+ </ItemActions>
93
93
  )}
94
- </div>
94
+ </Item>
95
95
  )
96
96
  }
@@ -0,0 +1,105 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectGroup,
6
+ SelectItem,
7
+ SelectLabel,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from '../ui/select'
11
+ import type { AddressBlockValue } from './address-block'
12
+
13
+ /** One entry a picker may offer, from whichever book it came out of. */
14
+ export interface AddressOption extends AddressBlockValue {
15
+ id: string
16
+ label?: string | null
17
+ }
18
+
19
+ /**
20
+ * A set of entries under the name of the book they came from — a customer's
21
+ * own addresses, a company's sites. Groups are rendered in the order given.
22
+ */
23
+ export interface AddressOptionGroup {
24
+ key: string
25
+ heading: string
26
+ addresses: AddressOption[]
27
+ }
28
+
29
+ /** The one-line form a picker shows: the entry's own name, then the street. */
30
+ export function summarizeAddress(address: AddressOption): string {
31
+ const place = [address.address1, address.city, address.state_text, address.postal_code]
32
+ .filter(Boolean)
33
+ .join(', ')
34
+
35
+ const who = address.label || address.company || address.full_name
36
+
37
+ return [who, place].filter(Boolean).join(' — ')
38
+ }
39
+
40
+ /**
41
+ * Picks one saved address out of the books a purchase may draw on.
42
+ *
43
+ * Headless: the groups arrive as props, so the same control serves a buyer's
44
+ * own book, a company's sites, and both at once — which is the B2B case, where
45
+ * an order for a company may ship to that company's sites and to the ones it
46
+ * inherits from the companies above it.
47
+ *
48
+ * Base UI resolves the trigger's label from `items`, so the summary line shows
49
+ * instead of the raw prefixed id.
50
+ */
51
+ export function AddressSelect({
52
+ id,
53
+ value,
54
+ onValueChange,
55
+ groups,
56
+ placeholder,
57
+ emptyLabel,
58
+ disabled,
59
+ }: {
60
+ id?: string
61
+ /** The chosen address id, or `''` for "none picked". */
62
+ value: string
63
+ onValueChange: (value: string) => void
64
+ groups: AddressOptionGroup[]
65
+ placeholder?: string
66
+ /** Offered first so a merchant can take the choice back. */
67
+ emptyLabel?: string
68
+ disabled?: boolean
69
+ }) {
70
+ const { t } = useTranslation()
71
+ const none = emptyLabel ?? t('admin.address_select.none')
72
+
73
+ const items = [
74
+ { value: '', label: none },
75
+ ...groups.flatMap((group) =>
76
+ group.addresses.map((address) => ({
77
+ value: address.id,
78
+ label: summarizeAddress(address),
79
+ })),
80
+ ),
81
+ ]
82
+
83
+ return (
84
+ <Select items={items} value={value} onValueChange={onValueChange} disabled={disabled}>
85
+ <SelectTrigger id={id}>
86
+ <SelectValue placeholder={placeholder ?? none} />
87
+ </SelectTrigger>
88
+ <SelectContent>
89
+ <SelectItem value="">{none}</SelectItem>
90
+ {groups
91
+ .filter((group) => group.addresses.length > 0)
92
+ .map((group) => (
93
+ <SelectGroup key={group.key}>
94
+ <SelectLabel>{group.heading}</SelectLabel>
95
+ {group.addresses.map((address) => (
96
+ <SelectItem key={address.id} value={address.id}>
97
+ {summarizeAddress(address)}
98
+ </SelectItem>
99
+ ))}
100
+ </SelectGroup>
101
+ ))}
102
+ </SelectContent>
103
+ </Select>
104
+ )
105
+ }
@@ -1,8 +1,16 @@
1
- import type { ColumnDef } from '@tanstack/react-table'
2
1
  import { PlusIcon, XIcon } from 'lucide-react'
3
2
  import { useMemo } from 'react'
3
+ import { Alert, AlertDescription } from '../ui/alert'
4
4
  import { Button } from '../ui/button'
5
- import { DataGrid, DecimalCell, editableRowIndex, MoneyCell, ReadOnlyCell } from './data-grid'
5
+ import {
6
+ DataGrid,
7
+ type DataGridColumn,
8
+ DecimalCell,
9
+ editableRowIndex,
10
+ MoneyCell,
11
+ ReadOnlyCell,
12
+ } from './data-grid'
13
+ import { CircleAlertIcon } from './icons'
6
14
  import { SearchInput } from './search-input'
7
15
 
8
16
  export interface BulkPriceRow {
@@ -131,7 +139,7 @@ export function BulkPriceTable({
131
139
  isLoading,
132
140
  error,
133
141
  }: BulkPriceTableProps) {
134
- const columns = useMemo<ColumnDef<BulkPriceRow>[]>(
142
+ const columns = useMemo<DataGridColumn<BulkPriceRow>[]>(
135
143
  () => [
136
144
  {
137
145
  id: 'variant',
@@ -203,7 +211,7 @@ export function BulkPriceTable({
203
211
  />
204
212
  )
205
213
  },
206
- } satisfies ColumnDef<BulkPriceRow>,
214
+ } satisfies DataGridColumn<BulkPriceRow>,
207
215
  ]
208
216
  : []),
209
217
  {
@@ -291,7 +299,7 @@ export function BulkPriceTable({
291
299
  </Button>
292
300
  )
293
301
  },
294
- } satisfies ColumnDef<BulkPriceRow>,
302
+ } satisfies DataGridColumn<BulkPriceRow>,
295
303
  ]
296
304
  : []),
297
305
  ],
@@ -312,12 +320,10 @@ export function BulkPriceTable({
312
320
  // table in a card does.
313
321
  <div className="flex h-full flex-col gap-3">
314
322
  {error && (
315
- <div
316
- role="alert"
317
- className="mx-3 shrink-0 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-sm"
318
- >
319
- {error}
320
- </div>
323
+ <Alert variant="destructive" className="mx-3 w-auto shrink-0">
324
+ <CircleAlertIcon />
325
+ <AlertDescription>{error}</AlertDescription>
326
+ </Alert>
321
327
  )}
322
328
  {showToolbar && (
323
329
  // Always-mounted toolbar. Conditionally rendering the search input
@@ -1,6 +1,13 @@
1
- import type { ColumnDef } from '@tanstack/react-table'
2
1
  import { useMemo } from 'react'
3
- import { DataGrid, DecimalCell, ReadOnlyCell, SelectCell, SwitchCell, TextCell } from './data-grid'
2
+ import {
3
+ DataGrid,
4
+ type DataGridColumn,
5
+ DecimalCell,
6
+ ReadOnlyCell,
7
+ SelectCell,
8
+ SwitchCell,
9
+ TextCell,
10
+ } from './data-grid'
4
11
 
5
12
  export interface BulkVariantsRow {
6
13
  id: string
@@ -139,7 +146,7 @@ export function BulkVariantsTable({
139
146
  cartonOptions,
140
147
  onChange,
141
148
  }: BulkVariantsTableProps) {
142
- const columns = useMemo<ColumnDef<BulkVariantsRow>[]>(() => {
149
+ const columns = useMemo<DataGridColumn<BulkVariantsRow>[]>(() => {
143
150
  // Keyboard column coordinates, fixed per column: variant label is
144
151
  // read-only (unregistered), then sku=1, barcode=2, weight=3, unit=4,
145
152
  // height=5, width=6, depth=7, unit=8, preorderable=9, backorder=10,
@@ -148,7 +155,7 @@ export function BulkVariantsTable({
148
155
  const numericHeader = (label: string) => () => <span className="block text-right">{label}</span>
149
156
  const rowLabel = (r: BulkVariantsRow) => r.variantLabel ?? labels.variantDefault
150
157
 
151
- const defs: ColumnDef<BulkVariantsRow>[] = [
158
+ const defs: DataGridColumn<BulkVariantsRow>[] = [
152
159
  {
153
160
  id: 'variant',
154
161
  header: labels.variant,
@@ -182,7 +189,7 @@ export function BulkVariantsTable({
182
189
  />
183
190
  ),
184
191
  },
185
- ...DECIMAL_COLUMNS.map<ColumnDef<BulkVariantsRow>>(({ field, col }) => ({
192
+ ...DECIMAL_COLUMNS.map<DataGridColumn<BulkVariantsRow>>(({ field, col }) => ({
186
193
  id: field,
187
194
  header: numericHeader(labels[field]),
188
195
  cell: ({ row }) => (
@@ -13,6 +13,9 @@
13
13
  * Lives on the frontend (rather than as a server-rendered string) so
14
14
  * draft edits update the row immediately, before any save.
15
15
  */
16
+
17
+ import { formatMoney, isDecimalString } from '@spree/core'
18
+
16
19
  export interface CalculatorPayload {
17
20
  /**
18
21
  * Display name for the calculator, already localized by the caller — this
@@ -101,7 +104,12 @@ function formatField(
101
104
  ): string | null {
102
105
  if (value === null || value === undefined || value === '') return null
103
106
  if (property.format === 'currency') return null
104
- if (property.format === 'money') return formatMoney(value, currency)
107
+ if (property.format === 'money') {
108
+ const amount = String(value)
109
+ return (
110
+ formatMoney(amount, currency) || (isDecimalString(amount) ? `${amount} ${currency}` : amount)
111
+ )
112
+ }
105
113
  if (/(?:^|_)percent$/.test(key)) return `${value}%`
106
114
 
107
115
  const type = (Array.isArray(property.type) ? property.type : [property.type]).find(
@@ -115,19 +123,6 @@ function formatField(
115
123
  return `${humanize(key)}: ${value}`
116
124
  }
117
125
 
118
- function formatMoney(value: unknown, currency: string): string {
119
- const amount = String(value)
120
- if (!/^-?\d+(\.\d+)?$/.test(amount)) return amount
121
- try {
122
- // `Intl` reads a numeric string exactly; its ES2022 typings only list numbers.
123
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(
124
- amount as unknown as number,
125
- )
126
- } catch {
127
- return `${amount} ${currency}`
128
- }
129
- }
130
-
131
126
  function humanize(key: string): string {
132
127
  const spaced = key.replace(/_/g, ' ').trim()
133
128
  return spaced.charAt(0).toUpperCase() + spaced.slice(1)
@@ -1,3 +1,4 @@
1
+ import { CLAIM_RESOLUTIONS, type ClaimResolution, isPositiveMoney } from '@spree/core'
1
2
  import { useState } from 'react'
2
3
  import { useTranslation } from 'react-i18next'
3
4
  import { Button } from '../ui/button'
@@ -11,12 +12,12 @@ import {
11
12
  } from '../ui/dialog'
12
13
  import { Field, FieldLabel } from '../ui/field'
13
14
  import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
15
+ import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
14
16
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
15
17
  import { Switch } from '../ui/switch'
16
18
  import type { RefundMethod } from './return-dialogs'
17
19
 
18
- /** How a claim is settled. */
19
- export type ClaimResolution = 'refund' | 'replacement' | 'refund_and_replacement'
20
+ export type { ClaimResolution }
20
21
 
21
22
  /** One claimed line, as the resolve form needs it. */
22
23
  export type ClaimLineForResolve = {
@@ -73,12 +74,10 @@ export function ClaimResolveDialog({
73
74
  .filter(([, on]) => on)
74
75
  .map(([id]) => id)
75
76
 
76
- const resolutionOptions = (['refund', 'replacement', 'refund_and_replacement'] as const).map(
77
- (value) => ({
78
- value,
79
- label: t(`admin.pages.orders.detail.claims.resolutions.${value}`),
80
- }),
81
- )
77
+ const resolutionOptions = CLAIM_RESOLUTIONS.map((value) => ({
78
+ value,
79
+ label: t(`admin.pages.orders.detail.claims.resolutions.${value}`),
80
+ }))
82
81
 
83
82
  const methodOptions = [
84
83
  {
@@ -91,11 +90,11 @@ export function ClaimResolveDialog({
91
90
  },
92
91
  ]
93
92
 
93
+ // A number input keeps `.5` as typed; the API reads only `0.5`.
94
+ const refundAmount = amount.startsWith('.') ? `0${amount}` : amount
94
95
  // Refunding nothing, or replacing nothing, is what the server rejects —
95
96
  // say so here instead of letting the request fail.
96
- // The amount is the canonical decimal string a number input yields, so
97
- // "above zero" is unsigned digits with a non-zero digit among them.
98
- const positiveAmount = /^(\d+(\.\d+)?|\.\d+)$/.test(amount) && /[1-9]/.test(amount)
97
+ const positiveAmount = isPositiveMoney(refundAmount)
99
98
  const ready =
100
99
  (!refunding || positiveAmount) && (!sendingReplacement || chosenReplacements.length > 0)
101
100
 
@@ -178,21 +177,23 @@ export function ClaimResolveDialog({
178
177
  {t('admin.pages.orders.detail.claims.replacement_items')}
179
178
  </span>
180
179
  {lines.map((line) => (
181
- <div
182
- key={line.id}
183
- className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle p-3"
184
- >
185
- <span className="truncate text-sm">
186
- {line.label}
187
- <span className="text-muted-foreground"> ×{line.quantity}</span>
188
- </span>
189
- <Switch
190
- checked={replacing[line.id] ?? false}
191
- onCheckedChange={(checked) =>
192
- setReplacing({ ...replacing, [line.id]: checked })
193
- }
194
- />
195
- </div>
180
+ <Item key={line.id} variant="outline" className="flex-nowrap border-border-subtle">
181
+ <ItemContent className="min-w-0">
182
+ <ItemTitle className="block truncate font-normal">
183
+ {line.label}
184
+ <span className="text-muted-foreground"> ×{line.quantity}</span>
185
+ </ItemTitle>
186
+ </ItemContent>
187
+ <ItemActions>
188
+ <Switch
189
+ aria-label={line.label}
190
+ checked={replacing[line.id] ?? false}
191
+ onCheckedChange={(checked) =>
192
+ setReplacing({ ...replacing, [line.id]: checked })
193
+ }
194
+ />
195
+ </ItemActions>
196
+ </Item>
196
197
  ))}
197
198
  </div>
198
199
  )}
@@ -208,7 +209,7 @@ export function ClaimResolveDialog({
208
209
  onSubmit({
209
210
  resolution,
210
211
  refundMethod,
211
- amount: refunding ? amount : undefined,
212
+ amount: refunding ? refundAmount : undefined,
212
213
  replacementLineItemIds: chosenReplacements,
213
214
  })
214
215
  }