@spree/dashboard-core 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 (126) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +97 -36
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +425 -131
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +261 -14
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -5,16 +5,24 @@ import {
5
5
  DropdownMenu,
6
6
  DropdownMenuContent,
7
7
  DropdownMenuItem,
8
- DropdownMenuSeparator,
9
8
  DropdownMenuTrigger,
9
+ ExternalLink,
10
10
  useConfirm,
11
11
  useCopyToClipboard,
12
12
  useScrolled,
13
13
  } from '@spree/dashboard-ui'
14
+ import {
15
+ BracesIcon,
16
+ CheckIcon,
17
+ CopyIcon,
18
+ EllipsisVerticalIcon,
19
+ TrashIcon,
20
+ } from '@spree/dashboard-ui/icons'
14
21
  import type { JsonPreviewDrawerProps } from '@spree/dashboard-ui/spree/json-preview-drawer'
15
- import { BracesIcon, CheckIcon, CopyIcon, EllipsisVerticalIcon, TrashIcon } from 'lucide-react'
16
22
  import { lazy, type ReactNode, Suspense, useState } from 'react'
17
23
  import { useTranslation } from 'react-i18next'
24
+ import { docsUrl } from '../lib/docs'
25
+ import { useRegisterPageHeader, useStickyHeaderOffset } from '../providers/sticky-header-provider'
18
26
  import { Slot } from './slot'
19
27
 
20
28
  // JSON drawer is a developer-only feature; pulling its tree (which includes
@@ -45,22 +53,57 @@ export interface PageHeaderResource {
45
53
  }
46
54
 
47
55
  interface PageHeaderProps {
48
- /** Main title (left of any badges). */
56
+ /** Main title, on its own line. */
49
57
  title: ReactNode
50
- /** Small text below or beside the title (updated_at badge, customer email, etc.). */
58
+ /**
59
+ * Whether the header pins to the top of the scroll container. Default true,
60
+ * which is right for a detail page: the title and its Save button have to
61
+ * stay reachable down a long form.
62
+ *
63
+ * A list page passes `false`. There the table's own column row is the band
64
+ * worth pinning — it is what a reader needs while scrolling rows — and two
65
+ * stacked sticky bands spend a third of a short viewport on chrome. Turning
66
+ * this off also stops the header publishing a sticky offset, so the column
67
+ * row pins to the top of the sheet rather than below a header that is no
68
+ * longer there.
69
+ */
70
+ sticky?: boolean
71
+ /** Small text beside the badges (updated_at badge, customer email, etc.). */
51
72
  subtitle?: ReactNode
73
+ /**
74
+ * One line under the title saying what the page is for. Unlike `subtitle`,
75
+ * which shares the badge row and answers "where is this now", this explains
76
+ * the feature itself — worth writing for something a merchant meets for the
77
+ * first time (catalogs, price lists, transfers) and worth leaving off a page
78
+ * that explains itself.
79
+ */
80
+ description?: ReactNode
81
+ /**
82
+ * Documentation for the feature, linked at the end of the description.
83
+ * Relative to the user guide, or a full URL for a plugin's own docs.
84
+ */
85
+ docsPath?: string
52
86
  /** Back button target — passed to <BackButton fallback="..."/>. Omit for top-level pages. */
53
87
  backTo?: string
54
- /** Status badges rendered next to the title. Use <StatusBadge /> for consistency. */
88
+ /** Status badges, on the line under the title. Use <StatusBadge />. */
55
89
  badges?: ReactNode
56
90
  /** Primary action buttons (rightmost, before the dropdown). Most pages use this for "Save". */
57
91
  actions?: ReactNode
58
92
  /**
59
- * Domain-specific items to render at the top of the more-actions dropdown
60
- * (e.g., Complete/Approve/Cancel on orders). Rendered above the
61
- * `page.actions_dropdown` slot and the auto-wired Copy ID / Delete items.
93
+ * Domain-specific items for the top group of the more-actions dropdown
94
+ * (e.g. Preview, Resend, Approve on orders). Rendered above the
95
+ * `page.actions_dropdown` slot and the standard Copy ID / JSON items.
96
+ *
97
+ * Destructive entries belong in `destructiveItems`, not here — the dropdown
98
+ * orders its three groups itself so every page reads the same way.
62
99
  */
63
100
  dropdownItems?: ReactNode
101
+ /**
102
+ * Destructive domain actions (Cancel order, a delete with its own confirm).
103
+ * Always rendered last, in the same group as the auto-wired Delete, so a
104
+ * page can never put a red item above the standard actions.
105
+ */
106
+ destructiveItems?: ReactNode
64
107
  /**
65
108
  * The resource being edited. When supplied, PageHeader renders the legacy
66
109
  * "more actions" dropdown (Copy ID, Copy number, Delete). When omitted, the
@@ -70,14 +113,22 @@ interface PageHeaderProps {
70
113
  /** Slot context name. Defaults to inferring from `resource` keys. Optional. */
71
114
  slotContext?: Record<string, unknown>
72
115
  /**
73
- * Called after the user confirms the auto-rendered Delete action.
74
- * When provided, the Delete item is enabled. The confirmation prompt is
75
- * fixed ("Are you sure? This action cannot be undone.") — pass `dropdownItems`
76
- * directly if you need a custom delete flow.
116
+ * Called after the user confirms the auto-rendered Delete action. When
117
+ * provided, the Delete item is enabled. PageHeader owns the confirmation,
118
+ * so the handler must not prompt again — pass `dropdownItems` directly if
119
+ * you need a delete flow this cannot express.
77
120
  */
78
121
  onDelete?: () => void | Promise<void>
79
122
  /** Override the destructive label ("Delete order", "Delete product", etc.). */
80
123
  deleteLabel?: string
124
+ /**
125
+ * Replaces the generic "Are you sure?" prompt. Say what goes with the
126
+ * record — "Its zones and delivery methods go with it" — since that is what
127
+ * the reader cannot see from the button.
128
+ */
129
+ deleteConfirmMessage?: string
130
+ /** Heading for the delete prompt. Defaults to none, as the message carries it. */
131
+ deleteConfirmTitle?: string
81
132
  /**
82
133
  * When supplied, the more-actions dropdown gains a "View as JSON" item that
83
134
  * opens a developer-style drawer with the resource payload.
@@ -96,20 +147,28 @@ interface PageHeaderProps {
96
147
  */
97
148
  export function PageHeader({
98
149
  title,
150
+ sticky = true,
99
151
  subtitle,
152
+ description,
153
+ docsPath,
100
154
  backTo,
101
155
  badges,
102
156
  actions,
103
157
  dropdownItems,
158
+ destructiveItems,
104
159
  resource,
105
160
  slotContext,
106
161
  onDelete,
107
162
  deleteLabel,
163
+ deleteConfirmMessage,
164
+ deleteConfirmTitle,
108
165
  jsonPreview,
109
166
  }: PageHeaderProps) {
110
167
  const { t } = useTranslation()
111
168
  const slotCtx = { ...slotContext, resource }
112
- const showDropdown = Boolean(resource || onDelete || dropdownItems || jsonPreview)
169
+ const showDropdown = Boolean(
170
+ resource || onDelete || dropdownItems || destructiveItems || jsonPreview,
171
+ )
113
172
  const [jsonOpen, setJsonOpen] = useState(false)
114
173
  // Latches true on first open so the drawer (and its lazy JsonView chunk)
115
174
  // doesn't mount until the user actually invokes it, but stays mounted
@@ -120,15 +179,28 @@ export function PageHeader({
120
179
  setJsonOpen(true)
121
180
  }
122
181
  const scrolled = useScrolled()
182
+ // Publishes this header's measured height as the sticky offset every other
183
+ // pinned band reads (`top-header-height`): the table's pinned column row,
184
+ // the bulk-action bar. There is no top bar any more, so this header IS the
185
+ // chrome above the scroll area — and its height genuinely varies with
186
+ // badges, a subtitle and wrapped actions, which a fixed constant could only
187
+ // approximate. Measured rather than assumed, so those bands sit flush
188
+ // against it at every size.
189
+ const headerRef = useStickyHeaderOffset(sticky)
190
+ // Announces this header so the page's other headings pick the right level —
191
+ // a table below one renders its title as `h2` rather than a second `h1`.
192
+ useRegisterPageHeader()
123
193
 
124
194
  return (
125
- // Sticky below the TopBar (which sticks at `top-0`, height
126
- // `--spacing-header-height`) so the title, badges, and primary actions
127
- // (notably Save on form pages) stay reachable as the user scrolls long
128
- // detail pages. `bg-background` masks the content scrolling behind it;
195
+ // Pins at the very top of the viewport — with the top bar gone, the title,
196
+ // badges and primary actions (notably Save on form pages) are the only
197
+ // chrome that needs to stay reachable down a long page.
198
+ //
199
+ // `bg-card` masks the content scrolling behind it — the same surface as
200
+ // the content sheet this sits in, so the pinned band is invisible at rest
201
+ // rather than a stripe of the page ground across the top of a white sheet;
129
202
  // `-mx-4 px-4 lg:-mx-6 lg:px-6` and `-mt-4 lg:-mt-6 pt-4 lg:pt-6` undo
130
- // and re-apply the parent padding so the sticky band runs edge-to-edge
131
- // and there's no transparent gap between the TopBar and the header.
203
+ // and re-apply the parent padding so the sticky band runs edge-to-edge.
132
204
  //
133
205
  // The `::after` pseudo-element is the bottom hairline — it fades in
134
206
  // once the user scrolls (so the header blends at rest, separates when
@@ -137,27 +209,70 @@ export function PageHeader({
137
209
  // collide with the page edges; the `border-border` color it carries is
138
210
  // the same hairline used elsewhere in the app.
139
211
  <header
212
+ ref={headerRef}
140
213
  className={cn(
141
- 'sticky top-header-height z-20 -mx-4 -mt-4 flex items-start gap-3 bg-background px-4 pt-4 pb-3 lg:-mx-6 lg:-mt-6 lg:px-6 lg:pt-6',
142
- 'after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:opacity-0 after:transition-opacity after:duration-150 after:[mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]',
143
- scrolled && 'after:opacity-100',
214
+ // One row at every width. The title truncates rather than wrapping,
215
+ // so it yields space to the actions instead of pushing them onto a
216
+ // second row — a page with only a Save button was spending two rows
217
+ // of a short viewport on chrome.
218
+ // Opaque for the same reason as the top bar: this band pins over the
219
+ // page's own scrolling content, so a translucent fill lets that content
220
+ // ghost through it.
221
+ '-mx-4 -mt-4 flex flex-row items-start gap-2 bg-card p-4 sm:gap-3 lg:-mx-6 lg:-mt-6 lg:px-6 lg:pt-6',
222
+ sticky && 'sticky top-0 z-20',
223
+ 'transition-[box-shadow] duration-200 ease-out motion-reduce:transition-none',
224
+ 'after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:opacity-0 after:transition-opacity after:duration-200 after:[mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]',
225
+ sticky && scrolled && 'after:opacity-100 shadow-xs',
144
226
  )}
145
227
  >
146
- {backTo && <BackButton fallback={backTo} />}
228
+ {/* Row one: back button beside the title, so the arrow keeps its
229
+ relationship to the heading rather than floating above it. */}
230
+ <div className="flex min-w-0 flex-1 items-start gap-3">
231
+ {backTo && <BackButton fallback={backTo} />}
147
232
 
148
- {/* Title + badges share the first row; subtitle drops to its own line
149
- underneath so descriptive prose doesn't get crammed alongside the
150
- heading. Short metadata subtitles (customer location, order status
151
- hint) read fine on the second line too. */}
152
- <div className="flex min-w-0 flex-1 flex-col gap-1">
153
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
154
- <h1 className="text-2xl font-medium leading-tight">{title}</h1>
155
- {badges}
233
+ {/* The heading owns its line, with everything that qualifies it on
234
+ the line below, so the eye reads the name first rather than
235
+ picking it out of a row it shares. */}
236
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
237
+ {/* Smaller and clipped on a phone: sharing the row with the actions
238
+ leaves a long product name too little width to wrap into
239
+ anything readable. */}
240
+ <h1 className="min-w-0 truncate font-semibold text-xl leading-tight sm:text-2xl sm:whitespace-normal">
241
+ {title}
242
+ </h1>
243
+ {/* Statuses and the timestamp share the second line: both answer
244
+ "where is this now", so splitting them costs a row and reads as
245
+ two separate facts. The subtitle is clamped on a phone, where a
246
+ long one would make the sticky band tall enough to eat the page. */}
247
+ {(badges || subtitle) && (
248
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
249
+ {badges}
250
+ {subtitle && (
251
+ <span className="line-clamp-2 text-sm text-muted-foreground sm:line-clamp-none">
252
+ {subtitle}
253
+ </span>
254
+ )}
255
+ </div>
256
+ )}
257
+ {(description || docsPath) && (
258
+ <p className="text-muted-foreground text-sm">
259
+ {description}
260
+ {docsPath && (
261
+ <>
262
+ {description ? ' ' : null}
263
+ <ExternalLink href={docsUrl(docsPath)}>
264
+ {t('admin.common.learn_more')}
265
+ </ExternalLink>
266
+ </>
267
+ )}
268
+ </p>
269
+ )}
156
270
  </div>
157
- {subtitle && <span className="text-sm text-muted-foreground">{subtitle}</span>}
158
271
  </div>
159
272
 
160
- <div className="ml-auto flex items-center gap-2">
273
+ {/* Pinned to the end of the row. `shrink-0` so the actions keep their
274
+ full width and the title absorbs the shortfall. */}
275
+ <div className="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
161
276
  <Slot name="page.actions" context={slotCtx} />
162
277
  {actions}
163
278
  {showDropdown && (
@@ -165,8 +280,11 @@ export function PageHeader({
165
280
  resource={resource}
166
281
  slotContext={slotCtx}
167
282
  dropdownItems={dropdownItems}
283
+ destructiveItems={destructiveItems}
168
284
  onDelete={onDelete}
169
285
  deleteLabel={deleteLabel ?? t('admin.actions.delete')}
286
+ deleteConfirmMessage={deleteConfirmMessage}
287
+ deleteConfirmTitle={deleteConfirmTitle}
170
288
  onOpenJson={jsonPreview ? openJson : undefined}
171
289
  />
172
290
  )}
@@ -189,8 +307,11 @@ interface PageActionsDropdownProps {
189
307
  resource?: PageHeaderResource
190
308
  slotContext: Record<string, unknown>
191
309
  dropdownItems?: ReactNode
310
+ destructiveItems?: ReactNode
192
311
  onDelete?: () => void | Promise<void>
193
312
  deleteLabel: string
313
+ deleteConfirmMessage?: string
314
+ deleteConfirmTitle?: string
194
315
  onOpenJson?: () => void
195
316
  }
196
317
 
@@ -198,8 +319,11 @@ function PageActionsDropdown({
198
319
  resource,
199
320
  slotContext,
200
321
  dropdownItems,
322
+ destructiveItems,
201
323
  onDelete,
202
324
  deleteLabel,
325
+ deleteConfirmMessage,
326
+ deleteConfirmTitle,
203
327
  onOpenJson,
204
328
  }: PageActionsDropdownProps) {
205
329
  const { t } = useTranslation()
@@ -208,42 +332,60 @@ function PageActionsDropdown({
208
332
  return (
209
333
  <DropdownMenu>
210
334
  <DropdownMenuTrigger asChild>
211
- <Button size="icon-sm" variant="outline" aria-label={t('admin.actions.more_actions')}>
335
+ <Button size="icon" variant="outline" aria-label={t('admin.actions.more_actions')}>
212
336
  <EllipsisVerticalIcon className="size-4" />
213
337
  </Button>
214
338
  </DropdownMenuTrigger>
215
- <DropdownMenuContent align="end">
216
- {dropdownItems}
217
- <Slot name="page.actions_dropdown" context={slotContext} />
339
+ {/* Three fixed groups, in this order: domain actions, standard actions,
340
+ destructive actions. The order lives here rather than in each caller
341
+ so every record page's menu reads the same way.
218
342
 
219
- {onOpenJson && (
220
- <DropdownMenuItem onClick={onOpenJson}>
221
- <BracesIcon className="size-4" />
222
- {t('admin.components.page_header.view_as_json')}
223
- </DropdownMenuItem>
224
- )}
225
- {resource?.number && (
226
- <CopyToClipboardItem
227
- label={t('admin.components.page_header.copy_number')}
228
- value={resource.number}
229
- />
230
- )}
231
- {resource && (
232
- <CopyToClipboardItem
233
- label={t('admin.components.page_header.copy_id')}
234
- value={resource.id}
235
- />
236
- )}
343
+ Dividers are drawn by CSS (`:not(:empty) ~ :not(:empty)`) rather than
344
+ by rendering a <DropdownMenuSeparator> between groups. A group's
345
+ content can be present as a value but render nothing — an all-false
346
+ fragment, or a plugin slot with no registrations — which a JS check
347
+ reads as "has content" and would leave a divider floating against the
348
+ menu's edge. `:empty` asks the DOM what actually rendered. */}
349
+ <DropdownMenuContent
350
+ align="end"
351
+ className="[&>[data-menu-group]:not(:empty)~[data-menu-group]:not(:empty)]:mt-1 [&>[data-menu-group]:not(:empty)~[data-menu-group]:not(:empty)]:border-t [&>[data-menu-group]:not(:empty)~[data-menu-group]:not(:empty)]:border-border-subtle [&>[data-menu-group]:not(:empty)~[data-menu-group]:not(:empty)]:pt-1"
352
+ >
353
+ <div data-menu-group>
354
+ {dropdownItems}
355
+ <Slot name="page.actions_dropdown" context={slotContext} />
356
+ </div>
357
+
358
+ <div data-menu-group>
359
+ {onOpenJson && (
360
+ <DropdownMenuItem onClick={onOpenJson}>
361
+ <BracesIcon className="size-4" />
362
+ {t('admin.components.page_header.view_as_json')}
363
+ </DropdownMenuItem>
364
+ )}
365
+ {resource?.number && (
366
+ <CopyToClipboardItem
367
+ label={t('admin.components.page_header.copy_number')}
368
+ value={resource.number}
369
+ />
370
+ )}
371
+ {resource && (
372
+ <CopyToClipboardItem
373
+ label={t('admin.components.page_header.copy_id')}
374
+ value={resource.id}
375
+ />
376
+ )}
377
+ </div>
237
378
 
238
- {onDelete && (
239
- <>
240
- <DropdownMenuSeparator />
379
+ <div data-menu-group>
380
+ {destructiveItems}
381
+ {onDelete && (
241
382
  <DropdownMenuItem
242
383
  variant="destructive"
243
384
  onClick={async () => {
244
385
  if (
245
386
  await confirm({
246
- message: t('admin.common.delete_confirm_message'),
387
+ title: deleteConfirmTitle,
388
+ message: deleteConfirmMessage ?? t('admin.common.delete_confirm_message'),
247
389
  variant: 'destructive',
248
390
  confirmLabel: deleteLabel,
249
391
  })
@@ -255,8 +397,8 @@ function PageActionsDropdown({
255
397
  <TrashIcon className="size-4" />
256
398
  {deleteLabel}
257
399
  </DropdownMenuItem>
258
- </>
259
- )}
400
+ )}
401
+ </div>
260
402
  </DropdownMenuContent>
261
403
  </DropdownMenu>
262
404
  )
@@ -48,7 +48,10 @@ export function PageTabs({ tabs, slotName = 'page.tabs', slotContext, className
48
48
  const target =
49
49
  typeof tab.to === 'string'
50
50
  ? tab.to
51
- : router.buildLocation({ to: tab.to, params: tab.params, search: tab.search }).pathname
51
+ : // Same literal-route caveat as the <Link> below: `to` is unknown
52
+ // at this generic call site.
53
+ router.buildLocation({ to: tab.to as never, params: tab.params, search: tab.search })
54
+ .pathname
52
55
  const active =
53
56
  tab.match === 'prefix'
54
57
  ? location.pathname.startsWith(target)
@@ -2,17 +2,24 @@ import type { PreferenceField as PreferenceFieldDef } from '@spree/admin-sdk'
2
2
  import {
3
3
  Button,
4
4
  Field,
5
+ FieldDescription,
5
6
  FieldGroup,
6
7
  FieldLabel,
7
8
  Input,
8
9
  SecretInput,
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
9
15
  Switch,
10
16
  Textarea,
11
17
  } from '@spree/dashboard-ui'
12
- import { PlusIcon, TrashIcon } from 'lucide-react'
18
+ import { PlusIcon, TrashIcon } from '@spree/dashboard-ui/icons'
13
19
  import { useId, useMemo, useState } from 'react'
14
20
  import { useTranslation } from 'react-i18next'
15
21
  import { CurrencySelect } from './currency-select'
22
+ import { StoreDatePicker } from './store-date-picker'
16
23
 
17
24
  /**
18
25
  * Hydrates a `preferences` hash with each field's default. Used to seed
@@ -50,6 +57,20 @@ interface PreferencesFormProps {
50
57
  redactPasswords?: boolean
51
58
  /** Optional human-readable name overrides keyed by preference key. */
52
59
  labelOverrides?: Record<string, string>
60
+ /**
61
+ * Currencies a currency-typed preference may be set to. Defaults to the
62
+ * store's when a `StoreProvider` is mounted. Panels without one (the
63
+ * marketplace seller panel) pass their own list — an unconstrained picker
64
+ * would let a currency the store does not trade in be saved, and a
65
+ * calculator set to one quotes no rate at all.
66
+ */
67
+ currencyOptions?: string[]
68
+ /**
69
+ * Render date/datetime pickers inline instead of in a Popover portal.
70
+ * Set this when the form is inside a `<Sheet>` — Base UI's Popover fails
71
+ * to mount in deeply nested portal trees, so the calendar never appears.
72
+ */
73
+ inlineDatePickers?: boolean
53
74
  }
54
75
 
55
76
  /**
@@ -70,6 +91,8 @@ export function PreferencesForm({
70
91
  onChange,
71
92
  redactPasswords = false,
72
93
  labelOverrides,
94
+ currencyOptions,
95
+ inlineDatePickers,
73
96
  }: PreferencesFormProps) {
74
97
  if (!schema?.length) return null
75
98
 
@@ -88,6 +111,8 @@ export function PreferencesForm({
88
111
  label={labelOverrides?.[field.key]}
89
112
  onChange={(v) => setValue(field.key, v)}
90
113
  redactPasswords={redactPasswords}
114
+ currencyOptions={currencyOptions}
115
+ inlineDatePickers={inlineDatePickers}
91
116
  />
92
117
  ))}
93
118
  </FieldGroup>
@@ -100,6 +125,8 @@ interface PreferenceFieldProps {
100
125
  label?: string
101
126
  onChange: (value: unknown) => void
102
127
  redactPasswords?: boolean
128
+ currencyOptions?: string[]
129
+ inlineDatePickers?: boolean
103
130
  }
104
131
 
105
132
  export function PreferenceField({
@@ -108,6 +135,8 @@ export function PreferenceField({
108
135
  label,
109
136
  onChange,
110
137
  redactPasswords,
138
+ currencyOptions,
139
+ inlineDatePickers,
111
140
  }: PreferenceFieldProps) {
112
141
  const { t, i18n } = useTranslation()
113
142
  const id = `preference-${field.key}`
@@ -117,6 +146,11 @@ export function PreferenceField({
117
146
  const preferenceKey = `admin.preferences.${field.key}`
118
147
  const displayLabel =
119
148
  label ?? (i18n.exists(preferenceKey) ? t(preferenceKey) : humanizeKey(field.key))
149
+ // Optional one-line help, under the same key convention as the label. Most
150
+ // preferences say what they are in their name; the ones that do not — a
151
+ // customs signer, an incoterm — are unguessable without it.
152
+ const descriptionKey = `${preferenceKey}_description`
153
+ const description = i18n.exists(descriptionKey) ? t(descriptionKey) : undefined
120
154
 
121
155
  // Currency-typed preferences (`currency`, `default_currency`,
122
156
  // `display_currency`, …) get the store's CurrencySelect — same
@@ -125,7 +159,12 @@ export function PreferenceField({
125
159
  return (
126
160
  <Field>
127
161
  <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
128
- <CurrencySelect id={id} value={(value as string) ?? ''} onChange={onChange} />
162
+ <CurrencySelect
163
+ id={id}
164
+ value={(value as string) ?? ''}
165
+ onChange={onChange}
166
+ options={currencyOptions}
167
+ />
129
168
  </Field>
130
169
  )
131
170
  }
@@ -164,6 +203,26 @@ export function PreferenceField({
164
203
  </Field>
165
204
  )
166
205
 
206
+ // A date is a picker, never a text box: an operator typing "1 Jan 2026"
207
+ // into a field the server parses as ISO-8601 gets a validation error with
208
+ // no hint of the format wanted. `<StoreDatePicker>` also reads the store's
209
+ // timezone, so a date means the same day for every admin.
210
+ case 'date':
211
+ case 'datetime':
212
+ return (
213
+ <Field>
214
+ <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
215
+ <StoreDatePicker
216
+ id={id}
217
+ value={(value as string) ?? null}
218
+ onChange={(next) => onChange(next)}
219
+ includeTime={field.type === 'datetime'}
220
+ inline={inlineDatePickers}
221
+ />
222
+ {description && <FieldDescription>{description}</FieldDescription>}
223
+ </Field>
224
+ )
225
+
167
226
  case 'integer':
168
227
  case 'decimal':
169
228
  return (
@@ -228,6 +287,41 @@ export function PreferenceField({
228
287
  )
229
288
 
230
289
  default:
290
+ // A preference that declares a fixed set is a picker, not a text box:
291
+ // the set is what the server validates against, so typing anything
292
+ // else can only fail on save.
293
+ if (field.choices?.length) {
294
+ const options = field.choices.map((choice) => ({
295
+ value: choice,
296
+ label: i18n.exists(`${preferenceKey}_options.${choice}`)
297
+ ? t(`${preferenceKey}_options.${choice}`)
298
+ : humanizeKey(choice),
299
+ }))
300
+
301
+ return (
302
+ <Field>
303
+ <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
304
+ <Select
305
+ items={options}
306
+ value={(value as string) ?? ''}
307
+ onValueChange={(next) => onChange(next ?? '')}
308
+ >
309
+ <SelectTrigger id={id}>
310
+ <SelectValue />
311
+ </SelectTrigger>
312
+ <SelectContent>
313
+ {options.map((option) => (
314
+ <SelectItem key={option.value} value={option.value}>
315
+ {option.label}
316
+ </SelectItem>
317
+ ))}
318
+ </SelectContent>
319
+ </Select>
320
+ {description && <FieldDescription>{description}</FieldDescription>}
321
+ </Field>
322
+ )
323
+ }
324
+
231
325
  return (
232
326
  <Field>
233
327
  <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
@@ -236,6 +330,7 @@ export function PreferenceField({
236
330
  value={(value as string) ?? ''}
237
331
  onChange={(e) => onChange(e.target.value)}
238
332
  />
333
+ {description && <FieldDescription>{description}</FieldDescription>}
239
334
  </Field>
240
335
  )
241
336
  }
@@ -389,10 +484,9 @@ function TierRow({
389
484
  <Button
390
485
  type="button"
391
486
  size="icon-sm"
392
- variant="ghost"
487
+ variant="destructive-ghost"
393
488
  onClick={onRemove}
394
489
  aria-label={t('admin.components.preferences_form.tiers.remove_tier')}
395
- className="text-destructive hover:bg-destructive/10 hover:text-destructive"
396
490
  >
397
491
  <TrashIcon className="size-4" />
398
492
  </Button>