@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,253 @@
1
+ import { type ReactNode, 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 { Input } from '../ui/input'
14
+ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
15
+ import { Textarea } from '../ui/textarea'
16
+ import {
17
+ type PostSaleSelection,
18
+ type PostSaleUnit,
19
+ QuantityPicker,
20
+ selectedUnits,
21
+ } from './post-sale-fields'
22
+
23
+ /**
24
+ * Opens a return: which units are coming back, why, and any note.
25
+ *
26
+ * `extraFields` is where a surface adds what only it can offer — the
27
+ * operator's shelf picker, for instance — so the shape stays one component.
28
+ * Shared by the operator's order page and the seller's.
29
+ */
30
+ export function CreateReturnDialog({
31
+ units,
32
+ reasonField,
33
+ extraFields,
34
+ onClose,
35
+ onSubmit,
36
+ pending = false,
37
+ }: {
38
+ units: PostSaleUnit[]
39
+ /** The reason picker, wired to whichever vocabulary the caller reads. */
40
+ reasonField?: ReactNode
41
+ extraFields?: ReactNode
42
+ onClose: () => void
43
+ onSubmit: (params: {
44
+ items: Array<{ fulfillment_item_id: string; quantity: number }>
45
+ memo?: string
46
+ }) => void
47
+ pending?: boolean
48
+ }) {
49
+ const { t } = useTranslation()
50
+ const [selection, setSelection] = useState<PostSaleSelection>({})
51
+ const [memo, setMemo] = useState('')
52
+
53
+ const chosen = selectedUnits(selection)
54
+
55
+ return (
56
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
57
+ <DialogContent>
58
+ <DialogHeader>
59
+ <DialogTitle>{t('admin.pages.orders.detail.returns.create_title')}</DialogTitle>
60
+ </DialogHeader>
61
+ <DialogBody className="flex flex-col gap-4">
62
+ <QuantityPicker units={units} selection={selection} onChange={setSelection} />
63
+ {reasonField}
64
+ {extraFields}
65
+ <Field>
66
+ <FieldLabel htmlFor="return-memo">
67
+ {t('admin.pages.orders.detail.returns.memo')}
68
+ </FieldLabel>
69
+ <Textarea
70
+ id="return-memo"
71
+ value={memo}
72
+ onChange={(event) => setMemo(event.target.value)}
73
+ />
74
+ </Field>
75
+ </DialogBody>
76
+ <DialogFooter>
77
+ <Button type="button" variant="outline" onClick={onClose} disabled={pending}>
78
+ {t('admin.actions.cancel')}
79
+ </Button>
80
+ <Button
81
+ type="button"
82
+ disabled={chosen.length === 0 || pending}
83
+ onClick={() =>
84
+ onSubmit({
85
+ items: chosen.map(([id, quantity]) => ({
86
+ fulfillment_item_id: id,
87
+ quantity,
88
+ })),
89
+ memo: memo.trim() || undefined,
90
+ })
91
+ }
92
+ >
93
+ {t('admin.pages.orders.detail.returns.actions.create')}
94
+ </Button>
95
+ </DialogFooter>
96
+ </DialogContent>
97
+ </Dialog>
98
+ )
99
+ }
100
+
101
+ /** A line the claim can name, with what one unit of it cost. */
102
+ export type ClaimableLine = PostSaleUnit & {
103
+ /** Unit price, used to default the refund to what was actually paid. */
104
+ price?: string | null
105
+ }
106
+
107
+ /**
108
+ * Reports a problem with a delivery: which lines, how many, and what to give
109
+ * back for each.
110
+ *
111
+ * A claim is about what was bought rather than what shipped — goods that
112
+ * never arrived are exactly what one is for — so the lines come from the
113
+ * order, not from its fulfillments.
114
+ */
115
+ export function CreateClaimDialog({
116
+ lines,
117
+ currencySymbol,
118
+ reasonField,
119
+ onClose,
120
+ onSubmit,
121
+ pending = false,
122
+ }: {
123
+ lines: ClaimableLine[]
124
+ currencySymbol: string
125
+ reasonField?: ReactNode
126
+ onClose: () => void
127
+ onSubmit: (params: {
128
+ items: Array<{
129
+ line_item_id: string
130
+ quantity: number
131
+ description?: string
132
+ refund_amount?: string
133
+ }>
134
+ memo?: string
135
+ }) => void
136
+ pending?: boolean
137
+ }) {
138
+ const { t } = useTranslation()
139
+ const [selection, setSelection] = useState<PostSaleSelection>({})
140
+ const [amounts, setAmounts] = useState<Record<string, string>>({})
141
+ const [memo, setMemo] = useState('')
142
+
143
+ const chosen = selectedUnits(selection)
144
+
145
+ return (
146
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
147
+ <DialogContent>
148
+ <DialogHeader>
149
+ <DialogTitle>{t('admin.pages.orders.detail.claims.create_title')}</DialogTitle>
150
+ </DialogHeader>
151
+ <DialogBody className="flex flex-col gap-4">
152
+ <div className="flex flex-col gap-2">
153
+ {lines.map((line) => {
154
+ const chosenQuantity = selection[line.id] ?? 0
155
+
156
+ return (
157
+ <div
158
+ key={line.id}
159
+ className="flex flex-col gap-2 rounded-lg border border-border-subtle p-3"
160
+ >
161
+ <div className="flex items-center justify-between gap-4">
162
+ <span className="min-w-0 truncate text-sm">{line.label}</span>
163
+ <Input
164
+ type="number"
165
+ min={0}
166
+ max={line.quantity}
167
+ className="w-20"
168
+ value={chosenQuantity}
169
+ aria-label={line.label}
170
+ onChange={(event) => {
171
+ const quantity = Math.max(
172
+ 0,
173
+ Math.min(Number(event.target.value), line.quantity),
174
+ )
175
+ setSelection({ ...selection, [line.id]: quantity })
176
+ // Default the refund to what was paid for those units;
177
+ // the merchant can still overwrite it.
178
+ if (quantity > 0 && !amounts[line.id]) {
179
+ const unitPrice = Number(line.price)
180
+ if (Number.isFinite(unitPrice)) {
181
+ setAmounts((current) => ({
182
+ ...current,
183
+ [line.id]: (unitPrice * quantity).toFixed(2),
184
+ }))
185
+ }
186
+ }
187
+ }}
188
+ />
189
+ </div>
190
+ {chosenQuantity > 0 && (
191
+ <Field>
192
+ <FieldLabel htmlFor={`claim-amount-${line.id}`}>
193
+ {t('admin.pages.orders.detail.claims.refund_amount')}
194
+ </FieldLabel>
195
+ <InputGroup>
196
+ <InputGroupAddon>
197
+ <InputGroupText>{currencySymbol}</InputGroupText>
198
+ </InputGroupAddon>
199
+ <InputGroupInput
200
+ id={`claim-amount-${line.id}`}
201
+ type="number"
202
+ step="0.01"
203
+ min="0"
204
+ value={amounts[line.id] ?? ''}
205
+ onChange={(event) =>
206
+ setAmounts({ ...amounts, [line.id]: event.target.value })
207
+ }
208
+ />
209
+ </InputGroup>
210
+ </Field>
211
+ )}
212
+ </div>
213
+ )
214
+ })}
215
+ </div>
216
+
217
+ {reasonField}
218
+ <Field>
219
+ <FieldLabel htmlFor="claim-memo">
220
+ {t('admin.pages.orders.detail.returns.memo')}
221
+ </FieldLabel>
222
+ <Textarea
223
+ id="claim-memo"
224
+ value={memo}
225
+ onChange={(event) => setMemo(event.target.value)}
226
+ />
227
+ </Field>
228
+ </DialogBody>
229
+ <DialogFooter>
230
+ <Button type="button" variant="outline" onClick={onClose} disabled={pending}>
231
+ {t('admin.actions.cancel')}
232
+ </Button>
233
+ <Button
234
+ type="button"
235
+ disabled={chosen.length === 0 || pending}
236
+ onClick={() =>
237
+ onSubmit({
238
+ items: chosen.map(([id, quantity]) => ({
239
+ line_item_id: id,
240
+ quantity,
241
+ refund_amount: amounts[id] || undefined,
242
+ })),
243
+ memo: memo.trim() || undefined,
244
+ })
245
+ }
246
+ >
247
+ {t('admin.pages.orders.detail.claims.actions.create')}
248
+ </Button>
249
+ </DialogFooter>
250
+ </DialogContent>
251
+ </Dialog>
252
+ )
253
+ }
@@ -0,0 +1,133 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import { Field, FieldLabel } from '../ui/field'
3
+ import { Input } from '../ui/input'
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
5
+
6
+ /** A unit on the order that a return, exchange or claim can name. */
7
+ export type PostSaleUnit = {
8
+ id: string
9
+ label: string
10
+ quantity: number
11
+ }
12
+
13
+ /** How many of each unit a record covers; zero means "not included". */
14
+ export type PostSaleSelection = Record<string, number>
15
+
16
+ /**
17
+ * Picks how many of each unit a post-sale record covers.
18
+ *
19
+ * Shared by the operator's order page and the seller's, so opening a return
20
+ * looks the same on both sides of a marketplace.
21
+ */
22
+ export function QuantityPicker({
23
+ units,
24
+ selection,
25
+ onChange,
26
+ }: {
27
+ units: PostSaleUnit[]
28
+ selection: PostSaleSelection
29
+ onChange: (selection: PostSaleSelection) => void
30
+ }) {
31
+ return (
32
+ <div className="flex flex-col gap-2">
33
+ {units.map((unit) => (
34
+ <div
35
+ key={unit.id}
36
+ className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle p-3"
37
+ >
38
+ <span className="min-w-0 truncate text-sm">{unit.label}</span>
39
+ <Input
40
+ type="number"
41
+ min={0}
42
+ max={unit.quantity}
43
+ className="w-20"
44
+ value={selection[unit.id] ?? 0}
45
+ aria-label={unit.label}
46
+ onChange={(event) =>
47
+ onChange({
48
+ ...selection,
49
+ [unit.id]: Math.max(0, Math.min(Number(event.target.value), unit.quantity)),
50
+ })
51
+ }
52
+ />
53
+ </div>
54
+ ))}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ /** The units a record actually covers, as id/quantity pairs. */
60
+ export function selectedUnits(selection: PostSaleSelection): Array<[string, number]> {
61
+ return Object.entries(selection).filter(([, quantity]) => quantity > 0)
62
+ }
63
+
64
+ /** One entry of a merchant's reason vocabulary. */
65
+ export type ReasonOption = {
66
+ id: string
67
+ name: string
68
+ }
69
+
70
+ /**
71
+ * Reason dropdown, shared by every surface that records why something
72
+ * happened. Optional by design — the API accepts a record without a reason,
73
+ * and a merchant mid-return should not be blocked because nobody has curated
74
+ * the list yet.
75
+ *
76
+ * With no `emptyOptionLabel` the field hides itself when the vocabulary is
77
+ * empty, which suits a form that has other fields to fall back on. Passing one
78
+ * keeps the field visible with an explicit "none" choice at the top, for a
79
+ * surface where the reason is the point and its absence has to be sayable.
80
+ *
81
+ * The reasons come from the caller: each panel reads its own endpoint, and
82
+ * which vocabulary a surface offers is not this component's business.
83
+ */
84
+ export function ReasonField({
85
+ id,
86
+ reasons,
87
+ value,
88
+ onChange,
89
+ label,
90
+ emptyOptionLabel,
91
+ }: {
92
+ /** Scopes the field id, since a page can show more than one of these. */
93
+ id: string
94
+ reasons: ReasonOption[]
95
+ value: string
96
+ onChange: (value: string) => void
97
+ label?: string
98
+ emptyOptionLabel?: string
99
+ }) {
100
+ const { t } = useTranslation()
101
+
102
+ if (reasons.length === 0 && emptyOptionLabel === undefined) return null
103
+
104
+ // Base UI's <Select> needs `items` to resolve the trigger label; without it
105
+ // the closed trigger renders the raw id (see CLAUDE.md).
106
+ const options = [
107
+ ...(emptyOptionLabel === undefined ? [] : [{ value: '', label: emptyOptionLabel }]),
108
+ ...reasons.map((reason) => ({ value: reason.id, label: reason.name })),
109
+ ]
110
+
111
+ return (
112
+ <Field>
113
+ <FieldLabel htmlFor={`reason-${id}`}>
114
+ {label ?? t('admin.pages.orders.detail.returns.reason')}
115
+ </FieldLabel>
116
+ <Select items={options} value={value} onValueChange={(next) => onChange(next ?? '')}>
117
+ <SelectTrigger id={`reason-${id}`}>
118
+ {/* An explicit empty choice IS the label for "none", so it must show
119
+ rather than fall through to the placeholder Base UI renders for
120
+ an unset value. */}
121
+ <SelectValue placeholder={emptyOptionLabel ?? t('admin.common.select_placeholder')} />
122
+ </SelectTrigger>
123
+ <SelectContent>
124
+ {options.map((option) => (
125
+ <SelectItem key={option.value} value={option.value}>
126
+ {option.label}
127
+ </SelectItem>
128
+ ))}
129
+ </SelectContent>
130
+ </Select>
131
+ </Field>
132
+ )
133
+ }