@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.
- package/README.md +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- 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
|
+
}
|