@spree/dashboard-core 0.13.1 → 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 +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
|
@@ -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
|
|
56
|
+
/** Main title, on its own line. */
|
|
49
57
|
title: ReactNode
|
|
50
|
-
/**
|
|
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
|
|
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
|
|
60
|
-
* (e.g
|
|
61
|
-
* `page.actions_dropdown` slot and the
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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 '
|
|
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
|
|
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>
|