@spree/dashboard-ui 0.13.0 → 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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/package.json +15 -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 +39 -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/address-map.tsx +62 -0
  11. package/src/spree/back-button.tsx +14 -9
  12. package/src/spree/bulk-dialog.tsx +2 -2
  13. package/src/spree/bulk-price-table.tsx +145 -14
  14. package/src/spree/bulk-variants-table.tsx +434 -0
  15. package/src/spree/calculator-summary.tsx +5 -1
  16. package/src/spree/claim-resolve-dialog.tsx +219 -0
  17. package/src/spree/confirm-dialog.tsx +1 -1
  18. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  19. package/src/spree/data-grid/cells.tsx +199 -25
  20. package/src/spree/data-grid/context.ts +6 -0
  21. package/src/spree/data-grid/data-grid.tsx +149 -7
  22. package/src/spree/data-grid/helpers.ts +6 -2
  23. package/src/spree/data-grid/index.ts +9 -1
  24. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  25. package/src/spree/delivery-form-dialog.tsx +166 -0
  26. package/src/spree/drag-handle.tsx +2 -2
  27. package/src/spree/editable-note-card.tsx +103 -0
  28. package/src/spree/external-link.tsx +56 -0
  29. package/src/spree/file-type-icon.tsx +68 -0
  30. package/src/spree/form-actions.tsx +2 -3
  31. package/src/spree/form-section.tsx +46 -0
  32. package/src/spree/fulfill-items-form.tsx +282 -0
  33. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  34. package/src/spree/fulfillment-item-list.tsx +61 -0
  35. package/src/spree/fulfillment-panel.tsx +53 -0
  36. package/src/spree/icons.ts +167 -0
  37. package/src/spree/json-preview-drawer.tsx +4 -4
  38. package/src/spree/label-upload-dialog.tsx +195 -0
  39. package/src/spree/language-menu-items.tsx +37 -30
  40. package/src/spree/metadata/metadata-card.tsx +1 -1
  41. package/src/spree/order-cancel-dialog.tsx +149 -0
  42. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  43. package/src/spree/post-sale-fields.tsx +133 -0
  44. package/src/spree/product-membership-list.tsx +453 -0
  45. package/src/spree/products/status-card.tsx +79 -0
  46. package/src/spree/quantity-tier-editor.tsx +162 -0
  47. package/src/spree/resource-combobox.tsx +12 -1
  48. package/src/spree/resource-name-cell.tsx +21 -4
  49. package/src/spree/return-dialogs.tsx +238 -0
  50. package/src/spree/route-error-boundary.tsx +1 -1
  51. package/src/spree/row-actions.tsx +2 -2
  52. package/src/spree/search-input.tsx +76 -0
  53. package/src/spree/secret-input.tsx +26 -40
  54. package/src/spree/tax-identifiers-card.tsx +418 -0
  55. package/src/spree/theme-toggle.tsx +36 -23
  56. package/src/spree/wizard.tsx +254 -0
  57. package/src/styles.css +750 -75
  58. package/src/ui/accordion.tsx +83 -0
  59. package/src/ui/alert.tsx +79 -0
  60. package/src/ui/attachment.tsx +1 -1
  61. package/src/ui/badge.tsx +198 -56
  62. package/src/ui/breadcrumb.tsx +1 -1
  63. package/src/ui/button.tsx +22 -10
  64. package/src/ui/calendar.tsx +1 -1
  65. package/src/ui/card.tsx +52 -6
  66. package/src/ui/checkbox.tsx +13 -5
  67. package/src/ui/combobox.tsx +125 -6
  68. package/src/ui/command.tsx +149 -23
  69. package/src/ui/context-menu.tsx +99 -0
  70. package/src/ui/data-table.tsx +448 -9
  71. package/src/ui/date-picker.tsx +18 -3
  72. package/src/ui/date-range-picker.tsx +47 -60
  73. package/src/ui/dialog.tsx +6 -6
  74. package/src/ui/dropdown-menu.tsx +18 -11
  75. package/src/ui/field.tsx +10 -2
  76. package/src/ui/input-group.tsx +1 -1
  77. package/src/ui/input.tsx +1 -1
  78. package/src/ui/map.tsx +2110 -0
  79. package/src/ui/media-preview.tsx +105 -0
  80. package/src/ui/pagination.tsx +19 -11
  81. package/src/ui/popover.tsx +4 -2
  82. package/src/ui/progress.tsx +5 -2
  83. package/src/ui/radio-group.tsx +4 -4
  84. package/src/ui/rich-text-editor.tsx +214 -88
  85. package/src/ui/scroll-area.tsx +65 -0
  86. package/src/ui/select.tsx +17 -10
  87. package/src/ui/separator.tsx +1 -1
  88. package/src/ui/sheet.tsx +49 -7
  89. package/src/ui/sidebar.tsx +138 -37
  90. package/src/ui/skeleton.tsx +3 -1
  91. package/src/ui/tabs.tsx +84 -0
  92. package/src/ui/textarea.tsx +1 -1
  93. package/src/ui/thumbnail.tsx +80 -0
  94. package/src/ui/toast.tsx +112 -0
  95. package/src/ui/tooltip.tsx +3 -1
  96. package/src/ui/sonner.tsx +0 -47
@@ -0,0 +1,453 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors,
9
+ } from '@dnd-kit/core'
10
+ import {
11
+ arrayMove,
12
+ SortableContext,
13
+ sortableKeyboardCoordinates,
14
+ useSortable,
15
+ verticalListSortingStrategy,
16
+ } from '@dnd-kit/sortable'
17
+ import { CSS } from '@dnd-kit/utilities'
18
+ import { Fragment, type ReactNode, useEffect, useMemo, useState } from 'react'
19
+ import { cn } from '../lib/utils'
20
+ import { Badge } from '../ui/badge'
21
+ import { Button } from '../ui/button'
22
+ import { Checkbox } from '../ui/checkbox'
23
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/data-table'
24
+ import { Pagination, type PaginationMeta } from '../ui/pagination'
25
+ import { Thumbnail } from '../ui/thumbnail'
26
+ import { DragHandle } from './drag-handle'
27
+ import { Undo2Icon, XIcon } from './icons'
28
+ import { SearchInput } from './search-input'
29
+
30
+ /** One product row in a membership list. */
31
+ /**
32
+ * How many cells a sub-row must emit before and after the extra columns to
33
+ * line up with the product rows above it. Reported by the list rather than
34
+ * counted by the caller, since the leading cells depend on whether the list
35
+ * is curatable and reorderable.
36
+ */
37
+ export interface SubRowLayout {
38
+ /** Cells before the product-name column (selection checkbox, drag handle). */
39
+ leadingCells: number
40
+ /** How many extra columns the caller's own `extraColumns` set contributes. */
41
+ extraColumnCount: number
42
+ /** Cells after the extra columns (the row-action column). */
43
+ trailingCells: number
44
+ }
45
+
46
+ export interface ProductMembershipRow {
47
+ id: string
48
+ name?: string | null
49
+ thumbnailUrl?: string | null
50
+ /**
51
+ * Deferred-editing state: `added` rows are staged locally and not yet
52
+ * persisted (badged, not draggable); `removed` rows are struck through and
53
+ * their remove button becomes a restore.
54
+ */
55
+ pending?: 'added' | 'removed'
56
+ }
57
+
58
+ /** All user-visible copy, so the component stays free of locale namespaces. */
59
+ export interface ProductMembershipListLabels {
60
+ filterPlaceholder: string
61
+ clearFilter: string
62
+ noMatches: string
63
+ empty: string
64
+ loading: string
65
+ columnProduct: string
66
+ selectAll: string
67
+ selectRow: string
68
+ remove: string
69
+ /** Required when any row can be `pending: 'removed'`. */
70
+ restore?: string
71
+ /** Badge on `pending: 'added'` rows. */
72
+ pendingAddedBadge?: string
73
+ }
74
+
75
+ export interface ProductMembershipListProps {
76
+ rows: ProductMembershipRow[]
77
+ labels: ProductMembershipListLabels
78
+ loading?: boolean
79
+ meta?: PaginationMeta | null
80
+ onPageChange?: (page: number) => void
81
+ /** Hides checkboxes, drag handles and remove buttons. */
82
+ readOnly?: boolean
83
+ /** Controlled selection — the host renders the bulk actions that consume it. */
84
+ selected?: string[]
85
+ onSelectedChange?: (ids: string[]) => void
86
+ onRemove?: (id: string) => void
87
+ onRestore?: (id: string) => void
88
+ /** Enables drag-to-reorder. Filtered views never reorder. */
89
+ reorderable?: boolean
90
+ /**
91
+ * Persist a drag. `position` is the 0-based index across the whole set
92
+ * (page offset already applied). A rejected promise rolls the row back.
93
+ */
94
+ onReorder?: (id: string, position: number) => Promise<unknown> | undefined
95
+ /** Row title cell — pass a link to the product page; defaults to plain text. */
96
+ renderTitle?: (row: ProductMembershipRow) => ReactNode
97
+ /**
98
+ * Extra columns between the product name and the remove button, for a
99
+ * parent whose membership carries data of its own — a catalog's quantity
100
+ * terms, say. Headers and cells are passed separately so the host stays in
101
+ * charge of what a row means to it; both must return the same number of
102
+ * cells or the table misaligns.
103
+ */
104
+ /**
105
+ * Rows rendered beneath a product's own, spanning the same columns — a
106
+ * catalog lists each variant's price this way, since one figure on the
107
+ * product row names a single variant's deal and hides the others
108
+ * (docs/plans/6.0-volume-pricing.md). Omitted by every other consumer, so
109
+ * their rows are unchanged.
110
+ */
111
+ renderSubRows?: (row: ProductMembershipRow, layout: SubRowLayout) => ReactNode
112
+ extraColumns?: {
113
+ /**
114
+ * How many columns this set contributes. Stated rather than counted from
115
+ * the rendered headers, so a sub-row can span the columns it does not
116
+ * fill without the table coming up short.
117
+ */
118
+ columnCount?: number
119
+ headers: ReactNode
120
+ renderCells: (row: ProductMembershipRow) => ReactNode
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The shared membership table for "products in a grouping" panels —
126
+ * categories, collections, catalogs, price lists. Headless: rows, selection
127
+ * and every mutation come through props; the host owns data fetching, the
128
+ * picker, confirms and save semantics (immediate writes or form state).
129
+ *
130
+ * Renders the client-side quick filter, the selectable rows with thumbnails,
131
+ * optional drag-to-reorder with optimistic rollback, and pagination.
132
+ */
133
+ export function ProductMembershipList({
134
+ rows,
135
+ labels,
136
+ loading = false,
137
+ meta,
138
+ onPageChange,
139
+ readOnly = false,
140
+ selected = [],
141
+ onSelectedChange,
142
+ onRemove,
143
+ onRestore,
144
+ reorderable = false,
145
+ onReorder,
146
+ renderTitle,
147
+ renderSubRows,
148
+ extraColumns,
149
+ }: ProductMembershipListProps) {
150
+ const [query, setQuery] = useState('')
151
+
152
+ // Local order mirrors the incoming rows so a drag re-renders instantly;
153
+ // a resolved onReorder re-syncs through the next rows prop.
154
+ const [order, setOrder] = useState<ProductMembershipRow[]>(rows)
155
+
156
+ useEffect(() => {
157
+ setOrder(rows)
158
+ }, [rows])
159
+
160
+ const sensors = useSensors(
161
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
162
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
163
+ )
164
+
165
+ const ids = useMemo(() => order.map((row) => row.id), [order])
166
+
167
+ // Client-side quick filter over the current page. While a filter is active,
168
+ // drag-reorder is disabled — reordering a filtered subset would compute
169
+ // positions against the wrong neighbours.
170
+ const trimmedQuery = query.trim().toLowerCase()
171
+ const filtering = trimmedQuery.length > 0
172
+ const visible = useMemo(
173
+ () =>
174
+ filtering
175
+ ? order.filter((row) => (row.name ?? '').toLowerCase().includes(trimmedQuery))
176
+ : order,
177
+ [order, filtering, trimmedQuery],
178
+ )
179
+
180
+ // Rows a checkbox makes sense for — pending removals restore instead.
181
+ const selectableIds = useMemo(
182
+ () => visible.filter((row) => row.pending !== 'removed').map((row) => row.id),
183
+ [visible],
184
+ )
185
+ const selectedSet = useMemo(() => new Set(selected), [selected])
186
+ const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedSet.has(id))
187
+
188
+ function toggleSelected(id: string) {
189
+ if (!onSelectedChange) return
190
+ onSelectedChange(
191
+ selectedSet.has(id) ? selected.filter((existing) => existing !== id) : [...selected, id],
192
+ )
193
+ }
194
+
195
+ // Operate only on the currently-visible (filtered) rows so "select all"
196
+ // never pulls in hidden products a later bulk action would touch unseen.
197
+ function toggleAll() {
198
+ if (!onSelectedChange) return
199
+ if (allSelected) {
200
+ const visibleSet = new Set(selectableIds)
201
+ onSelectedChange(selected.filter((id) => !visibleSet.has(id)))
202
+ } else {
203
+ onSelectedChange([...new Set([...selected, ...selectableIds])])
204
+ }
205
+ }
206
+
207
+ function handleDragEnd(event: DragEndEvent) {
208
+ const { active, over } = event
209
+ if (!over || active.id === over.id || !onReorder) return
210
+ const from = order.findIndex((row) => row.id === active.id)
211
+ const to = order.findIndex((row) => row.id === over.id)
212
+ if (from === -1 || to === -1) return
213
+ const previousOrder = order
214
+ setOrder(arrayMove(order, from, to)) // optimistic
215
+ // `to` is an index within the current page; the caller wants a 0-based
216
+ // index across the whole grouping. `meta.from` is 1-based.
217
+ const result = onReorder(String(active.id), (meta?.from ?? 1) - 1 + to)
218
+ Promise.resolve(result).catch(() => setOrder(previousOrder))
219
+ }
220
+
221
+ const curatable = !readOnly
222
+
223
+ // The leading cells are conditional, so the list states the shape rather
224
+ // than leaving each caller to re-derive it and drift.
225
+ const subRowLayout: SubRowLayout = {
226
+ leadingCells: (curatable ? 1 : 0) + (reorderable ? 1 : 0),
227
+ extraColumnCount: extraColumns?.columnCount ?? 0,
228
+ trailingCells: 1,
229
+ }
230
+
231
+ if (loading) {
232
+ return <p className="p-6 text-muted-foreground text-sm">{labels.loading}</p>
233
+ }
234
+
235
+ if (order.length === 0) {
236
+ return <p className="p-6 text-muted-foreground text-sm">{labels.empty}</p>
237
+ }
238
+
239
+ return (
240
+ <>
241
+ <div className="border-border-subtle border-b p-3">
242
+ <SearchInput
243
+ value={query}
244
+ onValueChange={setQuery}
245
+ onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
246
+ placeholder={labels.filterPlaceholder}
247
+ clearLabel={labels.clearFilter}
248
+ />
249
+ </div>
250
+ {visible.length === 0 ? (
251
+ <p className="p-6 text-muted-foreground text-sm">{labels.noMatches}</p>
252
+ ) : (
253
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
254
+ <Table>
255
+ <TableHeader>
256
+ <TableRow>
257
+ {curatable && (
258
+ <TableHead className="w-10">
259
+ <Checkbox
260
+ checked={allSelected}
261
+ onCheckedChange={toggleAll}
262
+ aria-label={labels.selectAll}
263
+ />
264
+ </TableHead>
265
+ )}
266
+ {reorderable && <TableHead className="w-8" />}
267
+ {/* `w-full max-w-0`: table layout sizes a column to its
268
+ content, so a long product or variant name would push the
269
+ columns beside it out of the card — which clips them
270
+ rather than scrolling. This makes the product column take
271
+ whatever is left instead, and its content truncate. */}
272
+ <TableHead className="w-full max-w-0">{labels.columnProduct}</TableHead>
273
+ {extraColumns?.headers}
274
+ <TableHead className="w-10" />
275
+ </TableRow>
276
+ </TableHeader>
277
+ <SortableContext items={ids} strategy={verticalListSortingStrategy}>
278
+ {/* No border-t: TableHead draws the rule as an inset shadow,
279
+ and a border alongside it stacks into a 2px line. */}
280
+ <TableBody>
281
+ {visible.map((row) => (
282
+ <MembershipRow
283
+ key={row.id}
284
+ row={row}
285
+ curatable={curatable}
286
+ // Reorder only makes sense over the full page of settled
287
+ // rows; staged rows have no position yet.
288
+ reorderable={reorderable && curatable && !filtering && !row.pending}
289
+ showDragColumn={reorderable}
290
+ selected={selectedSet.has(row.id)}
291
+ onToggleSelected={() => toggleSelected(row.id)}
292
+ onRemove={onRemove ? () => onRemove(row.id) : undefined}
293
+ onRestore={onRestore ? () => onRestore(row.id) : undefined}
294
+ labels={labels}
295
+ renderTitle={renderTitle}
296
+ extraCells={extraColumns?.renderCells(row)}
297
+ subRows={renderSubRows?.(row, subRowLayout)}
298
+ />
299
+ ))}
300
+ </TableBody>
301
+ </SortableContext>
302
+ </Table>
303
+ </DndContext>
304
+ )}
305
+ {/* Pagination brings its own top border + padding. */}
306
+ {meta && onPageChange && <Pagination meta={meta} onPageChange={onPageChange} />}
307
+ </>
308
+ )
309
+ }
310
+
311
+ function MembershipRow({
312
+ row,
313
+ curatable,
314
+ reorderable,
315
+ showDragColumn,
316
+ selected,
317
+ onToggleSelected,
318
+ onRemove,
319
+ onRestore,
320
+ labels,
321
+ renderTitle,
322
+ extraCells,
323
+ subRows,
324
+ }: {
325
+ row: ProductMembershipRow
326
+ curatable: boolean
327
+ reorderable: boolean
328
+ showDragColumn: boolean
329
+ selected: boolean
330
+ onToggleSelected: () => void
331
+ onRemove?: () => void
332
+ onRestore?: () => void
333
+ labels: ProductMembershipListLabels
334
+ renderTitle?: (row: ProductMembershipRow) => ReactNode
335
+ extraCells?: ReactNode
336
+ subRows?: ReactNode
337
+ }) {
338
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
339
+ id: row.id,
340
+ disabled: !reorderable,
341
+ })
342
+
343
+ const removed = row.pending === 'removed'
344
+ const added = row.pending === 'added'
345
+
346
+ return (
347
+ <>
348
+ <tr
349
+ ref={setNodeRef}
350
+ style={{ transform: CSS.Transform.toString(transform), transition }}
351
+ // Mirror <TableRow> styling — a plain <tr> is required so dnd-kit's
352
+ // setNodeRef attaches (TableRow doesn't forward refs).
353
+ className={cn(
354
+ 'group/row last:*:border-b-0 hover:bg-accent-strong/50',
355
+ selected && 'bg-accent-strong/75 hover:bg-accent-strong-hover',
356
+ isDragging && 'relative z-10 opacity-70',
357
+ removed && 'opacity-60',
358
+ )}
359
+ >
360
+ {curatable && (
361
+ <TableCell>
362
+ {!removed && (
363
+ <Checkbox
364
+ checked={selected}
365
+ onCheckedChange={onToggleSelected}
366
+ aria-label={labels.selectRow}
367
+ />
368
+ )}
369
+ </TableCell>
370
+ )}
371
+ {showDragColumn && (
372
+ <TableCell className="pr-0">
373
+ {reorderable && <DragHandle attributes={attributes} listeners={listeners} />}
374
+ </TableCell>
375
+ )}
376
+ <TableCell className="max-w-0">
377
+ <span className={cn('flex min-w-0 items-center gap-3', removed && 'line-through')}>
378
+ <Thumbnail src={row.thumbnailUrl} size="sm" />
379
+ {renderTitle ? renderTitle(row) : <span className="truncate text-sm">{row.name}</span>}
380
+ {added && labels.pendingAddedBadge && (
381
+ <Badge variant="secondary">{labels.pendingAddedBadge}</Badge>
382
+ )}
383
+ </span>
384
+ </TableCell>
385
+ {extraCells}
386
+ <TableCell className="text-right">
387
+ {curatable &&
388
+ (removed
389
+ ? onRestore && (
390
+ <Button
391
+ type="button"
392
+ variant="ghost"
393
+ size="icon"
394
+ onClick={onRestore}
395
+ aria-label={labels.restore ?? labels.remove}
396
+ >
397
+ <Undo2Icon className="size-4" />
398
+ </Button>
399
+ )
400
+ : onRemove && (
401
+ <Button
402
+ type="button"
403
+ variant="ghost"
404
+ size="icon"
405
+ onClick={onRemove}
406
+ aria-label={labels.remove}
407
+ >
408
+ <XIcon className="size-4" />
409
+ </Button>
410
+ ))}
411
+ </TableCell>
412
+ </tr>
413
+ {/* Sub-rows sit outside the sortable <tr> so dragging a product still
414
+ moves one row, and so a removed product's rows dim with it. */}
415
+ {!removed && subRows}
416
+ </>
417
+ )
418
+ }
419
+
420
+ /**
421
+ * Renders several `extraColumns` sets side by side, for a parent whose rows
422
+ * carry more than one kind of data — a catalog states both what it charges for
423
+ * a product and what quantities it may be ordered in.
424
+ *
425
+ * Composed here rather than at each call site because the contract being kept
426
+ * is this file's: every set must emit the same number of header cells as body
427
+ * cells, or the table misaligns.
428
+ */
429
+ export function mergeExtraColumns(
430
+ ...sets: NonNullable<ProductMembershipListProps['extraColumns']>[]
431
+ ): NonNullable<ProductMembershipListProps['extraColumns']> {
432
+ return {
433
+ columnCount: sets.reduce((total, set) => total + (set.columnCount ?? 0), 0),
434
+ headers: (
435
+ <>
436
+ {sets.map((set, index) => (
437
+ // Column sets are positional and fixed for the life of the table —
438
+ // there is no identity to key on but their order.
439
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional by nature
440
+ <Fragment key={index}>{set.headers}</Fragment>
441
+ ))}
442
+ </>
443
+ ),
444
+ renderCells: (row: ProductMembershipRow) => (
445
+ <>
446
+ {sets.map((set, index) => (
447
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional by nature
448
+ <Fragment key={index}>{set.renderCells(row)}</Fragment>
449
+ ))}
450
+ </>
451
+ ),
452
+ }
453
+ }
@@ -0,0 +1,79 @@
1
+ import type * as React from 'react'
2
+ import type { Control, FieldValues, Path } from 'react-hook-form'
3
+ import { Controller } from 'react-hook-form'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'
5
+ import { Field, FieldLabel } from '../../ui/field'
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'
7
+
8
+ export type StatusOption = { value: string; label: string }
9
+
10
+ /**
11
+ * The product's status, chosen from whatever the caller allows.
12
+ *
13
+ * The options are a prop rather than a constant because the two dashboards
14
+ * offer different ones: an operator publishes directly, while a seller submits
15
+ * for review and can only take a listing back down. Headless, so the labels
16
+ * arrive translated.
17
+ */
18
+ export function StatusCard<TFieldValues extends FieldValues>({
19
+ control,
20
+ name,
21
+ title,
22
+ label,
23
+ options,
24
+ disabled,
25
+ description,
26
+ actions,
27
+ }: {
28
+ control: Control<TFieldValues>
29
+ name: Path<TFieldValues>
30
+ title: string
31
+ label: string
32
+ options: StatusOption[]
33
+ disabled?: boolean
34
+ description?: string
35
+ /**
36
+ * Rendered under the field. The moves out of a status this picker cannot
37
+ * assign live here — a review decision is a mutation, which the panel owns
38
+ * rather than the design system.
39
+ */
40
+ actions?: React.ReactNode
41
+ }) {
42
+ return (
43
+ <Card>
44
+ <CardHeader>
45
+ <CardTitle>{title}</CardTitle>
46
+ </CardHeader>
47
+ <CardContent className="flex flex-col gap-4">
48
+ <Field>
49
+ <FieldLabel>{label}</FieldLabel>
50
+ <Controller
51
+ name={name}
52
+ control={control}
53
+ render={({ field }) => (
54
+ <Select
55
+ items={options as never}
56
+ value={field.value}
57
+ onValueChange={field.onChange}
58
+ disabled={disabled}
59
+ >
60
+ <SelectTrigger className="w-full">
61
+ <SelectValue />
62
+ </SelectTrigger>
63
+ <SelectContent>
64
+ {options.map((option) => (
65
+ <SelectItem key={option.value} value={option.value}>
66
+ {option.label}
67
+ </SelectItem>
68
+ ))}
69
+ </SelectContent>
70
+ </Select>
71
+ )}
72
+ />
73
+ {description && <p className="text-muted-foreground text-sm">{description}</p>}
74
+ </Field>
75
+ {actions && <div className="flex flex-wrap gap-2">{actions}</div>}
76
+ </CardContent>
77
+ </Card>
78
+ )
79
+ }
@@ -0,0 +1,162 @@
1
+ import { PlusIcon, XIcon } from 'lucide-react'
2
+ import { Button } from '../ui/button'
3
+ import { Input } from '../ui/input'
4
+ import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
5
+
6
+ /**
7
+ * One rung of a quantity ladder: from `minQuantity` units up, the value is
8
+ * `value`. What the value means is the caller's business — a unit price on a
9
+ * price row, a percentage on a list's adjustment.
10
+ */
11
+ export interface QuantityTierRow {
12
+ /** Stable key for React. The caller owns it; a client-side id is fine. */
13
+ id: string
14
+ /** The quantity this rung applies from, as typed. */
15
+ minQuantity: string
16
+ /** The rung's figure, as typed. */
17
+ value: string
18
+ /**
19
+ * Locked rungs render their quantity read-only. Used for the bottom rung of
20
+ * a price ladder, which is the list's ordinary price and always quantity 1.
21
+ */
22
+ locked?: boolean
23
+ }
24
+
25
+ export interface QuantityTierEditorLabels {
26
+ /** Column header over the quantity inputs. */
27
+ quantity: string
28
+ /** Column header over the value inputs. */
29
+ value: string
30
+ /** The "add a rung" button. */
31
+ add: string
32
+ /** Accessible name for a rung's remove button. `{quantity}` is substituted. */
33
+ remove: string
34
+ /** Shown in place of the rows when the ladder is empty. */
35
+ empty?: string
36
+ /** Shown under the rows — a validation message, or a hint. */
37
+ hint?: string
38
+ }
39
+
40
+ export interface QuantityTierEditorProps {
41
+ rows: QuantityTierRow[]
42
+ labels: QuantityTierEditorLabels
43
+ onChange: (id: string, field: 'minQuantity' | 'value', next: string) => void
44
+ onAdd: () => void
45
+ onRemove: (id: string) => void
46
+ /** Prefix or suffix rendered inside the value input — a currency symbol, a `%`. */
47
+ valueAddon?: string
48
+ /** Which side the addon sits on. Defaults to a leading addon. */
49
+ valueAddonAlign?: 'inline-start' | 'inline-end'
50
+ /** Hides "add" once the ladder is full, so the cap is visible rather than a surprise. */
51
+ canAdd?: boolean
52
+ disabled?: boolean
53
+ /** Rendered in destructive styling under the rows. */
54
+ error?: string
55
+ }
56
+
57
+ /**
58
+ * The `[Min qty | value]` ladder both halves of volume pricing are edited
59
+ * through: fixed breaks on a variant's price rows, and quantity bands on a
60
+ * price list's percentage (docs/plans/6.0-volume-pricing.md).
61
+ *
62
+ * Headless — it holds no state, fetches nothing and knows no translations.
63
+ * The caller owns the rows and every string.
64
+ */
65
+ export function QuantityTierEditor({
66
+ rows,
67
+ labels,
68
+ onChange,
69
+ onAdd,
70
+ onRemove,
71
+ valueAddon,
72
+ valueAddonAlign = 'inline-start',
73
+ canAdd = true,
74
+ disabled,
75
+ error,
76
+ }: QuantityTierEditorProps) {
77
+ return (
78
+ <div className="flex flex-col gap-2">
79
+ {rows.length === 0 ? (
80
+ labels.empty ? (
81
+ <p className="text-xs text-muted-foreground">{labels.empty}</p>
82
+ ) : null
83
+ ) : (
84
+ <div className="flex flex-col gap-1.5">
85
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
86
+ <span className="w-24">{labels.quantity}</span>
87
+ <span className="flex-1">{labels.value}</span>
88
+ <span className="w-8" />
89
+ </div>
90
+ {rows.map((row) => (
91
+ <div key={row.id} className="flex items-center gap-2">
92
+ <Input
93
+ type="number"
94
+ step="1"
95
+ min="1"
96
+ className="w-24"
97
+ value={row.minQuantity}
98
+ readOnly={row.locked}
99
+ disabled={disabled}
100
+ aria-label={labels.quantity}
101
+ onChange={(event) => onChange(row.id, 'minQuantity', event.target.value)}
102
+ />
103
+ <InputGroup className="flex-1">
104
+ {valueAddon && valueAddonAlign === 'inline-start' && (
105
+ <InputGroupAddon>{valueAddon}</InputGroupAddon>
106
+ )}
107
+ <InputGroupInput
108
+ type="number"
109
+ step="0.01"
110
+ value={row.value}
111
+ disabled={disabled}
112
+ aria-label={labels.value}
113
+ onChange={(event) => onChange(row.id, 'value', event.target.value)}
114
+ />
115
+ {valueAddon && valueAddonAlign === 'inline-end' && (
116
+ <InputGroupAddon align="inline-end">{valueAddon}</InputGroupAddon>
117
+ )}
118
+ </InputGroup>
119
+ {/* The bottom rung is the ordinary price, not a tier — removing
120
+ it would be removing the price itself. */}
121
+ {row.locked ? (
122
+ <span className="w-8" />
123
+ ) : (
124
+ <Button
125
+ type="button"
126
+ variant="ghost"
127
+ size="icon"
128
+ className="size-8 shrink-0"
129
+ disabled={disabled}
130
+ aria-label={labels.remove.replace('{quantity}', row.minQuantity)}
131
+ onClick={() => onRemove(row.id)}
132
+ >
133
+ <XIcon className="size-4" />
134
+ </Button>
135
+ )}
136
+ </div>
137
+ ))}
138
+ </div>
139
+ )}
140
+
141
+ {canAdd && (
142
+ <Button
143
+ type="button"
144
+ variant="outline"
145
+ size="sm"
146
+ className="self-start"
147
+ disabled={disabled}
148
+ onClick={onAdd}
149
+ >
150
+ <PlusIcon className="size-4" />
151
+ {labels.add}
152
+ </Button>
153
+ )}
154
+
155
+ {error ? (
156
+ <p className="text-xs text-destructive">{error}</p>
157
+ ) : labels.hint ? (
158
+ <p className="text-xs text-muted-foreground">{labels.hint}</p>
159
+ ) : null}
160
+ </div>
161
+ )
162
+ }