@spree/dashboard-ui 0.13.1 → 1.0.0-beta.2

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 (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +38 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -0,0 +1,434 @@
1
+ import type { ColumnDef } from '@tanstack/react-table'
2
+ import { useMemo } from 'react'
3
+ import { DataGrid, DecimalCell, ReadOnlyCell, SelectCell, SwitchCell, TextCell } from './data-grid'
4
+
5
+ export interface BulkVariantsRow {
6
+ id: string
7
+ variantLabel?: string | null
8
+ sku?: string | null
9
+ barcode?: string | null
10
+ // Display strings — decimal weight/dimension values, or null for unset.
11
+ weight?: string | null
12
+ weightUnit?: string | null
13
+ height?: string | null
14
+ width?: string | null
15
+ depth?: string | null
16
+ dimensionsUnit?: string | null
17
+ preorderable?: boolean
18
+ backorderLimit?: string | null
19
+ taxCategoryId?: string | null
20
+ hsCode?: string | null
21
+ /** ISO 3166-1 alpha-2 code, never a country record id. */
22
+ countryOfOrigin?: string | null
23
+ customsDescription?: string | null
24
+ unitsPerCarton?: string | null
25
+ cartonPackageTypeId?: string | null
26
+ cartonWeight?: string | null
27
+ cartonsPerPallet?: string | null
28
+ }
29
+
30
+ export type BulkVariantsField =
31
+ | 'sku'
32
+ | 'barcode'
33
+ | 'weight'
34
+ | 'weightUnit'
35
+ | 'height'
36
+ | 'width'
37
+ | 'depth'
38
+ | 'dimensionsUnit'
39
+ | 'preorderable'
40
+ | 'backorderLimit'
41
+ | 'taxCategoryId'
42
+ | 'hsCode'
43
+ | 'countryOfOrigin'
44
+ | 'customsDescription'
45
+ | 'unitsPerCarton'
46
+ | 'cartonPackageTypeId'
47
+ | 'cartonWeight'
48
+ | 'cartonsPerPallet'
49
+
50
+ export type BulkVariantsChange =
51
+ | { field: Exclude<BulkVariantsField, 'preorderable'>; value: string | null }
52
+ | { field: 'preorderable'; value: boolean }
53
+
54
+ export interface BulkVariantsTableLabels {
55
+ /** Column headers. */
56
+ variant: string
57
+ sku: string
58
+ barcode: string
59
+ weight: string
60
+ weightUnit: string
61
+ height: string
62
+ width: string
63
+ depth: string
64
+ dimensionsUnit: string
65
+ preorderable: string
66
+ backorderLimit: string
67
+ taxCategory: string
68
+ hsCode: string
69
+ countryOfOrigin: string
70
+ customsDescription: string
71
+ unitsPerCarton: string
72
+ cartonPackageType: string
73
+ cartonWeight: string
74
+ cartonsPerPallet: string
75
+ /** Placeholder for a variant row with no options text (e.g. "Default"). */
76
+ variantDefault: string
77
+ /** Shown in unit cells with no explicit value ("inherit the default"). */
78
+ unitDefault: string
79
+ /** Shown in the tax-category cell when none is set. */
80
+ taxCategoryNone: string
81
+ /** Shown in the country-of-origin cell when none is set. */
82
+ countryOfOriginNone: string
83
+ /** Shown in the carton cell when the variant is packed into none. */
84
+ cartonPackageTypeNone: string
85
+ /** Aria label on the DataGrid for screen readers. */
86
+ gridAriaLabel?: string
87
+ }
88
+
89
+ export interface BulkVariantsTableProps {
90
+ rows: BulkVariantsRow[]
91
+ /** Localized strings — the caller owns translations. */
92
+ labels: BulkVariantsTableLabels
93
+ weightUnitOptions: ReadonlyArray<{ value: string; label: string }>
94
+ dimensionUnitOptions: ReadonlyArray<{ value: string; label: string }>
95
+ /** Omit to hide the tax-category column (no tax categories configured). */
96
+ taxCategoryOptions?: ReadonlyArray<{ value: string; label: string }>
97
+ /**
98
+ * The store's cartons. Omit to hide the packing columns entirely. The
99
+ * optional `hint` carries each carton's size, so two similarly named ones
100
+ * are told apart without leaving the sheet.
101
+ */
102
+ cartonOptions?: ReadonlyArray<{ value: string; label: string; hint?: string }>
103
+ /**
104
+ * Countries as `{ value: ISO, label: name }`. Omit to hide the customs
105
+ * columns entirely (e.g. the country list has not loaded yet). Values are
106
+ * ISO codes so the stored data survives the countries table going away.
107
+ */
108
+ countryOptions?: ReadonlyArray<{ value: string; label: string }>
109
+ /** Called when the user commits a value to a cell. The id is the row id. */
110
+ onChange: (rowId: string, change: BulkVariantsChange) => void
111
+ }
112
+
113
+ // Decimal columns in grid order, paired with their fixed keyboard column
114
+ // coordinate (see the coordinate map in the component body).
115
+ const DECIMAL_COLUMNS: ReadonlyArray<{
116
+ field: 'weight' | 'height' | 'width' | 'depth'
117
+ col: number
118
+ }> = [
119
+ { field: 'weight', col: 3 },
120
+ { field: 'height', col: 5 },
121
+ { field: 'width', col: 6 },
122
+ { field: 'depth', col: 7 },
123
+ ]
124
+
125
+ /**
126
+ * Presentational primitive for a "spreadsheet of variant fields" UI: SKU,
127
+ * barcode, shipping measurements + units, availability, tax category.
128
+ * Selection, copy/paste, and drag-fill come from the DataGrid. Owns NO data
129
+ * fetching, edit tracking, or save logic — the caller projects its rows into
130
+ * BulkVariantsRow shape and writes edits back through `onChange`.
131
+ */
132
+ export function BulkVariantsTable({
133
+ rows,
134
+ labels,
135
+ weightUnitOptions,
136
+ dimensionUnitOptions,
137
+ taxCategoryOptions,
138
+ countryOptions,
139
+ cartonOptions,
140
+ onChange,
141
+ }: BulkVariantsTableProps) {
142
+ const columns = useMemo<ColumnDef<BulkVariantsRow>[]>(() => {
143
+ // Keyboard column coordinates, fixed per column: variant label is
144
+ // read-only (unregistered), then sku=1, barcode=2, weight=3, unit=4,
145
+ // height=5, width=6, depth=7, unit=8, preorderable=9, backorder=10,
146
+ // tax=11, hs code=12, origin=13, customs description=14, carton=15,
147
+ // carton weight=16, cartons per pallet=17, units per carton=18.
148
+ const numericHeader = (label: string) => () => <span className="block text-right">{label}</span>
149
+ const rowLabel = (r: BulkVariantsRow) => r.variantLabel ?? labels.variantDefault
150
+
151
+ const defs: ColumnDef<BulkVariantsRow>[] = [
152
+ {
153
+ id: 'variant',
154
+ header: labels.variant,
155
+ cell: ({ row }) => (
156
+ <ReadOnlyCell className="whitespace-nowrap text-muted-foreground">
157
+ {rowLabel(row.original)}
158
+ </ReadOnlyCell>
159
+ ),
160
+ },
161
+ {
162
+ id: 'sku',
163
+ header: labels.sku,
164
+ cell: ({ row }) => (
165
+ <TextCell
166
+ coords={{ row: row.index, col: 1 }}
167
+ value={row.original.sku ?? null}
168
+ onChange={(next) => onChange(row.original.id, { field: 'sku', value: next })}
169
+ ariaLabel={`${labels.sku} — ${rowLabel(row.original)}`}
170
+ />
171
+ ),
172
+ },
173
+ {
174
+ id: 'barcode',
175
+ header: labels.barcode,
176
+ cell: ({ row }) => (
177
+ <TextCell
178
+ coords={{ row: row.index, col: 2 }}
179
+ value={row.original.barcode ?? null}
180
+ onChange={(next) => onChange(row.original.id, { field: 'barcode', value: next })}
181
+ ariaLabel={`${labels.barcode} — ${rowLabel(row.original)}`}
182
+ />
183
+ ),
184
+ },
185
+ ...DECIMAL_COLUMNS.map<ColumnDef<BulkVariantsRow>>(({ field, col }) => ({
186
+ id: field,
187
+ header: numericHeader(labels[field]),
188
+ cell: ({ row }) => (
189
+ <DecimalCell
190
+ coords={{ row: row.index, col }}
191
+ value={row.original[field] ?? null}
192
+ onChange={(next) => onChange(row.original.id, { field, value: next })}
193
+ ariaLabel={`${labels[field]} — ${rowLabel(row.original)}`}
194
+ />
195
+ ),
196
+ })),
197
+ {
198
+ id: 'weight_unit',
199
+ header: labels.weightUnit,
200
+ cell: ({ row }) => (
201
+ <SelectCell
202
+ coords={{ row: row.index, col: 4 }}
203
+ value={row.original.weightUnit ?? null}
204
+ options={weightUnitOptions}
205
+ nullLabel={labels.unitDefault}
206
+ onChange={(next) => onChange(row.original.id, { field: 'weightUnit', value: next })}
207
+ ariaLabel={`${labels.weightUnit} — ${rowLabel(row.original)}`}
208
+ />
209
+ ),
210
+ },
211
+ {
212
+ id: 'dimensions_unit',
213
+ header: labels.dimensionsUnit,
214
+ cell: ({ row }) => (
215
+ <SelectCell
216
+ coords={{ row: row.index, col: 8 }}
217
+ value={row.original.dimensionsUnit ?? null}
218
+ options={dimensionUnitOptions}
219
+ nullLabel={labels.unitDefault}
220
+ onChange={(next) => onChange(row.original.id, { field: 'dimensionsUnit', value: next })}
221
+ ariaLabel={`${labels.dimensionsUnit} — ${rowLabel(row.original)}`}
222
+ />
223
+ ),
224
+ },
225
+ {
226
+ id: 'preorderable',
227
+ header: labels.preorderable,
228
+ cell: ({ row }) => (
229
+ <SwitchCell
230
+ coords={{ row: row.index, col: 9 }}
231
+ value={row.original.preorderable ?? false}
232
+ onChange={(next) => onChange(row.original.id, { field: 'preorderable', value: next })}
233
+ ariaLabel={`${labels.preorderable} — ${rowLabel(row.original)}`}
234
+ />
235
+ ),
236
+ },
237
+ {
238
+ id: 'backorder_limit',
239
+ header: numericHeader(labels.backorderLimit),
240
+ cell: ({ row }) => (
241
+ <DecimalCell
242
+ coords={{ row: row.index, col: 10 }}
243
+ value={row.original.backorderLimit ?? null}
244
+ onChange={(next) => onChange(row.original.id, { field: 'backorderLimit', value: next })}
245
+ ariaLabel={`${labels.backorderLimit} — ${rowLabel(row.original)}`}
246
+ />
247
+ ),
248
+ },
249
+ ]
250
+
251
+ if (taxCategoryOptions) {
252
+ defs.push({
253
+ id: 'tax_category',
254
+ header: labels.taxCategory,
255
+ cell: ({ row }) => (
256
+ <SelectCell
257
+ coords={{ row: row.index, col: 11 }}
258
+ value={row.original.taxCategoryId ?? null}
259
+ options={taxCategoryOptions}
260
+ nullLabel={labels.taxCategoryNone}
261
+ onChange={(next) => onChange(row.original.id, { field: 'taxCategoryId', value: next })}
262
+ ariaLabel={`${labels.taxCategory} — ${rowLabel(row.original)}`}
263
+ />
264
+ ),
265
+ })
266
+ }
267
+
268
+ // Customs columns travel together — classification is only meaningful
269
+ // alongside the origin it is declared with.
270
+ if (countryOptions) {
271
+ defs.push(
272
+ {
273
+ id: 'hs_code',
274
+ header: labels.hsCode,
275
+ cell: ({ row }) => (
276
+ <TextCell
277
+ coords={{ row: row.index, col: 12 }}
278
+ value={row.original.hsCode ?? null}
279
+ onChange={(next) => onChange(row.original.id, { field: 'hsCode', value: next })}
280
+ ariaLabel={`${labels.hsCode} — ${rowLabel(row.original)}`}
281
+ />
282
+ ),
283
+ },
284
+ {
285
+ id: 'country_of_origin',
286
+ header: labels.countryOfOrigin,
287
+ cell: ({ row }) => (
288
+ <SelectCell
289
+ coords={{ row: row.index, col: 13 }}
290
+ value={row.original.countryOfOrigin ?? null}
291
+ options={countryOptions}
292
+ nullLabel={labels.countryOfOriginNone}
293
+ onChange={(next) =>
294
+ onChange(row.original.id, { field: 'countryOfOrigin', value: next })
295
+ }
296
+ ariaLabel={`${labels.countryOfOrigin} — ${rowLabel(row.original)}`}
297
+ />
298
+ ),
299
+ },
300
+ {
301
+ id: 'customs_description',
302
+ header: labels.customsDescription,
303
+ cell: ({ row }) => (
304
+ <TextCell
305
+ coords={{ row: row.index, col: 14 }}
306
+ value={row.original.customsDescription ?? null}
307
+ onChange={(next) =>
308
+ onChange(row.original.id, { field: 'customsDescription', value: next })
309
+ }
310
+ ariaLabel={`${labels.customsDescription} — ${rowLabel(row.original)}`}
311
+ />
312
+ ),
313
+ },
314
+ )
315
+ }
316
+
317
+ // The packing chain travels together: a carton weight or a pallet count
318
+ // means nothing without the carton they describe.
319
+ if (cartonOptions) {
320
+ defs.push(
321
+ {
322
+ id: 'units_per_carton',
323
+ header: numericHeader(labels.unitsPerCarton),
324
+ cell: ({ row }) => (
325
+ <DecimalCell
326
+ coords={{ row: row.index, col: 18 }}
327
+ value={row.original.unitsPerCarton ?? null}
328
+ onChange={(next) =>
329
+ onChange(row.original.id, { field: 'unitsPerCarton', value: next })
330
+ }
331
+ ariaLabel={`${labels.unitsPerCarton} — ${rowLabel(row.original)}`}
332
+ />
333
+ ),
334
+ },
335
+ {
336
+ id: 'carton_package_type',
337
+ header: labels.cartonPackageType,
338
+ cell: ({ row }) => (
339
+ <SelectCell
340
+ coords={{ row: row.index, col: 15 }}
341
+ value={row.original.cartonPackageTypeId ?? null}
342
+ options={cartonOptions}
343
+ nullLabel={labels.cartonPackageTypeNone}
344
+ onChange={(next) =>
345
+ onChange(row.original.id, { field: 'cartonPackageTypeId', value: next })
346
+ }
347
+ ariaLabel={`${labels.cartonPackageType} — ${rowLabel(row.original)}`}
348
+ />
349
+ ),
350
+ },
351
+ {
352
+ id: 'carton_weight',
353
+ header: numericHeader(labels.cartonWeight),
354
+ cell: ({ row }) => (
355
+ <DecimalCell
356
+ coords={{ row: row.index, col: 16 }}
357
+ value={row.original.cartonWeight ?? null}
358
+ onChange={(next) => onChange(row.original.id, { field: 'cartonWeight', value: next })}
359
+ ariaLabel={`${labels.cartonWeight} — ${rowLabel(row.original)}`}
360
+ />
361
+ ),
362
+ },
363
+ {
364
+ id: 'cartons_per_pallet',
365
+ header: numericHeader(labels.cartonsPerPallet),
366
+ cell: ({ row }) => (
367
+ <DecimalCell
368
+ coords={{ row: row.index, col: 17 }}
369
+ value={row.original.cartonsPerPallet ?? null}
370
+ onChange={(next) =>
371
+ onChange(row.original.id, { field: 'cartonsPerPallet', value: next })
372
+ }
373
+ ariaLabel={`${labels.cartonsPerPallet} — ${rowLabel(row.original)}`}
374
+ />
375
+ ),
376
+ },
377
+ )
378
+ }
379
+
380
+ // Reorder to visual sequence: the coordinate map above is stable, but
381
+ // the defs array built column groups out of order for concision.
382
+ const order = [
383
+ 'variant',
384
+ 'sku',
385
+ 'barcode',
386
+ 'weight',
387
+ 'weight_unit',
388
+ 'height',
389
+ 'width',
390
+ 'depth',
391
+ 'dimensions_unit',
392
+ 'preorderable',
393
+ 'backorder_limit',
394
+ 'tax_category',
395
+ 'hs_code',
396
+ 'country_of_origin',
397
+ 'customs_description',
398
+ 'units_per_carton',
399
+ 'carton_package_type',
400
+ 'carton_weight',
401
+ 'cartons_per_pallet',
402
+ ]
403
+ return defs.sort((a, b) => order.indexOf(a.id as string) - order.indexOf(b.id as string))
404
+ }, [
405
+ onChange,
406
+ labels,
407
+ weightUnitOptions,
408
+ dimensionUnitOptions,
409
+ taxCategoryOptions,
410
+ countryOptions,
411
+ cartonOptions,
412
+ ])
413
+
414
+ // Roughly 130px a column, which is what the widest headers here ("Available
415
+ // for pre-order", "Customs description") need before they wrap to two lines.
416
+ // The grid scrolls sideways past that rather than squeezing them.
417
+ const minWidth = cartonOptions
418
+ ? 'min-w-[2470px]'
419
+ : countryOptions
420
+ ? 'min-w-[1950px]'
421
+ : 'min-w-[1560px]'
422
+
423
+ return (
424
+ <DataGrid<BulkVariantsRow>
425
+ rows={rows}
426
+ columns={columns}
427
+ getRowId={(row) => row.id}
428
+ className={minWidth}
429
+ aria-label={labels.gridAriaLabel}
430
+ // Sets a min-width well past any viewport, so it has to scroll itself.
431
+ scrollable
432
+ />
433
+ )
434
+ }
@@ -13,7 +13,11 @@
13
13
  * draft edits update the row immediately, before any save.
14
14
  */
15
15
  export interface CalculatorPayload {
16
- /** Human-readable name (e.g. "Flat Rate"). Optional; if missing the type is demodulized. */
16
+ /**
17
+ * Display name for the calculator, already localized by the caller — this
18
+ * package is headless and does not resolve translations itself. Optional;
19
+ * if missing the type is demodulized.
20
+ */
17
21
  label?: string | null
18
22
  /** STI class name (e.g. "Spree::Calculator::FlatRate"). Used as a label fallback. */
19
23
  type?: string | null
@@ -0,0 +1,219 @@
1
+ import { useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Button } from '../ui/button'
4
+ import {
5
+ Dialog,
6
+ DialogBody,
7
+ DialogContent,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '../ui/dialog'
12
+ import { Field, FieldLabel } from '../ui/field'
13
+ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
14
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
15
+ import { Switch } from '../ui/switch'
16
+ import type { RefundMethod } from './return-dialogs'
17
+
18
+ /** How a claim is settled. */
19
+ export type ClaimResolution = 'refund' | 'replacement' | 'refund_and_replacement'
20
+
21
+ /** One claimed line, as the resolve form needs it. */
22
+ export type ClaimLineForResolve = {
23
+ id: string
24
+ label: string
25
+ quantity: number
26
+ /** Whether the claim was opened asking for this line to be replaced. */
27
+ sendReplacement: boolean
28
+ }
29
+
30
+ /**
31
+ * Settles a claim: money back, a replacement shipment, or both.
32
+ *
33
+ * The resolution is chosen here rather than at claim creation, so the
34
+ * merchant decides how to make things right once they have the facts. The
35
+ * currency symbol comes from the caller — the operator reads it from the
36
+ * store, and a seller has no currency of their own.
37
+ *
38
+ * Shared by the operator's order page and the seller's.
39
+ */
40
+ export function ClaimResolveDialog({
41
+ lines,
42
+ defaultAmount,
43
+ currencySymbol,
44
+ onClose,
45
+ onSubmit,
46
+ pending = false,
47
+ }: {
48
+ lines: ClaimLineForResolve[]
49
+ /** What to offer as the refund, already resolved by the caller. */
50
+ defaultAmount: string
51
+ currencySymbol: string
52
+ onClose: () => void
53
+ onSubmit: (params: {
54
+ resolution: ClaimResolution
55
+ refundMethod: RefundMethod
56
+ amount?: string
57
+ replacementLineItemIds: string[]
58
+ }) => void
59
+ pending?: boolean
60
+ }) {
61
+ const { t } = useTranslation()
62
+
63
+ const [resolution, setResolution] = useState<ClaimResolution>('refund')
64
+ const [refundMethod, setRefundMethod] = useState<RefundMethod>('store_credit')
65
+ const [amount, setAmount] = useState(defaultAmount)
66
+ const [replacing, setReplacing] = useState<Record<string, boolean>>(() =>
67
+ Object.fromEntries(lines.map((line) => [line.id, line.sendReplacement])),
68
+ )
69
+
70
+ const refunding = resolution.includes('refund')
71
+ const sendingReplacement = resolution.includes('replacement')
72
+ const chosenReplacements = Object.entries(replacing)
73
+ .filter(([, on]) => on)
74
+ .map(([id]) => id)
75
+
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
+ )
82
+
83
+ const methodOptions = [
84
+ {
85
+ value: 'store_credit',
86
+ label: t('admin.pages.orders.detail.returns.refund_methods.store_credit'),
87
+ },
88
+ {
89
+ value: 'original_payment',
90
+ label: t('admin.pages.orders.detail.returns.refund_methods.original_payment'),
91
+ },
92
+ ]
93
+
94
+ // Refunding nothing, or replacing nothing, is what the server rejects —
95
+ // say so here instead of letting the request fail.
96
+ const ready =
97
+ (!refunding || Number(amount) > 0) && (!sendingReplacement || chosenReplacements.length > 0)
98
+
99
+ return (
100
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
101
+ <DialogContent>
102
+ <DialogHeader>
103
+ <DialogTitle>{t('admin.pages.orders.detail.claims.resolve_title')}</DialogTitle>
104
+ </DialogHeader>
105
+ <DialogBody className="flex flex-col gap-4">
106
+ <Field>
107
+ <FieldLabel htmlFor="claim-resolution">
108
+ {t('admin.pages.orders.detail.claims.resolution')}
109
+ </FieldLabel>
110
+ <Select
111
+ items={resolutionOptions}
112
+ value={resolution}
113
+ onValueChange={(value) => setResolution(value as ClaimResolution)}
114
+ >
115
+ <SelectTrigger id="claim-resolution">
116
+ <SelectValue />
117
+ </SelectTrigger>
118
+ <SelectContent>
119
+ {resolutionOptions.map((option) => (
120
+ <SelectItem key={option.value} value={option.value}>
121
+ {option.label}
122
+ </SelectItem>
123
+ ))}
124
+ </SelectContent>
125
+ </Select>
126
+ </Field>
127
+
128
+ {refunding && (
129
+ <>
130
+ <Field>
131
+ <FieldLabel htmlFor="claim-resolve-amount">
132
+ {t('admin.pages.orders.detail.claims.refund_amount')}
133
+ </FieldLabel>
134
+ <InputGroup>
135
+ <InputGroupAddon>
136
+ <InputGroupText>{currencySymbol}</InputGroupText>
137
+ </InputGroupAddon>
138
+ <InputGroupInput
139
+ id="claim-resolve-amount"
140
+ type="number"
141
+ step="0.01"
142
+ min="0"
143
+ value={amount}
144
+ onChange={(event) => setAmount(event.target.value)}
145
+ />
146
+ </InputGroup>
147
+ </Field>
148
+ <Field>
149
+ <FieldLabel htmlFor="claim-refund-method">
150
+ {t('admin.pages.orders.detail.returns.refund_method')}
151
+ </FieldLabel>
152
+ <Select
153
+ items={methodOptions}
154
+ value={refundMethod}
155
+ onValueChange={(value) => setRefundMethod(value as RefundMethod)}
156
+ >
157
+ <SelectTrigger id="claim-refund-method">
158
+ <SelectValue />
159
+ </SelectTrigger>
160
+ <SelectContent>
161
+ {methodOptions.map((option) => (
162
+ <SelectItem key={option.value} value={option.value}>
163
+ {option.label}
164
+ </SelectItem>
165
+ ))}
166
+ </SelectContent>
167
+ </Select>
168
+ </Field>
169
+ </>
170
+ )}
171
+
172
+ {sendingReplacement && (
173
+ <div className="flex flex-col gap-2">
174
+ <span className="font-medium text-sm">
175
+ {t('admin.pages.orders.detail.claims.replacement_items')}
176
+ </span>
177
+ {lines.map((line) => (
178
+ <div
179
+ key={line.id}
180
+ className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle p-3"
181
+ >
182
+ <span className="truncate text-sm">
183
+ {line.label}
184
+ <span className="text-muted-foreground"> ×{line.quantity}</span>
185
+ </span>
186
+ <Switch
187
+ checked={replacing[line.id] ?? false}
188
+ onCheckedChange={(checked) =>
189
+ setReplacing({ ...replacing, [line.id]: checked })
190
+ }
191
+ />
192
+ </div>
193
+ ))}
194
+ </div>
195
+ )}
196
+ </DialogBody>
197
+ <DialogFooter>
198
+ <Button type="button" variant="outline" onClick={onClose} disabled={pending}>
199
+ {t('admin.actions.cancel')}
200
+ </Button>
201
+ <Button
202
+ type="button"
203
+ disabled={!ready || pending}
204
+ onClick={() =>
205
+ onSubmit({
206
+ resolution,
207
+ refundMethod,
208
+ amount: refunding ? amount : undefined,
209
+ replacementLineItemIds: chosenReplacements,
210
+ })
211
+ }
212
+ >
213
+ {t('admin.pages.orders.detail.claims.actions.resolve')}
214
+ </Button>
215
+ </DialogFooter>
216
+ </DialogContent>
217
+ </Dialog>
218
+ )
219
+ }
@@ -64,7 +64,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
64
64
  <DialogTitle>{options.title ?? t('admin.components.confirm_dialog.title')}</DialogTitle>
65
65
  <DialogDescription>{options.message}</DialogDescription>
66
66
  </DialogHeader>
67
- <DialogFooter>
67
+ <DialogFooter className="border-0">
68
68
  <Button variant="outline" onClick={() => handleClose(false)}>
69
69
  {options.cancelLabel ?? t('admin.actions.cancel')}
70
70
  </Button>
@@ -1,8 +1,8 @@
1
- import { CheckIcon, CopyIcon } from 'lucide-react'
2
1
  import type { ComponentProps, ReactNode } from 'react'
3
2
  import { useCopyToClipboard } from '../hooks/use-copy-to-clipboard'
4
3
  import { cn } from '../lib/utils'
5
4
  import { Button } from '../ui/button'
5
+ import { CheckIcon, CopyIcon } from './icons'
6
6
 
7
7
  type ButtonProps = ComponentProps<typeof Button>
8
8