@spree/dashboard-core 6.0.3 → 6.0.4
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/dist/vite/index.js +81 -5
- package/package.json +19 -19
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +1 -1
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +189 -323
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +19 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +120 -14
- package/src/locales/de.json +120 -14
- package/src/locales/en.json +120 -14
- package/src/locales/es.json +120 -14
- package/src/locales/fr.json +120 -14
- package/src/locales/ja.json +120 -14
- package/src/locales/pl.json +120 -14
- package/src/locales/vi.json +120 -14
- package/src/locales/zh-CN.json +120 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +201 -70
- package/src/products/product-form-mapping.ts +1 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -1,307 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
SheetDescription,
|
|
7
|
-
SheetFooter,
|
|
8
|
-
SheetHeader,
|
|
9
|
-
SheetTitle,
|
|
10
|
-
} from '@spree/dashboard-ui'
|
|
11
|
-
import { CheckIcon, ImageIcon, Loader2Icon, PlayIcon, UploadIcon } from '@spree/dashboard-ui/icons'
|
|
12
|
-
import { useQuery } from '@tanstack/react-query'
|
|
13
|
-
import { useDeferredValue, useRef, useState } from 'react'
|
|
14
|
-
import { useTranslation } from 'react-i18next'
|
|
15
|
-
import { useTenantId } from '../providers/tenant-provider'
|
|
2
|
+
MediaPickerDialog,
|
|
3
|
+
type MediaPickerDialogProps,
|
|
4
|
+
type MediaPickerOption,
|
|
5
|
+
} from './media-picker-dialog'
|
|
16
6
|
|
|
17
|
-
|
|
18
|
-
export interface MediaPickerOption {
|
|
19
|
-
id: string
|
|
20
|
-
alt?: string | null
|
|
21
|
-
filename?: string | null
|
|
22
|
-
media_type?: string
|
|
23
|
-
small_url?: string | null
|
|
24
|
-
poster_url?: string | null
|
|
25
|
-
original_url?: string | null
|
|
26
|
-
/** Blob signed id, for handing a picked file to a plain attachment field. */
|
|
27
|
-
signed_id?: string | null
|
|
28
|
-
}
|
|
7
|
+
export type { MediaPickerOption, MediaPickerSearchOptions } from './media-picker-dialog'
|
|
29
8
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
onOpenChange: (open: boolean) => void
|
|
33
|
-
/** Paginated search over the library. Called with the trimmed query. */
|
|
34
|
-
search: (query: string) => Promise<{ data: T[] }>
|
|
35
|
-
/** Called with the picked files when the user confirms. */
|
|
36
|
-
onConfirm: (media: T[]) => void | Promise<void>
|
|
37
|
-
/** Uploads a file and returns the created library row, for the upload tab. */
|
|
38
|
-
onUpload?: (file: File) => Promise<T>
|
|
39
|
-
/** One file only — the picker confirms as soon as a tile is clicked. */
|
|
40
|
-
multiple?: boolean
|
|
41
|
-
/** Cache-isolation key, one per picker instance. */
|
|
42
|
-
queryKey: string
|
|
43
|
-
accept?: string
|
|
44
|
-
title?: string
|
|
45
|
-
description?: string
|
|
46
|
-
confirmLabel?: string
|
|
47
|
-
onConfirmError?: (error: unknown) => void
|
|
48
|
-
}
|
|
9
|
+
/** @deprecated Renamed to `MediaPickerDialogProps`; removed in the next minor release. */
|
|
10
|
+
export type MediaPickerSheetProps<T extends MediaPickerOption> = MediaPickerDialogProps<T>
|
|
49
11
|
|
|
50
12
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* A grid rather than the row list {@link ResourcePickerSheet} uses: choosing a
|
|
54
|
-
* picture means looking at it, and a filename tells a merchant almost nothing
|
|
55
|
-
* about which photo it is.
|
|
56
|
-
*
|
|
57
|
-
* Headless — the caller supplies `search` and `onUpload`, so the same sheet
|
|
58
|
-
* serves the product media card (which places files on a product) and plain
|
|
59
|
-
* image fields (which take the picked file's blob).
|
|
13
|
+
* @deprecated The picker is now a dialog; use `MediaPickerDialog`. This name
|
|
14
|
+
* keeps working until the next minor release.
|
|
60
15
|
*/
|
|
61
|
-
export function MediaPickerSheet<T extends MediaPickerOption>({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
onConfirm,
|
|
66
|
-
onUpload,
|
|
67
|
-
multiple = true,
|
|
68
|
-
queryKey,
|
|
69
|
-
accept = 'image/png,image/jpeg,image/webp,image/gif',
|
|
70
|
-
title,
|
|
71
|
-
description,
|
|
72
|
-
confirmLabel,
|
|
73
|
-
onConfirmError,
|
|
74
|
-
}: MediaPickerSheetProps<T>) {
|
|
75
|
-
const { t } = useTranslation()
|
|
76
|
-
const tenantId = useTenantId()
|
|
77
|
-
|
|
78
|
-
const [input, setInput] = useState('')
|
|
79
|
-
const deferredInput = useDeferredValue(input)
|
|
80
|
-
const trimmedQuery = deferredInput.trim()
|
|
81
|
-
const [staged, setStaged] = useState<Map<string, T>>(new Map())
|
|
82
|
-
const [submitting, setSubmitting] = useState(false)
|
|
83
|
-
const [uploading, setUploading] = useState(false)
|
|
84
|
-
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
85
|
-
// Bumped whenever the sheet closes. An upload started in an earlier session
|
|
86
|
-
// must not confirm into a host that has already moved on — closing mid-upload
|
|
87
|
-
// would otherwise still set the field the merchant cancelled.
|
|
88
|
-
const sessionRef = useRef(0)
|
|
89
|
-
|
|
90
|
-
// The tenant id is part of the key: a library is one tenant's, and without
|
|
91
|
-
// it reopening the picker after a store or seller switch serves the previous
|
|
92
|
-
// tenant's files from cache — whose ids the new tenant's API would reject.
|
|
93
|
-
const { data, isFetching, refetch } = useQuery({
|
|
94
|
-
queryKey: [queryKey, 'media-picker', tenantId, trimmedQuery],
|
|
95
|
-
queryFn: () => search(trimmedQuery),
|
|
96
|
-
enabled: open,
|
|
97
|
-
staleTime: 30_000,
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
const results = data?.data ?? []
|
|
101
|
-
|
|
102
|
-
async function pick(option: T) {
|
|
103
|
-
if (!multiple) {
|
|
104
|
-
await submit([option])
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
setStaged((prev) => {
|
|
109
|
-
const next = new Map(prev)
|
|
110
|
-
if (next.has(option.id)) next.delete(option.id)
|
|
111
|
-
else next.set(option.id, option)
|
|
112
|
-
return next
|
|
113
|
-
})
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function submit(media: T[]) {
|
|
117
|
-
if (media.length === 0) return
|
|
118
|
-
setSubmitting(true)
|
|
119
|
-
try {
|
|
120
|
-
await onConfirm(media)
|
|
121
|
-
reset()
|
|
122
|
-
onOpenChange(false)
|
|
123
|
-
} catch (error) {
|
|
124
|
-
// The mutation shows its own error; keep the sheet open with the
|
|
125
|
-
// selection intact so the merchant can retry.
|
|
126
|
-
onConfirmError?.(error)
|
|
127
|
-
} finally {
|
|
128
|
-
setSubmitting(false)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async function handleUpload(files: FileList | null) {
|
|
133
|
-
if (!files?.length || !onUpload) return
|
|
134
|
-
|
|
135
|
-
const session = sessionRef.current
|
|
136
|
-
setUploading(true)
|
|
137
|
-
try {
|
|
138
|
-
const uploaded: T[] = []
|
|
139
|
-
for (const file of Array.from(files)) {
|
|
140
|
-
uploaded.push(await onUpload(file))
|
|
141
|
-
}
|
|
142
|
-
// The file is in the library either way — only the confirmation is
|
|
143
|
-
// abandoned, so nothing the merchant uploaded is lost.
|
|
144
|
-
if (session !== sessionRef.current) return
|
|
145
|
-
|
|
146
|
-
await refetch()
|
|
147
|
-
|
|
148
|
-
if (multiple) {
|
|
149
|
-
setStaged((prev) => {
|
|
150
|
-
const next = new Map(prev)
|
|
151
|
-
for (const media of uploaded) next.set(media.id, media)
|
|
152
|
-
return next
|
|
153
|
-
})
|
|
154
|
-
} else {
|
|
155
|
-
await submit(uploaded.slice(0, 1))
|
|
156
|
-
}
|
|
157
|
-
} catch (error) {
|
|
158
|
-
onConfirmError?.(error)
|
|
159
|
-
} finally {
|
|
160
|
-
setUploading(false)
|
|
161
|
-
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function reset() {
|
|
166
|
-
setStaged(new Map())
|
|
167
|
-
setInput('')
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function handleOpenChange(next: boolean) {
|
|
171
|
-
if (!next) {
|
|
172
|
-
sessionRef.current += 1
|
|
173
|
-
reset()
|
|
174
|
-
}
|
|
175
|
-
onOpenChange(next)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return (
|
|
179
|
-
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
180
|
-
<SheetContent className="w-full gap-0 p-0 sm:max-w-2xl">
|
|
181
|
-
<SheetHeader>
|
|
182
|
-
<SheetTitle>{title ?? t('admin.media_picker.title')}</SheetTitle>
|
|
183
|
-
<SheetDescription>{description ?? t('admin.media_picker.description')}</SheetDescription>
|
|
184
|
-
</SheetHeader>
|
|
185
|
-
|
|
186
|
-
<div className="flex items-center gap-2 border-b border-border-subtle p-4">
|
|
187
|
-
<SearchInput
|
|
188
|
-
value={input}
|
|
189
|
-
onValueChange={setInput}
|
|
190
|
-
// Enter has no action here; swallow it so it can't submit a host form.
|
|
191
|
-
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
|
192
|
-
placeholder={t('admin.media_picker.search_placeholder')}
|
|
193
|
-
clearLabel={t('admin.common.clear')}
|
|
194
|
-
className="flex-1"
|
|
195
|
-
/>
|
|
196
|
-
{onUpload && (
|
|
197
|
-
<>
|
|
198
|
-
<input
|
|
199
|
-
ref={fileInputRef}
|
|
200
|
-
type="file"
|
|
201
|
-
accept={accept}
|
|
202
|
-
multiple={multiple}
|
|
203
|
-
className="hidden"
|
|
204
|
-
onChange={(e) => handleUpload(e.target.files)}
|
|
205
|
-
/>
|
|
206
|
-
<Button
|
|
207
|
-
type="button"
|
|
208
|
-
variant="outline"
|
|
209
|
-
disabled={uploading}
|
|
210
|
-
onClick={() => fileInputRef.current?.click()}
|
|
211
|
-
>
|
|
212
|
-
{uploading ? (
|
|
213
|
-
<Loader2Icon className="size-4 animate-spin" />
|
|
214
|
-
) : (
|
|
215
|
-
<UploadIcon className="size-4" />
|
|
216
|
-
)}
|
|
217
|
-
<span>{t('admin.media_picker.upload')}</span>
|
|
218
|
-
</Button>
|
|
219
|
-
</>
|
|
220
|
-
)}
|
|
221
|
-
</div>
|
|
222
|
-
|
|
223
|
-
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
224
|
-
{isFetching && results.length === 0 ? (
|
|
225
|
-
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
226
|
-
<Loader2Icon className="size-5 animate-spin" />
|
|
227
|
-
</div>
|
|
228
|
-
) : results.length === 0 ? (
|
|
229
|
-
<p className="p-6 text-center text-muted-foreground text-sm">
|
|
230
|
-
{trimmedQuery ? t('admin.media_picker.no_results') : t('admin.media_picker.empty')}
|
|
231
|
-
</p>
|
|
232
|
-
) : (
|
|
233
|
-
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-4">
|
|
234
|
-
{results.map((media) => {
|
|
235
|
-
const checked = staged.has(media.id)
|
|
236
|
-
const thumbnail = media.small_url ?? media.poster_url ?? media.original_url
|
|
237
|
-
const label = media.alt || media.filename || ''
|
|
238
|
-
|
|
239
|
-
return (
|
|
240
|
-
<li key={media.id}>
|
|
241
|
-
<button
|
|
242
|
-
type="button"
|
|
243
|
-
onClick={() => pick(media)}
|
|
244
|
-
aria-pressed={checked}
|
|
245
|
-
className={`group relative block w-full overflow-hidden rounded-md border transition-colors ${
|
|
246
|
-
checked
|
|
247
|
-
? 'border-primary ring-2 ring-primary'
|
|
248
|
-
: 'border-border hover:border-muted-foreground'
|
|
249
|
-
}`}
|
|
250
|
-
>
|
|
251
|
-
<span className="flex aspect-square items-center justify-center bg-muted">
|
|
252
|
-
{thumbnail ? (
|
|
253
|
-
<img
|
|
254
|
-
src={thumbnail}
|
|
255
|
-
alt={label}
|
|
256
|
-
loading="lazy"
|
|
257
|
-
className="size-full object-cover"
|
|
258
|
-
/>
|
|
259
|
-
) : (
|
|
260
|
-
<ImageIcon className="size-6 text-muted-foreground" />
|
|
261
|
-
)}
|
|
262
|
-
</span>
|
|
263
|
-
|
|
264
|
-
{media.media_type !== 'image' && (
|
|
265
|
-
<span className="absolute top-1.5 left-1.5 rounded bg-black/60 p-1 text-white">
|
|
266
|
-
<PlayIcon className="size-3" />
|
|
267
|
-
</span>
|
|
268
|
-
)}
|
|
269
|
-
|
|
270
|
-
{checked && (
|
|
271
|
-
<span className="absolute top-1.5 right-1.5 rounded-full bg-primary p-1 text-primary-foreground">
|
|
272
|
-
<CheckIcon className="size-3" />
|
|
273
|
-
</span>
|
|
274
|
-
)}
|
|
275
|
-
|
|
276
|
-
{label && (
|
|
277
|
-
<span className="block truncate px-2 py-1.5 text-left text-xs">
|
|
278
|
-
{label}
|
|
279
|
-
</span>
|
|
280
|
-
)}
|
|
281
|
-
</button>
|
|
282
|
-
</li>
|
|
283
|
-
)
|
|
284
|
-
})}
|
|
285
|
-
</ul>
|
|
286
|
-
)}
|
|
287
|
-
</div>
|
|
288
|
-
|
|
289
|
-
{multiple && (
|
|
290
|
-
<SheetFooter className="flex-row items-center justify-between border-t border-border">
|
|
291
|
-
<span className="text-muted-foreground text-xs">
|
|
292
|
-
{t('admin.media_picker.selected_count', { count: staged.size })}
|
|
293
|
-
</span>
|
|
294
|
-
<Button
|
|
295
|
-
type="button"
|
|
296
|
-
disabled={staged.size === 0 || submitting}
|
|
297
|
-
onClick={() => submit(Array.from(staged.values()))}
|
|
298
|
-
>
|
|
299
|
-
{submitting && <Loader2Icon className="size-4 animate-spin" />}
|
|
300
|
-
<span>{confirmLabel ?? t('admin.media_picker.confirm')}</span>
|
|
301
|
-
</Button>
|
|
302
|
-
</SheetFooter>
|
|
303
|
-
)}
|
|
304
|
-
</SheetContent>
|
|
305
|
-
</Sheet>
|
|
306
|
-
)
|
|
16
|
+
export function MediaPickerSheet<T extends MediaPickerOption>(props: MediaPickerDialogProps<T>) {
|
|
17
|
+
// Off unless asked for: a sheet-era `search` ignores the sort and filter
|
|
18
|
+
// options, so the controls would change nothing.
|
|
19
|
+
return <MediaPickerDialog browseControls={false} {...props} />
|
|
307
20
|
}
|
|
@@ -59,7 +59,7 @@ export function MobileTopBar({
|
|
|
59
59
|
{isLoading ? (
|
|
60
60
|
<Skeleton className="h-4 w-28" />
|
|
61
61
|
) : (
|
|
62
|
-
<span className="min-w-0 flex-1 truncate font-medium text-
|
|
62
|
+
<span className="min-w-0 flex-1 truncate font-medium text-base">{label}</span>
|
|
63
63
|
)}
|
|
64
64
|
|
|
65
65
|
{palette && (
|
|
@@ -39,12 +39,23 @@ export type NavItem = {
|
|
|
39
39
|
}[]
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export function NavIcon({
|
|
42
|
+
export function NavIcon({
|
|
43
|
+
icon: Icon,
|
|
44
|
+
isActive,
|
|
45
|
+
muted,
|
|
46
|
+
}: {
|
|
47
|
+
icon: NavItem['icon']
|
|
48
|
+
isActive?: boolean
|
|
49
|
+
/** Rest the icon at muted text colour, so only the current page's icon is lit. */
|
|
50
|
+
muted?: boolean
|
|
51
|
+
}) {
|
|
43
52
|
return (
|
|
44
53
|
<span
|
|
45
54
|
className={
|
|
46
55
|
'inline-flex shrink-0 items-center justify-center rounded-lg p-[0.2rem] transition-colors duration-100 ' +
|
|
47
|
-
(isActive
|
|
56
|
+
(isActive
|
|
57
|
+
? 'text-foreground'
|
|
58
|
+
: `group-hover/menu-button:text-foreground${muted ? ' text-muted-foreground' : ''}`)
|
|
48
59
|
}
|
|
49
60
|
>
|
|
50
61
|
<Icon size={16} strokeWidth={2} />
|
|
@@ -21,7 +21,10 @@ import {
|
|
|
21
21
|
import type { JsonPreviewDrawerProps } from '@spree/dashboard-ui/spree/json-preview-drawer'
|
|
22
22
|
import { lazy, type ReactNode, Suspense, useState } from 'react'
|
|
23
23
|
import { useTranslation } from 'react-i18next'
|
|
24
|
+
import { getApiClient, hasApiClient } from '../api-client'
|
|
25
|
+
import { buildCurlCommand } from '../lib/curl'
|
|
24
26
|
import { docsUrl } from '../lib/docs'
|
|
27
|
+
import { useJsonLinkResolver } from '../providers/json-link-resolver-provider'
|
|
25
28
|
import { useRegisterPageHeader, useStickyHeaderOffset } from '../providers/sticky-header-provider'
|
|
26
29
|
import { Slot } from './slot'
|
|
27
30
|
|
|
@@ -130,8 +133,15 @@ interface PageHeaderProps {
|
|
|
130
133
|
/** Heading for the delete prompt. Defaults to none, as the message carries it. */
|
|
131
134
|
deleteConfirmTitle?: string
|
|
132
135
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
136
|
+
* The record's Admin API path, e.g. `/api/v3/admin/products/prod_xyz`.
|
|
137
|
+
* Together with `resource` it adds "View as JSON", showing `resource` as
|
|
138
|
+
* loaded — no second request.
|
|
139
|
+
*/
|
|
140
|
+
endpoint?: string
|
|
141
|
+
/**
|
|
142
|
+
* A custom "View as JSON" drawer, for a page whose payload differs from
|
|
143
|
+
* `resource` (Orders load a richer shape than the header shows). Wins over
|
|
144
|
+
* `endpoint`.
|
|
135
145
|
*/
|
|
136
146
|
jsonPreview?: PageHeaderJsonPreview
|
|
137
147
|
}
|
|
@@ -162,13 +172,13 @@ export function PageHeader({
|
|
|
162
172
|
deleteLabel,
|
|
163
173
|
deleteConfirmMessage,
|
|
164
174
|
deleteConfirmTitle,
|
|
175
|
+
endpoint,
|
|
165
176
|
jsonPreview,
|
|
166
177
|
}: PageHeaderProps) {
|
|
167
178
|
const { t } = useTranslation()
|
|
168
179
|
const slotCtx = { ...slotContext, resource }
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
)
|
|
180
|
+
const json = useJsonPreview({ title, resource, endpoint, jsonPreview })
|
|
181
|
+
const showDropdown = Boolean(resource || onDelete || dropdownItems || destructiveItems || json)
|
|
172
182
|
const [jsonOpen, setJsonOpen] = useState(false)
|
|
173
183
|
// Latches true on first open so the drawer (and its lazy JsonView chunk)
|
|
174
184
|
// doesn't mount until the user actually invokes it, but stays mounted
|
|
@@ -199,7 +209,7 @@ export function PageHeader({
|
|
|
199
209
|
// `bg-card` masks the content scrolling behind it — the same surface as
|
|
200
210
|
// the content sheet this sits in, so the pinned band is invisible at rest
|
|
201
211
|
// rather than a stripe of the page ground across the top of a white sheet;
|
|
202
|
-
// `-mx-4 px-4
|
|
212
|
+
// `-mx-4 px-4` and `-mt-4 pt-4` undo
|
|
203
213
|
// and re-apply the parent padding so the sticky band runs edge-to-edge.
|
|
204
214
|
//
|
|
205
215
|
// The `::after` pseudo-element is the bottom hairline — it fades in
|
|
@@ -218,7 +228,7 @@ export function PageHeader({
|
|
|
218
228
|
// Opaque for the same reason as the top bar: this band pins over the
|
|
219
229
|
// page's own scrolling content, so a translucent fill lets that content
|
|
220
230
|
// ghost through it.
|
|
221
|
-
'-mx-4 -mt-4 flex flex-row items-start gap-2 bg-card p-4 sm:gap-3
|
|
231
|
+
'-mx-4 -mt-4 flex flex-row items-start gap-2 bg-card p-4 sm:gap-3',
|
|
222
232
|
sticky && 'sticky top-0 z-20',
|
|
223
233
|
'transition-[box-shadow] duration-200 ease-out motion-reduce:transition-none',
|
|
224
234
|
'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)]',
|
|
@@ -237,7 +247,7 @@ export function PageHeader({
|
|
|
237
247
|
{/* Smaller and clipped on a phone: sharing the row with the actions
|
|
238
248
|
leaves a long product name too little width to wrap into
|
|
239
249
|
anything readable. */}
|
|
240
|
-
<h1 className="min-w-0 truncate font-semibold text-
|
|
250
|
+
<h1 className="min-w-0 truncate font-semibold text-lg sm:text-xl sm:whitespace-normal">
|
|
241
251
|
{title}
|
|
242
252
|
</h1>
|
|
243
253
|
{/* Statuses and the timestamp share the second line: both answer
|
|
@@ -248,14 +258,14 @@ export function PageHeader({
|
|
|
248
258
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
249
259
|
{badges}
|
|
250
260
|
{subtitle && (
|
|
251
|
-
<span className="line-clamp-2 text-
|
|
261
|
+
<span className="line-clamp-2 text-base text-muted-foreground sm:line-clamp-none">
|
|
252
262
|
{subtitle}
|
|
253
263
|
</span>
|
|
254
264
|
)}
|
|
255
265
|
</div>
|
|
256
266
|
)}
|
|
257
267
|
{(description || docsPath) && (
|
|
258
|
-
<p className="text-muted-foreground text-
|
|
268
|
+
<p className="text-muted-foreground text-base">
|
|
259
269
|
{description}
|
|
260
270
|
{docsPath && (
|
|
261
271
|
<>
|
|
@@ -285,20 +295,54 @@ export function PageHeader({
|
|
|
285
295
|
deleteLabel={deleteLabel ?? t('admin.actions.delete')}
|
|
286
296
|
deleteConfirmMessage={deleteConfirmMessage}
|
|
287
297
|
deleteConfirmTitle={deleteConfirmTitle}
|
|
288
|
-
onOpenJson={
|
|
298
|
+
onOpenJson={json ? openJson : undefined}
|
|
289
299
|
/>
|
|
290
300
|
)}
|
|
291
301
|
</div>
|
|
292
302
|
|
|
293
|
-
{
|
|
303
|
+
{json && jsonEverOpened && (
|
|
294
304
|
<Suspense fallback={null}>
|
|
295
|
-
<JsonPreviewDrawer
|
|
305
|
+
<JsonPreviewDrawer
|
|
306
|
+
open={jsonOpen}
|
|
307
|
+
onOpenChange={setJsonOpen}
|
|
308
|
+
{...json}
|
|
309
|
+
curl={json.endpoint ? curlFor(json.endpoint) : undefined}
|
|
310
|
+
/>
|
|
296
311
|
</Suspense>
|
|
297
312
|
)}
|
|
298
313
|
</header>
|
|
299
314
|
)
|
|
300
315
|
}
|
|
301
316
|
|
|
317
|
+
function useJsonPreview({
|
|
318
|
+
title,
|
|
319
|
+
resource,
|
|
320
|
+
endpoint,
|
|
321
|
+
jsonPreview,
|
|
322
|
+
}: Pick<PageHeaderProps, 'title' | 'resource' | 'endpoint' | 'jsonPreview'>) {
|
|
323
|
+
const contextResolveLink = useJsonLinkResolver()
|
|
324
|
+
const preview: PageHeaderJsonPreview | undefined =
|
|
325
|
+
jsonPreview ??
|
|
326
|
+
(resource && endpoint
|
|
327
|
+
? {
|
|
328
|
+
title: typeof title === 'string' ? title : undefined,
|
|
329
|
+
fetch: () => Promise.resolve(resource),
|
|
330
|
+
endpoint,
|
|
331
|
+
}
|
|
332
|
+
: undefined)
|
|
333
|
+
if (!preview) return undefined
|
|
334
|
+
|
|
335
|
+
return { ...preview, resolveLink: preview.resolveLink ?? contextResolveLink }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Built only once the drawer opens, not on every header render.
|
|
339
|
+
function curlFor(endpoint: string) {
|
|
340
|
+
return buildCurlCommand(
|
|
341
|
+
endpoint,
|
|
342
|
+
hasApiClient() ? (getApiClient().downloadHeaders?.() ?? {}) : {},
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
302
346
|
// ---------------------------------------------------------------------------
|
|
303
347
|
// More-actions dropdown
|
|
304
348
|
// ---------------------------------------------------------------------------
|
|
@@ -65,7 +65,7 @@ export function PageTabs({ tabs, slotName = 'page.tabs', slotContext, className
|
|
|
65
65
|
params={tab.params}
|
|
66
66
|
search={tab.search}
|
|
67
67
|
className={cn(
|
|
68
|
-
'inline-flex items-center px-3 py-2 -mb-px text-
|
|
68
|
+
'inline-flex items-center px-3 py-2 -mb-px text-base font-medium border-b-2 transition-colors',
|
|
69
69
|
active
|
|
70
70
|
? 'border-foreground text-foreground'
|
|
71
71
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cn } from '@spree/dashboard-ui'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One labelled value in a sidebar card: the label on the left, the value on
|
|
7
|
+
* the right, with a placeholder when unset. `stacked` puts a long free-text
|
|
8
|
+
* value (notes, a reason) under its label instead, where right-aligned
|
|
9
|
+
* paragraphs would be hard to read.
|
|
10
|
+
*/
|
|
11
|
+
export function ReadRow({
|
|
12
|
+
label,
|
|
13
|
+
children,
|
|
14
|
+
stacked = false,
|
|
15
|
+
}: {
|
|
16
|
+
label: string
|
|
17
|
+
children: ReactNode
|
|
18
|
+
stacked?: boolean
|
|
19
|
+
}) {
|
|
20
|
+
const { t } = useTranslation()
|
|
21
|
+
const value = children ?? (
|
|
22
|
+
<span className="text-muted-foreground">{t('admin.sellers.not_provided')}</span>
|
|
23
|
+
)
|
|
24
|
+
if (stacked) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-col gap-1 text-base">
|
|
27
|
+
<span className="text-muted-foreground">{label}</span>
|
|
28
|
+
<span className="whitespace-pre-line">{value}</span>
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
<div className={cn('flex items-start justify-between gap-3 text-base')}>
|
|
34
|
+
<span className="shrink-0 text-muted-foreground">{label}</span>
|
|
35
|
+
<span className="min-w-0 text-right">{value}</span>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -162,7 +162,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
162
162
|
// the dropdown unmounts, so a click never lands.
|
|
163
163
|
<button
|
|
164
164
|
type="button"
|
|
165
|
-
className="flex w-full cursor-pointer items-center gap-2 border-t border-border px-2.5 py-2 text-left text-
|
|
165
|
+
className="flex w-full cursor-pointer items-center gap-2 border-t border-border px-2.5 py-2 text-left text-base hover:bg-accent"
|
|
166
166
|
onMouseDown={(event) => {
|
|
167
167
|
event.preventDefault()
|
|
168
168
|
action.onSelect()
|