@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.
Files changed (86) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +19 -19
  3. package/src/api-client.ts +7 -1
  4. package/src/client.ts +2 -0
  5. package/src/components/address-form-dialog.tsx +75 -28
  6. package/src/components/app-shell.tsx +5 -4
  7. package/src/components/app-sidebar.tsx +2 -1
  8. package/src/components/bulk-action-bar.tsx +6 -5
  9. package/src/components/country-state-fields.tsx +3 -0
  10. package/src/components/currency-select.tsx +6 -4
  11. package/src/components/developer-inspector-drawer.tsx +322 -0
  12. package/src/components/file-upload-field.tsx +64 -37
  13. package/src/components/image-upload-field.tsx +4 -0
  14. package/src/components/import-wizard-dialog.tsx +14 -12
  15. package/src/components/invitation-acceptance.tsx +15 -11
  16. package/src/components/media-editor-dialog.tsx +131 -0
  17. package/src/components/media-picker-dialog.tsx +866 -0
  18. package/src/components/media-picker-sheet.tsx +13 -300
  19. package/src/components/mobile-top-bar.tsx +1 -1
  20. package/src/components/nav-main.tsx +13 -2
  21. package/src/components/page-header.tsx +57 -13
  22. package/src/components/page-tabs.tsx +1 -1
  23. package/src/components/product-stock-summary.tsx +1 -1
  24. package/src/components/read-row.tsx +38 -0
  25. package/src/components/resource-combobox.tsx +1 -1
  26. package/src/components/resource-image-field.tsx +150 -0
  27. package/src/components/resource-picker-sheet.tsx +2 -2
  28. package/src/components/resource-table.tsx +161 -40
  29. package/src/components/section-heading.tsx +2 -2
  30. package/src/components/settings-container.tsx +19 -0
  31. package/src/components/settings-fields.tsx +165 -0
  32. package/src/components/settings-section.tsx +46 -0
  33. package/src/components/settings-sidebar.tsx +35 -18
  34. package/src/components/sheet-top-bar.tsx +7 -8
  35. package/src/components/sidebar-search.tsx +11 -12
  36. package/src/components/sidebar-user.tsx +39 -44
  37. package/src/components/skip-link.tsx +1 -1
  38. package/src/components/store-switcher.tsx +1 -1
  39. package/src/components/table-toolbar.tsx +189 -323
  40. package/src/components/timezone-combobox.tsx +119 -0
  41. package/src/components/top-bar-breadcrumbs.tsx +1 -1
  42. package/src/hooks/use-stock-locations.ts +6 -2
  43. package/src/hooks/use-table-density.ts +50 -0
  44. package/src/index.ts +19 -2
  45. package/src/lib/curl.test.ts +18 -0
  46. package/src/lib/curl.ts +28 -0
  47. package/src/lib/developer-inspector.test.ts +169 -0
  48. package/src/lib/developer-inspector.ts +134 -0
  49. package/src/lib/docs.ts +3 -2
  50. package/src/lib/fulfillment-items.test.ts +51 -0
  51. package/src/lib/fulfillment-items.ts +53 -0
  52. package/src/lib/request-snippets.ts +41 -0
  53. package/src/lib/table-registry.ts +13 -6
  54. package/src/lib/timestamp-columns.tsx +1 -1
  55. package/src/locales/ar.json +120 -14
  56. package/src/locales/de.json +120 -14
  57. package/src/locales/en.json +120 -14
  58. package/src/locales/es.json +120 -14
  59. package/src/locales/fr.json +120 -14
  60. package/src/locales/ja.json +120 -14
  61. package/src/locales/pl.json +120 -14
  62. package/src/locales/vi.json +120 -14
  63. package/src/locales/zh-CN.json +120 -14
  64. package/src/pages/settings-index.tsx +79 -48
  65. package/src/pages/stock-locations.tsx +14 -6
  66. package/src/products/digital-asset-edit-sheet.tsx +1 -1
  67. package/src/products/digital-asset-provider-sheet.tsx +1 -1
  68. package/src/products/digital-assets-card.tsx +7 -7
  69. package/src/products/inventory-section.tsx +2 -2
  70. package/src/products/media-edit-sheet.tsx +136 -133
  71. package/src/products/product-form-cards.tsx +201 -70
  72. package/src/products/product-form-mapping.ts +1 -0
  73. package/src/products/product-schema.ts +8 -0
  74. package/src/products/product-types.ts +2 -0
  75. package/src/products/use-product-form-data.ts +30 -3
  76. package/src/products/variant-edit-sheet.tsx +3 -3
  77. package/src/products/variants-options-builder.tsx +13 -8
  78. package/src/products/variants-section.tsx +196 -4
  79. package/src/providers/auth-provider.tsx +2 -0
  80. package/src/providers/developer-inspector-provider.tsx +145 -0
  81. package/src/providers/json-link-resolver-provider.tsx +27 -0
  82. package/src/sellers/seller-schema.ts +107 -0
  83. package/src/tables/package-types.tsx +3 -3
  84. package/src/tables/stock-locations.tsx +15 -14
  85. package/src/virtual.d.ts +4 -0
  86. package/src/vite/index.ts +103 -5
@@ -1,307 +1,20 @@
1
1
  import {
2
- Button,
3
- SearchInput,
4
- Sheet,
5
- SheetContent,
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
- /** The shape the picker needs; a full Media record satisfies it. */
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
- export interface MediaPickerSheetProps<T extends MediaPickerOption> {
31
- open: boolean
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
- * Browse the store's media library and pick files from it.
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
- open,
63
- onOpenChange,
64
- search,
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-sm">{label}</span>
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({ icon: Icon, isActive }: { icon: NavItem['icon']; isActive?: boolean }) {
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 ? 'text-foreground' : 'group-hover/menu-button:text-foreground')
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
- * When supplied, the more-actions dropdown gains a "View as JSON" item that
134
- * opens a developer-style drawer with the resource payload.
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 showDropdown = Boolean(
170
- resource || onDelete || dropdownItems || destructiveItems || jsonPreview,
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 lg:-mx-6 lg:px-6` and `-mt-4 lg:-mt-6 pt-4 lg:pt-6` undo
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 lg:-mx-6 lg:-mt-6 lg:px-6 lg:pt-6',
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-xl leading-tight sm:text-2xl sm:whitespace-normal">
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-sm text-muted-foreground sm:line-clamp-none">
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-sm">
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={jsonPreview ? openJson : undefined}
298
+ onOpenJson={json ? openJson : undefined}
289
299
  />
290
300
  )}
291
301
  </div>
292
302
 
293
- {jsonPreview && jsonEverOpened && (
303
+ {json && jsonEverOpened && (
294
304
  <Suspense fallback={null}>
295
- <JsonPreviewDrawer open={jsonOpen} onOpenChange={setJsonOpen} {...jsonPreview} />
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-sm font-medium border-b-2 transition-colors',
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',
@@ -45,7 +45,7 @@ export function ProductStockSummary({ product }: { product: ProductStockFields }
45
45
  )
46
46
 
47
47
  return (
48
- <span className="text-sm text-muted-foreground">
48
+ <span className="text-base text-muted-foreground">
49
49
  {status}
50
50
  {product.variant_count > 1 && (
51
51
  <>
@@ -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-sm hover:bg-accent"
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()