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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +38 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -1,11 +1,12 @@
1
1
  import { useParams, useRouter } from '@tanstack/react-router'
2
- import { ArrowLeftIcon } from 'lucide-react'
3
2
  import { useTranslation } from 'react-i18next'
3
+ import { ArrowLeftIcon } from './icons'
4
4
 
5
5
  interface BackButtonProps {
6
6
  /**
7
- * Fallback path segment under the current store when there is no history to go back to.
8
- * Example: "products" → navigates to `/$storeId/products`
7
+ * Fallback path segment under the current tenant when there is no history to
8
+ * go back to. Example: "products" → navigates to `/$storeId/products` in the
9
+ * operator's dashboard, `/$sellerId/products` in a seller's panel.
9
10
  */
10
11
  fallback: string
11
12
  className?: string
@@ -13,22 +14,26 @@ interface BackButtonProps {
13
14
 
14
15
  /**
15
16
  * Back button that goes to the previous page in history (preserving that page's
16
- * state — filters, column selection, etc.), falling back to `/$storeId/{fallback}`
17
- * when there's nothing to go back to (e.g. deep-link, new tab).
17
+ * state — filters, column selection, etc.), falling back to the tenant's list
18
+ * page when there's nothing to go back to (e.g. deep-link, new tab).
18
19
  */
19
20
  export function BackButton({ fallback, className }: BackButtonProps) {
20
21
  const { t } = useTranslation()
21
22
  const router = useRouter()
22
- const { storeId } = useParams({ strict: false }) as { storeId?: string }
23
+ // Whichever tenant this panel routes under. Reading `storeId` alone sent a
24
+ // seller to `/undefined/...`, since their routes are keyed `$sellerId` —
25
+ // this component is shared, so it cannot assume the operator's param name.
26
+ const params = useParams({ strict: false }) as { storeId?: string; sellerId?: string }
27
+ const tenantId = params.storeId ?? params.sellerId
23
28
 
24
29
  const handleClick = (e: React.MouseEvent) => {
25
30
  e.preventDefault()
26
31
  // If there's history within the app, go back to preserve previous state
27
32
  if (window.history.length > 1 && document.referrer.includes(window.location.host)) {
28
33
  router.history.back()
29
- } else {
34
+ } else if (tenantId) {
30
35
  // Fallback to the list page
31
- router.navigate({ to: `/${storeId}/${fallback}` as string })
36
+ router.navigate({ to: `/${tenantId}/${fallback}` as string })
32
37
  }
33
38
  }
34
39
 
@@ -38,7 +43,7 @@ export function BackButton({ fallback, className }: BackButtonProps) {
38
43
  onClick={handleClick}
39
44
  className={
40
45
  className ??
41
- 'inline-flex items-center justify-center rounded-lg p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors'
46
+ 'inline-flex items-center justify-center rounded-lg p-1.5 text-muted-foreground bg-transparent hover:bg-accent hover:text-foreground transition-colors'
42
47
  }
43
48
  aria-label={t('admin.actions.back')}
44
49
  >
@@ -57,10 +57,10 @@ export function BulkDialog({
57
57
  <FieldGroup>{children}</FieldGroup>
58
58
  </DialogBody>
59
59
  <DialogFooter>
60
- <Button type="button" variant="outline" size="sm" onClick={onCancel}>
60
+ <Button type="button" variant="outline" onClick={onCancel}>
61
61
  {t('admin.actions.cancel')}
62
62
  </Button>
63
- <Button type="submit" size="sm" disabled={submitDisabled}>
63
+ <Button type="submit" disabled={submitDisabled}>
64
64
  {submitLabel}
65
65
  </Button>
66
66
  </DialogFooter>
@@ -1,14 +1,20 @@
1
1
  import type { ColumnDef } from '@tanstack/react-table'
2
+ import { PlusIcon, XIcon } from 'lucide-react'
2
3
  import { useMemo } from 'react'
3
4
  import { Button } from '../ui/button'
4
- import { Input } from '../ui/input'
5
- import { DataGrid, editableRowIndex, MoneyCell, ReadOnlyCell } from './data-grid'
5
+ import { DataGrid, DecimalCell, editableRowIndex, MoneyCell, ReadOnlyCell } from './data-grid'
6
+ import { SearchInput } from './search-input'
6
7
 
7
8
  export interface BulkPriceRow {
8
9
  // Unique row id. For header rows use a synthetic key (e.g. `header:<groupId>`);
9
10
  // the table renders headers via `renderSectionHeader` without an editable cell.
10
11
  id: string
11
- kind: 'header' | 'item'
12
+ // `tier` rows are a variant's quantity breaks. They are editable rows like
13
+ // any other, so Tab and the arrow keys flow through them rather than
14
+ // stopping at the variant. The last one is always blank and waiting — a
15
+ // ladder is grown by typing in it, not by asking for a row first
16
+ // (docs/plans/6.0-volume-pricing.md).
17
+ kind: 'header' | 'item' | 'tier'
12
18
  // Header rows: the section title (typically the product name).
13
19
  groupLabel?: string
14
20
  // Item rows.
@@ -18,6 +24,11 @@ export interface BulkPriceRow {
18
24
  // user input or the API's canonical decimal. The table passes them through.
19
25
  amount?: string | null
20
26
  compareAt?: string | null
27
+ // On a `tier` row: the quantity it applies from, as typed.
28
+ minQuantity?: string
29
+ // The trailing blank row a variant always carries. It commits to a real
30
+ // break the moment it holds a quantity and a price.
31
+ blank?: boolean
21
32
  }
22
33
 
23
34
  export interface BulkPriceTableLabels {
@@ -33,6 +44,8 @@ export interface BulkPriceTableLabels {
33
44
  variantDefault: string
34
45
  /** Placeholder text for the search input. Omit to hide the search input. */
35
46
  searchPlaceholder?: string
47
+ /** Accessible name for the search field's clear button. */
48
+ clearSearch?: string
36
49
  /** A short message shown above the grid summarising the matching row count. */
37
50
  countSummary?: string
38
51
  /** Shown when the loading flag is set. */
@@ -53,6 +66,14 @@ export interface BulkPriceTableLabels {
53
66
  priceAriaTemplate?: string
54
67
  /** Aria-label template for the compare-at input. `{label}` is replaced with the variant label. */
55
68
  compareAtAriaTemplate?: string
69
+ /** Column header over the quantity column. Omit to hide the column. */
70
+ tierFrom?: string
71
+ /** The "add a break" row's label. */
72
+ tierAdd?: string
73
+ /** Accessible name for a tier row's remove button. */
74
+ tierRemove?: string
75
+ /** Accessible name for a tier row's quantity input. */
76
+ tierQuantity?: string
56
77
  }
57
78
 
58
79
  export interface BulkPriceTableProps {
@@ -65,6 +86,12 @@ export interface BulkPriceTableProps {
65
86
  labels: BulkPriceTableLabels
66
87
  /** Called when the user commits a value to a cell. The id is the row id. */
67
88
  onChange: (rowId: string, field: 'amount' | 'compareAt', value: string | null) => void
89
+ /** Edits a tier row's quantity. */
90
+ onTierQuantityChange?: (rowId: string, value: string) => void
91
+ /** Appends a break to the variant the `tier-add` row belongs to. */
92
+ onTierAdd?: (rowId: string) => void
93
+ /** Removes a tier row. */
94
+ onTierRemove?: (rowId: string) => void
68
95
 
69
96
  // Optional state for the toolbar + pagination footer. Omitting all of these
70
97
  // hides the toolbar/pagination entirely (use for in-memory single-page lists).
@@ -89,6 +116,9 @@ export function BulkPriceTable({
89
116
  decimal,
90
117
  labels,
91
118
  onChange,
119
+ onTierQuantityChange,
120
+ onTierAdd,
121
+ onTierRemove,
92
122
  search,
93
123
  onSearchChange,
94
124
  page,
@@ -103,6 +133,11 @@ export function BulkPriceTable({
103
133
  header: labels.variant,
104
134
  cell: ({ row }) => {
105
135
  const r = row.original
136
+
137
+ // A break belongs to the variant above it, so it says nothing here
138
+ // — the indent and the quantity column carry the relationship.
139
+ if (r.kind === 'tier') return null
140
+
106
141
  if (r.kind !== 'item') return null
107
142
  return (
108
143
  <ReadOnlyCell className="text-muted-foreground">
@@ -124,14 +159,61 @@ export function BulkPriceTable({
124
159
  )
125
160
  },
126
161
  },
162
+ // The quantity a row's price applies from. A variant's own price is the
163
+ // ladder's first rung and always reads 1 — which is why this is a
164
+ // column rather than a panel: every price in the sheet has a quantity,
165
+ // and only the breaks have one worth typing
166
+ // (docs/plans/6.0-volume-pricing.md).
167
+ ...(labels.tierFrom
168
+ ? [
169
+ {
170
+ id: 'min_quantity',
171
+ header: () => <span className="block text-right">{labels.tierFrom}</span>,
172
+ cell: ({ row, table }) => {
173
+ const r = row.original
174
+ if (r.kind === 'item') {
175
+ // justify-end, not text-right: ReadOnlyCell is a flex row,
176
+ // so the quantity would otherwise sit left of every editable
177
+ // quantity below it.
178
+ return (
179
+ <ReadOnlyCell className="justify-end text-muted-foreground tabular-nums">
180
+ 1
181
+ </ReadOnlyCell>
182
+ )
183
+ }
184
+
185
+ if (r.kind !== 'tier') return null
186
+
187
+ const coords = { row: editableRowIndex(table.getRowModel().rows, r.id), col: 2 }
188
+ return (
189
+ // Decimal rather than number: a rung being typed has no
190
+ // quantity yet, and a numeric cell would show that as 0 —
191
+ // a value nobody chose and the server refuses.
192
+ <DecimalCell
193
+ coords={coords}
194
+ value={r.minQuantity || null}
195
+ onChange={(next) => onTierQuantityChange?.(r.id, next ?? '')}
196
+ decimal={decimal}
197
+ ariaLabel={labels.tierQuantity ?? labels.tierFrom ?? ''}
198
+ />
199
+ )
200
+ },
201
+ } satisfies ColumnDef<BulkPriceRow>,
202
+ ]
203
+ : []),
127
204
  {
128
205
  id: 'amount',
129
206
  header: () => <span className="block text-right">{labels.price}</span>,
130
207
  cell: ({ row, table }) => {
131
208
  const r = row.original
132
- if (r.kind !== 'item') return null
133
- const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 2 }
134
- const label = r.variantLabel ?? labels.variantDefault
209
+ // A break's unit price sits in the price column, under the price it
210
+ // replaces from that quantity up.
211
+ if (r.kind !== 'item' && r.kind !== 'tier') return null
212
+ const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 3 }
213
+ const label =
214
+ r.kind === 'tier'
215
+ ? `${labels.tierFrom ?? ''} ${r.minQuantity ?? ''}`.trim()
216
+ : (r.variantLabel ?? labels.variantDefault)
135
217
  return (
136
218
  <MoneyCell
137
219
  coords={coords}
@@ -149,8 +231,10 @@ export function BulkPriceTable({
149
231
  header: () => <span className="block text-right">{labels.compareAt}</span>,
150
232
  cell: ({ row, table }) => {
151
233
  const r = row.original
234
+ // Variants only: a break is a contracted unit price, never a claim
235
+ // about a former one.
152
236
  if (r.kind !== 'item') return null
153
- const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 3 }
237
+ const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 4 }
154
238
  const label = r.variantLabel ?? labels.variantDefault
155
239
  return (
156
240
  <MoneyCell
@@ -164,8 +248,49 @@ export function BulkPriceTable({
164
248
  )
165
249
  },
166
250
  },
251
+ // A break removes itself where it sits; the trailing blank row offers
252
+ // the plus that starts another. Both fill the cell, so the whole square
253
+ // is the target rather than a small glyph inside it.
254
+ ...(onTierRemove
255
+ ? [
256
+ {
257
+ id: 'tier_actions',
258
+ header: () => <span className="sr-only">{labels.tierRemove}</span>,
259
+ cell: ({ row }) => {
260
+ const r = row.original
261
+ if (r.kind !== 'tier') return null
262
+
263
+ if (r.blank) {
264
+ return (
265
+ <Button
266
+ type="button"
267
+ variant="ghost"
268
+ aria-label={labels.tierAdd}
269
+ className="size-full min-h-9 rounded-none text-muted-foreground"
270
+ onClick={() => onTierAdd?.(r.id)}
271
+ >
272
+ <PlusIcon className="size-4" />
273
+ </Button>
274
+ )
275
+ }
276
+
277
+ return (
278
+ <Button
279
+ type="button"
280
+ variant="ghost"
281
+ aria-label={labels.tierRemove}
282
+ className="size-full min-h-9 rounded-none text-muted-foreground"
283
+ onClick={() => onTierRemove(r.id)}
284
+ >
285
+ <XIcon className="size-4" />
286
+ </Button>
287
+ )
288
+ },
289
+ } satisfies ColumnDef<BulkPriceRow>,
290
+ ]
291
+ : []),
167
292
  ],
168
- [symbol, decimal, onChange, labels],
293
+ [symbol, decimal, onChange, onTierQuantityChange, onTierAdd, onTierRemove, labels],
169
294
  )
170
295
 
171
296
  const showToolbar =
@@ -177,28 +302,34 @@ export function BulkPriceTable({
177
302
  const hasSearch = !!search && search.length > 0
178
303
 
179
304
  return (
305
+ // Padding lives on the toolbar and the pager, not on this wrapper: the
306
+ // grid between them should run to the container's edges, the way a
307
+ // table in a card does.
180
308
  <div className="flex h-full flex-col gap-3">
181
309
  {showToolbar && (
182
310
  // Always-mounted toolbar. Conditionally rendering the search input
183
311
  // would unmount it whenever a deferred query refetches into the
184
312
  // loading state, blurring the field mid-keystroke.
185
- <div className="flex shrink-0 items-center justify-between gap-3">
313
+ <div className="flex shrink-0 items-center justify-between gap-3 px-3">
186
314
  {labels.countSummary !== undefined && (
187
315
  <p className="text-xs text-muted-foreground">{labels.countSummary}</p>
188
316
  )}
189
317
  {onSearchChange !== undefined && labels.searchPlaceholder !== undefined && (
190
- <Input
191
- type="search"
318
+ <SearchInput
192
319
  placeholder={labels.searchPlaceholder}
193
320
  value={search ?? ''}
194
- onChange={(e) => onSearchChange(e.target.value)}
321
+ onValueChange={onSearchChange}
322
+ clearLabel={labels.clearSearch}
195
323
  className="h-9 max-w-sm"
196
324
  />
197
325
  )}
198
326
  </div>
199
327
  )}
200
328
 
201
- <div className="min-h-0 flex-1 overflow-auto">
329
+ {/* No `overflow-auto` here: the grid inside owns its own scroller
330
+ (it needs one for the sticky header), and a second one around it
331
+ nests two scrollbars over the same content. */}
332
+ <div className="min-h-0 flex-1">
202
333
  {isLoading ? (
203
334
  <p className="text-sm text-muted-foreground">{labels.loading}</p>
204
335
  ) : isEmpty ? (
@@ -223,7 +354,7 @@ export function BulkPriceTable({
223
354
  </div>
224
355
 
225
356
  {showPagination && (
226
- <div className="flex shrink-0 items-center justify-between gap-2 text-xs text-muted-foreground">
357
+ <div className="flex shrink-0 items-center justify-between gap-2 px-3 pb-3 text-xs text-muted-foreground">
227
358
  <span>
228
359
  {labels.pageOf.replace('{page}', String(page)).replace('{total}', String(totalPages))}
229
360
  </span>