@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,282 @@
1
+ import type { UseFormReturn } from 'react-hook-form'
2
+ import { Controller } from 'react-hook-form'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { cn } from '../lib/utils'
5
+ import { Alert, AlertDescription } from '../ui/alert'
6
+ import { Button } from '../ui/button'
7
+ import { Checkbox } from '../ui/checkbox'
8
+ import { Field, FieldLabel } from '../ui/field'
9
+ import { Input } from '../ui/input'
10
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
11
+ import { Thumbnail } from '../ui/thumbnail'
12
+ import { PackageIcon, TriangleAlertIcon } from './icons'
13
+
14
+ /** A pickable row: what it looks like and how many units are on offer. */
15
+ export type FulfillableRowData = {
16
+ key: string
17
+ itemId: string
18
+ name: string
19
+ optionsText?: string | null
20
+ thumbnailUrl?: string | null
21
+ quantity: number
22
+ }
23
+
24
+ /** The form shape both panels bind — see the shared fulfillment schema. */
25
+ export type FulfillItemsValues = {
26
+ items: Array<{ item_id: string; selected: boolean; quantity: number }>
27
+ tracking: string
28
+ tracking_carrier: string
29
+ notify_customer: boolean
30
+ }
31
+
32
+ function ItemQuantityRow({
33
+ row,
34
+ index,
35
+ selected,
36
+ idPrefix,
37
+ form,
38
+ }: {
39
+ row: FulfillableRowData
40
+ index: number
41
+ selected: boolean
42
+ /** Scopes the field ids, since an order can show several of these forms. */
43
+ idPrefix: string
44
+ form: UseFormReturn<FulfillItemsValues>
45
+ }) {
46
+ const { t } = useTranslation()
47
+ const checkboxId = `${idPrefix}-item-${index}`
48
+
49
+ return (
50
+ <div
51
+ className={cn(
52
+ 'my-2 flex items-center gap-3 rounded-lg py-2 pl-2',
53
+ selected && 'bg-accent-strong',
54
+ )}
55
+ >
56
+ <Controller
57
+ control={form.control}
58
+ name={`items.${index}.selected`}
59
+ render={({ field }) => (
60
+ <Checkbox
61
+ id={checkboxId}
62
+ checked={field.value}
63
+ aria-label={t('admin.orders.fulfill.include_item', { name: row.name })}
64
+ onCheckedChange={(checked) => {
65
+ const include = checked === true
66
+ field.onChange(include)
67
+ // Re-checking restores the full held quantity, so the merchant
68
+ // never has to retype what they just excluded.
69
+ form.setValue(`items.${index}.quantity`, include ? row.quantity : 0, {
70
+ shouldDirty: true,
71
+ })
72
+ }}
73
+ />
74
+ )}
75
+ />
76
+
77
+ <Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
78
+
79
+ <div className="min-w-0 flex-1">
80
+ <div className="truncate font-medium text-sm">{row.name}</div>
81
+ {row.optionsText && (
82
+ <div className="truncate text-muted-foreground text-xs">{row.optionsText}</div>
83
+ )}
84
+ </div>
85
+
86
+ <Controller
87
+ control={form.control}
88
+ name={`items.${index}.quantity`}
89
+ render={({ field }) => (
90
+ <div className="flex shrink-0 items-center gap-2">
91
+ <Input
92
+ type="number"
93
+ min={0}
94
+ max={row.quantity}
95
+ className="w-20 text-right"
96
+ disabled={!selected}
97
+ aria-label={t('admin.orders.fulfill.quantity_for', { name: row.name })}
98
+ value={field.value}
99
+ onChange={(event) => {
100
+ const parsed = Number(event.target.value)
101
+ const quantity = Number.isFinite(parsed)
102
+ ? Math.max(0, Math.min(parsed, row.quantity))
103
+ : 0
104
+ field.onChange(quantity)
105
+ // Typing the quantity down to zero is the same statement as
106
+ // unticking the row, so keep the two in step.
107
+ form.setValue(`items.${index}.selected`, quantity > 0, { shouldDirty: true })
108
+ }}
109
+ onBlur={field.onBlur}
110
+ />
111
+ <span className="w-16 whitespace-nowrap text-muted-foreground text-sm">
112
+ {t('admin.orders.fulfill.of_units', { count: row.quantity })}
113
+ </span>
114
+ </div>
115
+ )}
116
+ />
117
+ </div>
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Picks what to ship out of one fulfillment, in place of the card's normal
123
+ * contents. Shipping everything is the default; lowering a quantity or
124
+ * unticking an item splits the chosen units into a new fulfillment that ships,
125
+ * leaving the rest of this one open.
126
+ *
127
+ * Shared by the operator's order page and the seller's. Headless: the caller
128
+ * owns the form instance and the submit, so each panel keeps its own mutation
129
+ * and its own idea of who may ship — this holds the shape and the
130
+ * select/quantity coupling so the two cannot drift.
131
+ */
132
+ export function FulfillItemsForm({
133
+ form,
134
+ rows,
135
+ idPrefix,
136
+ carrierOptions,
137
+ onSubmit,
138
+ onCancel,
139
+ canShipWholeParcel = false,
140
+ pending = false,
141
+ }: {
142
+ form: UseFormReturn<FulfillItemsValues>
143
+ rows: FulfillableRowData[]
144
+ idPrefix: string
145
+ /** Carriers to choose between; the first is normally "detect from number". */
146
+ carrierOptions: Array<{ value: string; label: string }>
147
+ onSubmit: (values: FulfillItemsValues) => void | Promise<void>
148
+ onCancel: () => void
149
+ /**
150
+ * Whether the parcel can ship with nothing picked.
151
+ *
152
+ * True when it holds items that no row can address — the workflow reads an
153
+ * empty selection as "everything", so blocking submit would leave such a
154
+ * parcel unshippable. Never inferred from `rows` being empty here: that
155
+ * cannot tell an unaddressable parcel from an empty one, and an empty
156
+ * fulfillment must stay unsubmittable.
157
+ */
158
+ canShipWholeParcel?: boolean
159
+ pending?: boolean
160
+ }) {
161
+ const { t } = useTranslation()
162
+ const { errors } = form.formState
163
+
164
+ const watched = form.watch('items')
165
+ const totalSelected = watched.reduce(
166
+ (sum, item) => sum + (item.selected ? item.quantity || 0 : 0),
167
+ 0,
168
+ )
169
+ const totalAvailable = rows.reduce((sum, row) => sum + row.quantity, 0)
170
+ const shipsEverything = totalSelected === totalAvailable
171
+
172
+ return (
173
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col">
174
+ {errors.root?.message && (
175
+ <p className="text-destructive text-sm" role="alert">
176
+ {errors.root.message}
177
+ </p>
178
+ )}
179
+
180
+ {rows.length === 0 ? (
181
+ <p className="py-6 text-center text-muted-foreground">{t('admin.orders.fulfill.empty')}</p>
182
+ ) : (
183
+ <div className="divide-y">
184
+ {rows.map((row, index) => (
185
+ <ItemQuantityRow
186
+ key={row.key}
187
+ row={row}
188
+ index={index}
189
+ selected={watched[index]?.selected ?? true}
190
+ idPrefix={idPrefix}
191
+ form={form}
192
+ />
193
+ ))}
194
+ </div>
195
+ )}
196
+
197
+ <div className="grid grid-cols-1 gap-4 border-border-subtle border-t py-3 sm:grid-cols-2">
198
+ <Field>
199
+ <FieldLabel htmlFor={`${idPrefix}-tracking`}>
200
+ {t('admin.orders.fulfill.tracking_label')}
201
+ </FieldLabel>
202
+ <Input
203
+ id={`${idPrefix}-tracking`}
204
+ placeholder={t('admin.orders.fulfill.tracking_placeholder')}
205
+ {...form.register('tracking')}
206
+ />
207
+ </Field>
208
+
209
+ <Field>
210
+ <FieldLabel htmlFor={`${idPrefix}-tracking-carrier`}>
211
+ {t('admin.orders.detail.fulfillments.carrier_label')}
212
+ </FieldLabel>
213
+ <Controller
214
+ control={form.control}
215
+ name="tracking_carrier"
216
+ render={({ field }) => (
217
+ <Select
218
+ items={carrierOptions}
219
+ value={field.value}
220
+ onValueChange={(value) => field.onChange(value ?? '')}
221
+ >
222
+ <SelectTrigger id={`${idPrefix}-tracking-carrier`}>
223
+ <SelectValue />
224
+ </SelectTrigger>
225
+ <SelectContent>
226
+ {carrierOptions.map((option) => (
227
+ <SelectItem key={option.value} value={option.value}>
228
+ {option.label}
229
+ </SelectItem>
230
+ ))}
231
+ </SelectContent>
232
+ </Select>
233
+ )}
234
+ />
235
+ </Field>
236
+ </div>
237
+
238
+ <Controller
239
+ control={form.control}
240
+ name="notify_customer"
241
+ render={({ field }) => (
242
+ <label
243
+ htmlFor={`${idPrefix}-notify`}
244
+ className="flex cursor-pointer items-center gap-2 py-3 text-sm"
245
+ >
246
+ <Checkbox
247
+ id={`${idPrefix}-notify`}
248
+ checked={field.value}
249
+ onCheckedChange={(checked) => field.onChange(checked === true)}
250
+ />
251
+ {t('admin.orders.fulfill.notify_customer')}
252
+ </label>
253
+ )}
254
+ />
255
+
256
+ {!shipsEverything && totalSelected > 0 && (
257
+ <div className="px-3 pb-3">
258
+ <Alert variant="warning">
259
+ <TriangleAlertIcon />
260
+ <AlertDescription>{t('admin.orders.fulfill.partial_hint')}</AlertDescription>
261
+ </Alert>
262
+ </div>
263
+ )}
264
+
265
+ <div className="flex items-center justify-end gap-2 border-border-subtle border-t py-3">
266
+ <span className="mr-auto text-muted-foreground text-sm">
267
+ {t('admin.orders.fulfill.units_selected')}: {totalSelected} / {totalAvailable}
268
+ </span>
269
+ <Button type="button" size="sm" variant="outline" onClick={onCancel}>
270
+ {t('admin.actions.cancel')}
271
+ </Button>
272
+ <Button
273
+ type="submit"
274
+ size="sm"
275
+ disabled={(totalSelected === 0 && !canShipWholeParcel) || pending}
276
+ >
277
+ {pending ? t('admin.actions.saving') : t('admin.orders.fulfill.submit')}
278
+ </Button>
279
+ </div>
280
+ </form>
281
+ )
282
+ }
@@ -0,0 +1,211 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Button } from '../ui/button'
4
+ import {
5
+ Dialog,
6
+ DialogBody,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '../ui/dialog'
13
+ import { Field, FieldError, FieldLabel } from '../ui/field'
14
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
15
+
16
+ /** A shelf a parcel can ship from, already labelled by the caller. */
17
+ export type FulfillmentOriginOption = {
18
+ value: string
19
+ label: string
20
+ }
21
+
22
+ /** A priced service the parcel can be carried by, already labelled. */
23
+ export type FulfillmentRateOption = {
24
+ value: string
25
+ label: string
26
+ }
27
+
28
+ export type FulfillmentEditValues = {
29
+ stockLocationId: string
30
+ selectedDeliveryRateId: string
31
+ }
32
+
33
+ /**
34
+ * Where a parcel ships from and which priced service carries it.
35
+ *
36
+ * The two fields are coupled: rates are quoted against an origin, so moving
37
+ * the parcel invalidates the list on screen. Rather than offering stale
38
+ * options, a save that changes the origin writes the location first and the
39
+ * method field stands down until the caller re-quotes — which is why
40
+ * `onSubmit` reports *which* fields changed and the caller decides the order
41
+ * of writes.
42
+ *
43
+ * Headless: both panels label their own options (the operator marks shelves
44
+ * that cannot cover the parcel; a seller sees only their own), and both own
45
+ * their mutations. This holds the shape and the coupling rule so the two
46
+ * cannot drift.
47
+ */
48
+ export function FulfillmentEditDialog({
49
+ open,
50
+ onOpenChange,
51
+ originOptions,
52
+ rateOptions,
53
+ currentOriginId,
54
+ currentRateId,
55
+ onSubmit,
56
+ pending = false,
57
+ errorMessage,
58
+ }: {
59
+ open: boolean
60
+ onOpenChange: (open: boolean) => void
61
+ originOptions: FulfillmentOriginOption[]
62
+ rateOptions: FulfillmentRateOption[]
63
+ currentOriginId: string
64
+ currentRateId: string
65
+ /**
66
+ * Applies the change. Returning `'requote'` keeps the dialog open and
67
+ * re-seeds it from the caller's refreshed record — the origin moved, so the
68
+ * rates on screen described a route the parcel has left.
69
+ */
70
+ onSubmit: (
71
+ values: FulfillmentEditValues,
72
+ changed: { origin: boolean; rate: boolean },
73
+ ) => Promise<'requote' | 'done'>
74
+ pending?: boolean
75
+ errorMessage?: string
76
+ }) {
77
+ const { t } = useTranslation()
78
+
79
+ const [originId, setOriginId] = useState(currentOriginId)
80
+ const [rateId, setRateId] = useState(currentRateId)
81
+
82
+ // A re-quote lands as a changed record: the caller has moved the parcel and
83
+ // the server has re-selected a method for the new origin. Adopt that pick,
84
+ // so the second save confirms a rate that actually applies rather than
85
+ // re-sending the one quoted for the origin the parcel just left.
86
+ const seededOrigin = useRef(currentOriginId)
87
+ useEffect(() => {
88
+ if (seededOrigin.current === currentOriginId) return
89
+ seededOrigin.current = currentOriginId
90
+ setOriginId(currentOriginId)
91
+ setRateId(currentRateId)
92
+ }, [currentOriginId, currentRateId])
93
+
94
+ // The rates on the record were quoted for the origin it currently has. Once
95
+ // a different one is picked they describe a route no longer on offer, so the
96
+ // method field stands down until the server re-quotes.
97
+ const originMoved = originId !== currentOriginId
98
+ const originMissing = originId.length === 0
99
+
100
+ async function handleSubmit(event: React.FormEvent) {
101
+ // The order page renders its cards inside their own forms — without
102
+ // stopping the bubble the browser submits the outer one.
103
+ event.preventDefault()
104
+ event.stopPropagation()
105
+ if (originMissing) return
106
+
107
+ const outcome = await onSubmit(
108
+ { stockLocationId: originId, selectedDeliveryRateId: rateId },
109
+ { origin: originMoved, rate: rateId !== currentRateId },
110
+ )
111
+
112
+ if (outcome === 'done') onOpenChange(false)
113
+ }
114
+
115
+ return (
116
+ <Dialog open={open} onOpenChange={onOpenChange}>
117
+ <DialogContent>
118
+ <form onSubmit={handleSubmit}>
119
+ <DialogHeader>
120
+ <DialogTitle>{t('admin.orders.detail.fulfillments.edit_title')}</DialogTitle>
121
+ <DialogDescription>
122
+ {t('admin.orders.detail.fulfillments.edit_description')}
123
+ </DialogDescription>
124
+ </DialogHeader>
125
+
126
+ <DialogBody className="flex flex-col gap-4">
127
+ {errorMessage && (
128
+ <p className="text-sm text-destructive" role="alert">
129
+ {errorMessage}
130
+ </p>
131
+ )}
132
+
133
+ <Field>
134
+ <FieldLabel htmlFor="fulfillment-location">
135
+ {t('admin.orders.detail.fulfillments.ships_from')}
136
+ </FieldLabel>
137
+ <Select
138
+ items={originOptions}
139
+ value={originId}
140
+ onValueChange={(value) => setOriginId((value as string) ?? '')}
141
+ >
142
+ <SelectTrigger id="fulfillment-location">
143
+ <SelectValue placeholder={t('admin.common.select_placeholder')} />
144
+ </SelectTrigger>
145
+ <SelectContent>
146
+ {originOptions.map((option) => (
147
+ <SelectItem key={option.value} value={option.value}>
148
+ {option.label}
149
+ </SelectItem>
150
+ ))}
151
+ </SelectContent>
152
+ </Select>
153
+ {originMissing && <FieldError>{t('admin.errors.required')}</FieldError>}
154
+ </Field>
155
+
156
+ <Field>
157
+ <FieldLabel htmlFor="fulfillment-rate">
158
+ {t('admin.orders.detail.fulfillments.delivery_method')}
159
+ </FieldLabel>
160
+ <Select
161
+ items={rateOptions}
162
+ value={rateId}
163
+ disabled={originMoved || rateOptions.length === 0}
164
+ onValueChange={(value) => setRateId((value as string) ?? '')}
165
+ >
166
+ <SelectTrigger id="fulfillment-rate">
167
+ <SelectValue placeholder={t('admin.common.select_placeholder')} />
168
+ </SelectTrigger>
169
+ <SelectContent>
170
+ {rateOptions.map((option) => (
171
+ <SelectItem key={option.value} value={option.value}>
172
+ {option.label}
173
+ </SelectItem>
174
+ ))}
175
+ </SelectContent>
176
+ </Select>
177
+ {originMoved && (
178
+ <span className="text-muted-foreground text-xs">
179
+ {t('admin.orders.detail.fulfillments.rates_pending_origin')}
180
+ </span>
181
+ )}
182
+ {!originMoved && rateOptions.length === 0 && (
183
+ <span className="text-muted-foreground text-xs">
184
+ {t('admin.orders.detail.fulfillments.no_rates')}
185
+ </span>
186
+ )}
187
+ </Field>
188
+ </DialogBody>
189
+
190
+ <DialogFooter>
191
+ <Button
192
+ type="button"
193
+ variant="outline"
194
+ onClick={() => onOpenChange(false)}
195
+ disabled={pending}
196
+ >
197
+ {t('admin.actions.cancel')}
198
+ </Button>
199
+ <Button type="submit" disabled={pending || originMissing}>
200
+ {pending
201
+ ? t('admin.actions.saving')
202
+ : originMoved
203
+ ? t('admin.orders.detail.fulfillments.move_and_requote')
204
+ : t('admin.actions.save')}
205
+ </Button>
206
+ </DialogFooter>
207
+ </form>
208
+ </DialogContent>
209
+ </Dialog>
210
+ )
211
+ }
@@ -0,0 +1,61 @@
1
+ import { Thumbnail } from '../ui/thumbnail'
2
+ import { PackageIcon } from './icons'
3
+
4
+ /**
5
+ * One row of a fulfillment's item list, already joined to its line item by
6
+ * the caller. The image and the price are optional: a surface that does not
7
+ * expose them renders the name and the count alone.
8
+ */
9
+ export type FulfillmentItemRowData = {
10
+ key: string
11
+ name: string
12
+ optionsText?: string | null
13
+ thumbnailUrl?: string | null
14
+ displayPrice?: string | null
15
+ quantity: number
16
+ }
17
+
18
+ /**
19
+ * What a fulfillment holds: what it looks like, what it costs and how many
20
+ * units travel together.
21
+ *
22
+ * Read-only — changing what an order contains after placement belongs to the
23
+ * order edit screen, not here. Shared by the operator's order page and the
24
+ * seller's so a parcel's contents read the same on both.
25
+ */
26
+ function ItemRow({ row }: { row: FulfillmentItemRowData }) {
27
+ return (
28
+ <div className="flex items-center gap-3 p-3">
29
+ <Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
30
+
31
+ <div className="min-w-0 flex-1">
32
+ <div className="truncate text-sm font-medium">{row.name}</div>
33
+ {row.optionsText && (
34
+ <div className="truncate text-xs text-muted-foreground">{row.optionsText}</div>
35
+ )}
36
+ </div>
37
+
38
+ <div className="shrink-0 whitespace-nowrap text-right text-muted-foreground text-sm">
39
+ {row.displayPrice ? (
40
+ <span>
41
+ {row.displayPrice} × {row.quantity}
42
+ </span>
43
+ ) : (
44
+ <span>× {row.quantity}</span>
45
+ )}
46
+ </div>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ export function FulfillmentItemList({ rows }: { rows: FulfillmentItemRowData[] }) {
52
+ if (rows.length === 0) return null
53
+
54
+ return (
55
+ <div className="divide-y">
56
+ {rows.map((row) => (
57
+ <ItemRow key={row.key} row={row} />
58
+ ))}
59
+ </div>
60
+ )
61
+ }
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from 'react'
2
+ import { StatusBadge } from '../ui/badge'
3
+ import { Card, CardAction, CardHeader, CardTitle } from '../ui/card'
4
+ import { MapPinIcon } from './icons'
5
+
6
+ /**
7
+ * The nested card one parcel occupies on an order page: what state it is in,
8
+ * where it ships from, and its own menu.
9
+ *
10
+ * Shared by the operator's order page and the seller's, so a parcel reads the
11
+ * same on both sides of a marketplace. Headless — the status label and every
12
+ * action come from the caller.
13
+ */
14
+ export function FulfillmentPanel({
15
+ status,
16
+ statusLabel,
17
+ location,
18
+ meta,
19
+ actions,
20
+ children,
21
+ }: {
22
+ status: string
23
+ /** Omit to let the badge humanize the status itself. */
24
+ statusLabel?: string
25
+ /** Where it ships from. Absent until a fulfillment exists. */
26
+ location?: string | null
27
+ /** Trailing header detail, e.g. when it shipped. */
28
+ meta?: ReactNode
29
+ /** The panel's own menu. Absent when there is nothing to act on. */
30
+ actions?: ReactNode
31
+ children: ReactNode
32
+ }) {
33
+ return (
34
+ <Card variant="nested">
35
+ <CardHeader>
36
+ <CardTitle className="min-w-0 font-normal text-sm">
37
+ <StatusBadge status={status} label={statusLabel} />
38
+ {location && (
39
+ <div className="flex min-w-0 items-center gap-1.5 text-muted-foreground text-xs">
40
+ <MapPinIcon className="size-3 shrink-0" />
41
+ {/* The card clips its overflow, so a long warehouse name has to
42
+ truncate here or it is simply cut off. */}
43
+ <span className="truncate">{location}</span>
44
+ </div>
45
+ )}
46
+ {meta}
47
+ </CardTitle>
48
+ {actions && <CardAction>{actions}</CardAction>}
49
+ </CardHeader>
50
+ {children}
51
+ </Card>
52
+ )
53
+ }