@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,9 +1,10 @@
1
- import { Card, SearchInput } from '@spree/dashboard-ui'
2
- import { PackageIcon } from '@spree/dashboard-ui/icons'
3
- import { Link } from '@tanstack/react-router'
1
+ import { Card, cn, SearchInput } from '@spree/dashboard-ui'
2
+ import { ChevronRightIcon, PackageIcon } from '@spree/dashboard-ui/icons'
3
+ import { Link, Navigate } from '@tanstack/react-router'
4
4
  import { useMemo, useState } from 'react'
5
5
  import { useTranslation } from 'react-i18next'
6
6
  import { PageHeader } from '../components/page-header'
7
+ import { useSettingsRailShown } from '../components/settings-sidebar'
7
8
  import { resolveNavDescription, resolveNavLabel } from '../lib/nav-registry'
8
9
  import {
9
10
  filterSettingsByPermissions,
@@ -14,21 +15,25 @@ import {
14
15
  import { usePermissions } from '../providers/permission-provider'
15
16
 
16
17
  /**
17
- * Settings landing page — a card grid of every settings area the current
18
- * member can reach, grouped the same way the sidebar groups them.
18
+ * Settings landing page — every settings area the current member can reach,
19
+ * one card of rows per group, grouped the same way the sidebar groups them.
20
+ *
21
+ * Phones and tablets only. From `lg` the settings rail lists the same entries
22
+ * beside the page, so this page would only repeat it; there it hands over to
23
+ * the first section the member can open instead.
19
24
  *
20
25
  * Shared by the operator dashboard and the marketplace seller panel. Each
21
26
  * host passes its tenant id so the cards link under that panel's prefix
22
27
  * (`/$storeId/settings/…` or `/$sellerId/settings/…`).
23
28
  *
24
29
  * It doubles as the settings navigation below `lg`, where `SettingsSidebar` is
25
- * hidden: on a narrow viewport this grid is the only way into the area, so it
26
- * must never be replaced by a redirect to a specific page.
30
+ * hidden: on a narrow viewport this list is the only way into the area, so the
31
+ * redirect must stay bound to the rail being on screen.
27
32
  */
28
33
  export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
29
34
  const { t } = useTranslation()
30
35
  const snapshot = useSettingsNav()
31
- const { permissions } = usePermissions()
36
+ const { permissions, isLoading: permissionsLoading } = usePermissions()
32
37
  const [query, setQuery] = useState('')
33
38
 
34
39
  // Permission filtering depends only on the snapshot, so it survives typing.
@@ -38,42 +43,52 @@ export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
38
43
  )
39
44
  const groups = useMemo(() => filterSettingsByQuery(allowed, query, t).groups, [allowed, query, t])
40
45
 
46
+ const railShown = useSettingsRailShown()
47
+ const first = railShown
48
+ ? allowed.groups.flatMap((g) => g.entries).find((entry) => !entry.comingSoon)
49
+ : undefined
50
+ if (first) return <Navigate to={`/${tenantId}/settings${first.path}` as never} replace />
51
+ // Nothing to open yet while permissions load; render nothing rather than
52
+ // flash the list the rail already shows. Loaded with nothing to open, the
53
+ // list below says so rather than leaving the page blank.
54
+ if (railShown && permissionsLoading) return null
55
+
41
56
  return (
42
- <div className="flex flex-col gap-6">
57
+ <div className="flex flex-col gap-8">
43
58
  <PageHeader
44
59
  title={t('admin.settings_page.title')}
45
60
  subtitle={t('admin.settings_page.description')}
46
61
  />
47
62
 
48
- {/* Hidden from `lg`, where the settings sidebar is on screen listing the
49
- same entries and carrying the same search. Below that the sidebar is
50
- gone, so this is the only way to filter — and the only navigation. */}
51
- <div className="lg:hidden">
52
- <SearchInput
53
- value={query}
54
- onValueChange={setQuery}
55
- placeholder={t('admin.settings_page.search_placeholder')}
56
- aria-label={t('admin.settings_page.search_placeholder')}
57
- clearLabel={t('admin.common.clear')}
58
- className="h-11 max-w-md"
59
- />
60
- </div>
63
+ <SearchInput
64
+ value={query}
65
+ onValueChange={setQuery}
66
+ placeholder={t('admin.settings_page.search_placeholder')}
67
+ aria-label={t('admin.settings_page.search_placeholder')}
68
+ clearLabel={t('admin.common.clear')}
69
+ className="h-11 max-w-md"
70
+ />
61
71
 
62
72
  {groups.length === 0 ? (
63
- <p className="text-sm text-muted-foreground">
73
+ <p className="text-base text-muted-foreground">
64
74
  {t('admin.settings_page.no_results', { query: query.trim() })}
65
75
  </p>
66
76
  ) : (
67
77
  groups.map(({ group, entries }) => (
68
78
  <section key={group.key} className="flex flex-col gap-3">
69
- <h2 className="text-sm font-medium text-muted-foreground">
70
- {resolveNavLabel(group, t)}
71
- </h2>
72
- <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
73
- {entries.map((entry) => (
74
- <SettingsCard key={entry.key} entry={entry} tenantId={tenantId} />
75
- ))}
76
- </div>
79
+ <h2 className="font-semibold text-base">{resolveNavLabel(group, t)}</h2>
80
+ <Card className="gap-0 overflow-hidden">
81
+ <ul>
82
+ {entries.map((entry, index) => (
83
+ <SettingsIndexRow
84
+ key={entry.key}
85
+ entry={entry}
86
+ tenantId={tenantId}
87
+ divided={index > 0}
88
+ />
89
+ ))}
90
+ </ul>
91
+ </Card>
77
92
  </section>
78
93
  ))
79
94
  )}
@@ -81,35 +96,51 @@ export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
81
96
  )
82
97
  }
83
98
 
84
- function SettingsCard({ entry, tenantId }: { entry: SettingsNavEntry; tenantId: string }) {
99
+ function SettingsIndexRow({
100
+ entry,
101
+ tenantId,
102
+ divided,
103
+ }: {
104
+ entry: SettingsNavEntry
105
+ tenantId: string
106
+ /** Draws the rule above the row's text, which starts past the icon. */
107
+ divided: boolean
108
+ }) {
85
109
  const { t } = useTranslation()
86
110
  const Icon = entry.icon ?? PackageIcon
87
111
  const label = resolveNavLabel(entry, t)
88
112
  const description = resolveNavDescription(entry, t)
89
113
 
90
114
  return (
91
- <Card className="p-0 transition-colors border-border-subtle hover:bg-accent/80 hover:border-border">
92
- {/* The whole card is the target — a small title-only link would fail the
93
- 44px minimum on touch and leaves most of the card dead to a click. */}
94
- {/* Paths come from a runtime registry plugins extend, so they can't be
115
+ <li>
116
+ {/* The whole row is the target, so it clears the 44px touch minimum.
117
+ Paths come from a runtime registry plugins extend, so they can't be
95
118
  checked against the generated route tree. */}
96
119
  <Link
97
120
  to={`/${tenantId}/settings${entry.path}` as never}
98
- // The whole card is the target, so without a replacement for the
99
- // suppressed outline a keyboard user tabbing the grid cannot tell which
100
- // card they are on. A solid ring rather than the border pair buttons
101
- // use: this link draws no border of its own to recolour.
102
- className="flex h-full items-start gap-3 rounded-xl p-4 outline-none focus-visible:shadow-[0_0_0_2px_var(--ring)]"
121
+ // The row draws no border of its own, so focus gets an inset ring in
122
+ // place of the suppressed outline.
123
+ className="group flex items-center gap-3.5 ps-4 outline-none transition-colors hover:bg-accent focus-visible:shadow-[inset_0_0_0_2px_var(--ring)]"
103
124
  >
104
- <Icon className="mt-0.5 size-6 shrink-0 text-muted-foreground opacity-80 hover:opacity-100" />
105
- <span className="flex min-w-0 flex-col gap-1">
106
- <span className="flex items-center gap-1.5 text-sm font-medium leading-none">
107
- {label}
108
- {entry.badge && <entry.badge />}
125
+ <Icon className="size-5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
126
+ <span
127
+ className={cn(
128
+ 'flex min-w-0 flex-1 items-center gap-3 py-3.5 pe-4',
129
+ divided && 'border-border-subtle border-t',
130
+ )}
131
+ >
132
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
133
+ <span className="flex items-center gap-1.5 text-base font-medium">
134
+ {label}
135
+ {entry.badge && <entry.badge />}
136
+ </span>
137
+ {description && (
138
+ <span className="text-base text-muted-foreground md:truncate">{description}</span>
139
+ )}
109
140
  </span>
110
- {description && <span className="text-sm text-muted-foreground">{description}</span>}
141
+ <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground rtl:rotate-180" />
111
142
  </span>
112
143
  </Link>
113
- </Card>
144
+ </li>
114
145
  )
115
146
  }
@@ -34,7 +34,7 @@ import { Can } from '../components/can'
34
34
  import { CountryCombobox } from '../components/country-combobox'
35
35
  import { StateCombobox, useCountryStates } from '../components/country-state-fields'
36
36
  import { RecordMetadataSection } from '../components/record-metadata'
37
- import { ResourceTable, resourceSearchSchema } from '../components/resource-table'
37
+ import { ResourceTable, resourceSearchSchema, type TableView } from '../components/resource-table'
38
38
  import { HostFormSlot } from '../components/slot'
39
39
  import {
40
40
  canDeleteStockLocations,
@@ -94,8 +94,11 @@ export function StockLocationsPage({
94
94
  stockLevelsPanel: StockLevelsPanel,
95
95
  activityPanel: ActivityPanel,
96
96
  formKey = 'stock_location',
97
+ views,
97
98
  }: {
98
99
  search: StockLocationsSearch
100
+ /** Tabs splitting the list — the operator's own locations and their sellers'. */
101
+ views?: TableView[]
99
102
  /** Names the sheets' slot and extension fields — `seller.stock_location` in the seller panel. */
100
103
  formKey?: string
101
104
  /** Rendered inside the edit sheet, below the form. Optional — see above. */
@@ -149,6 +152,7 @@ export function StockLocationsPage({
149
152
  queryKey="stock-locations"
150
153
  queryFn={(params) => listStockLocations(params)}
151
154
  searchParams={search}
155
+ views={views}
152
156
  rowActions={(location) => (
153
157
  <RowActions
154
158
  actions={[
@@ -273,16 +277,20 @@ function CreateStockLocationSheet({
273
277
  // Edit Sheet
274
278
  // ============================================================================
275
279
 
276
- function EditStockLocationSheet({
280
+ /**
281
+ * One location's editor. Exported so a record page that lists locations (a
282
+ * seller's) opens it in place rather than sending the operator to settings.
283
+ */
284
+ export function EditStockLocationSheet({
277
285
  id,
278
- formKey,
286
+ formKey = 'stock_location',
279
287
  open,
280
288
  onOpenChange,
281
289
  stockLevelsPanel: StockLevelsPanel,
282
290
  activityPanel: ActivityPanel,
283
291
  }: {
284
292
  id: string
285
- formKey: string
293
+ formKey?: string
286
294
  open: boolean
287
295
  onOpenChange: (open: boolean) => void
288
296
  stockLevelsPanel?: ComponentType<{ stockLocationId: string }>
@@ -330,7 +338,7 @@ function EditStockLocationSheet({
330
338
  <SheetDescription>{t('admin.stock_locations.edit_description')}</SheetDescription>
331
339
  </SheetHeader>
332
340
  {isLoading ? (
333
- <div className="p-4 text-sm text-muted-foreground">{t('admin.common.loading')}</div>
341
+ <div className="p-4 text-base text-muted-foreground">{t('admin.common.loading')}</div>
334
342
  ) : (
335
343
  <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
336
344
  <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
@@ -384,7 +392,7 @@ function StockLocationFormFields({ form }: { form: UseFormReturn<StockLocationFo
384
392
  // to as well as its own.
385
393
  <div className="flex flex-col gap-6">
386
394
  {errors.root?.message && (
387
- <p className="text-sm text-destructive" role="alert">
395
+ <p className="text-base text-destructive" role="alert">
388
396
  {errors.root.message}
389
397
  </p>
390
398
  )}
@@ -175,7 +175,7 @@ export function DigitalAssetEditSheet({
175
175
 
176
176
  <p className="text-muted-foreground text-xs">{t('admin.digital_assets.limits_help')}</p>
177
177
 
178
- {error && <p className="text-destructive text-sm">{error}</p>}
178
+ {error && <p className="text-destructive text-base">{error}</p>}
179
179
  </div>
180
180
 
181
181
  <SheetFooter>
@@ -71,7 +71,7 @@ export function DigitalAssetProviderSheet({ productId, provider, onOpenChange, o
71
71
  onChange={setValues}
72
72
  />
73
73
 
74
- {error && <p className="text-destructive text-sm">{error}</p>}
74
+ {error && <p className="text-destructive text-base">{error}</p>}
75
75
  </div>
76
76
 
77
77
  <SheetFooter>
@@ -31,7 +31,7 @@ import {
31
31
  useConfirm,
32
32
  } from '@spree/dashboard-ui'
33
33
  import {
34
- ChevronDownIcon,
34
+ ChevronsUpDownIcon,
35
35
  DownloadIcon,
36
36
  FileDownIcon,
37
37
  FileIcon,
@@ -274,7 +274,7 @@ export function DigitalAssetsCard({
274
274
  {uploading
275
275
  ? t('admin.digital_assets.uploading_short')
276
276
  : t('admin.digital_assets.add')}
277
- <ChevronDownIcon className="ml-2 size-4" />
277
+ <ChevronsUpDownIcon className="ml-2 size-4" />
278
278
  </Button>
279
279
  </DropdownMenuTrigger>
280
280
  <DropdownMenuContent align="end">
@@ -298,8 +298,8 @@ export function DigitalAssetsCard({
298
298
  </CardHeader>
299
299
 
300
300
  <CardContent className="flex flex-col gap-4">
301
- <p className="text-muted-foreground text-sm">{t('admin.digital_assets.description')}</p>
302
- {notice && canWrite && <p className="text-sm">{notice}</p>}
301
+ <p className="text-muted-foreground text-base">{t('admin.digital_assets.description')}</p>
302
+ {notice && canWrite && <p className="text-base">{notice}</p>}
303
303
 
304
304
  <input
305
305
  ref={fileInputRef}
@@ -320,7 +320,7 @@ export function DigitalAssetsCard({
320
320
  {isLoading ? (
321
321
  <Skeleton className="h-24 w-full" />
322
322
  ) : !hasAssets && !canWrite ? (
323
- <p className="text-muted-foreground text-sm">{t('admin.digital_assets.empty')}</p>
323
+ <p className="text-muted-foreground text-base">{t('admin.digital_assets.empty')}</p>
324
324
  ) : !hasAssets ? (
325
325
  <button
326
326
  type="button"
@@ -331,7 +331,7 @@ export function DigitalAssetsCard({
331
331
  className="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border p-6 text-center transition-colors hover:border-foreground/30 disabled:pointer-events-none disabled:opacity-60"
332
332
  >
333
333
  <FileIcon className="size-8 text-muted-foreground" />
334
- <p className="text-muted-foreground text-sm">
334
+ <p className="text-muted-foreground text-base">
335
335
  {uploading
336
336
  ? t('admin.digital_assets.uploading_short')
337
337
  : t('admin.digital_assets.drop_hint')}
@@ -339,7 +339,7 @@ export function DigitalAssetsCard({
339
339
  </button>
340
340
  ) : (
341
341
  <div className="overflow-hidden rounded-xl border border-border-subtle">
342
- <Table>
342
+ <Table roundedTop>
343
343
  <TableHeader>
344
344
  <TableRow>
345
345
  <TableHead>{t('admin.digital_assets.columns.file')}</TableHead>
@@ -227,10 +227,10 @@ export function InventorySection({ form, stockLocationHref }: InventorySectionPr
227
227
  // Showing "no stock locations" while the query is still in flight is
228
228
  // misleading — the locations may exist but haven't loaded yet.
229
229
  if (stockLocationsLoading) {
230
- return <p className="text-sm text-muted-foreground">{t('admin.common.loading')}</p>
230
+ return <p className="text-base text-muted-foreground">{t('admin.common.loading')}</p>
231
231
  }
232
232
  return (
233
- <p className="text-sm text-muted-foreground">
233
+ <p className="text-base text-muted-foreground">
234
234
  {stockLocations.length === 0
235
235
  ? t('admin.products.inventory.empty_no_locations')
236
236
  : t('admin.products.inventory.empty_no_variants')}
@@ -2,24 +2,14 @@ import type { ProductFormValues } from '@spree/dashboard-core'
2
2
  import {
3
3
  extensionFieldNames,
4
4
  ImageUploadField,
5
+ MediaEditorDialog,
6
+ MediaEditorSection,
5
7
  parseVideoUrl,
6
8
  Slot,
7
9
  useTranslation,
8
10
  } from '@spree/dashboard-core'
9
- import {
10
- Button,
11
- Field,
12
- FieldError,
13
- FieldLabel,
14
- Input,
15
- MediaPreview,
16
- Sheet,
17
- SheetContent,
18
- SheetFooter,
19
- SheetHeader,
20
- SheetTitle,
21
- } from '@spree/dashboard-ui'
22
- import { CheckIcon } from '@spree/dashboard-ui/icons'
11
+ import { Button, Field, FieldError, FieldLabel, Input, MediaPreview } from '@spree/dashboard-ui'
12
+ import { CheckIcon, InfoIcon, LayersIcon } from '@spree/dashboard-ui/icons'
23
13
  import { useCallback, useEffect, useRef } from 'react'
24
14
  import type { UseFormReturn } from 'react-hook-form'
25
15
  import { variantLabel } from './assignable-variants'
@@ -51,7 +41,7 @@ interface Props {
51
41
  productId?: string
52
42
  }
53
43
 
54
- // Form-backed media editor. The sheet edits `form.media[index]` directly —
44
+ // Form-backed media editor, full screen. It edits `form.media[index]` directly —
55
45
  // changes ride the parent product PATCH/POST, no per-asset API calls.
56
46
  // Cancel restores a snapshot taken on open; Done runs the slot widgets' own
57
47
  // saves, then closes (parent form Save commits). Same model as the per-variant
@@ -250,121 +240,12 @@ export function MediaEditSheet({
250
240
  }
251
241
 
252
242
  return (
253
- <Sheet open={open} onOpenChange={(o) => (o ? onOpenChange(o) : handleCancel())}>
254
- <SheetContent side="right" showCloseButton={false} className="flex flex-col">
255
- <SheetHeader>
256
- <SheetTitle>{t('admin.products.media.edit_title')}</SheetTitle>
257
- </SheetHeader>
258
-
259
- <div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5">
260
- <MediaPreview
261
- mediaType={entry.media_type}
262
- previewUrl={previewUrl}
263
- videoUrl={videoUrl}
264
- embedUrl={parsedVideo?.embedUrl}
265
- alt={alt}
266
- focalPoint={focalPoint}
267
- onFocalPointClick={handleFocalPointClick}
268
- />
269
-
270
- {isImage && previewUrl && (
271
- <div className="flex items-center justify-between gap-2 -mt-3">
272
- <p className="text-xs text-muted-foreground">
273
- {t('admin.products.media.focal_point_help')}
274
- </p>
275
- {focalPoint && (
276
- <Button type="button" variant="ghost" size="sm" onClick={clearFocalPoint}>
277
- {t('admin.products.media.focal_point_reset')}
278
- </Button>
279
- )}
280
- </div>
281
- )}
282
-
283
- {isExternalVideo && (
284
- <Field>
285
- <FieldLabel htmlFor="media-video-url">
286
- {t('admin.fields.media.external_video_url.label')}
287
- </FieldLabel>
288
- <Input
289
- id="media-video-url"
290
- value={externalVideoUrl}
291
- aria-invalid={videoUrlInvalid || undefined}
292
- onChange={(e) => setExternalVideoUrl(e.target.value)}
293
- placeholder={t('admin.fields.media.external_video_url.placeholder')}
294
- />
295
- {videoUrlInvalid && (
296
- <FieldError>{t('admin.products.media.video_url_invalid')}</FieldError>
297
- )}
298
- </Field>
299
- )}
300
-
301
- <Field>
302
- <FieldLabel htmlFor="media-alt">{t('admin.fields.media.alt.label')}</FieldLabel>
303
- <Input
304
- id="media-alt"
305
- value={alt}
306
- onChange={(e) => setAlt(e.target.value)}
307
- placeholder={t('admin.fields.media.alt.placeholder')}
308
- />
309
- </Field>
310
-
311
- {isVideo && (
312
- <Field>
313
- <FieldLabel>{t('admin.fields.media.poster.label')}</FieldLabel>
314
- <ImageUploadField
315
- value={posterValue}
316
- onChange={setPoster}
317
- help={t('admin.fields.media.poster.help')}
318
- />
319
- </Field>
320
- )}
321
-
322
- {variants.length > 0 && (
323
- <Field>
324
- <FieldLabel>{t('admin.products.media.assigned_variants_label')}</FieldLabel>
325
- <p className="text-xs text-muted-foreground">
326
- {t('admin.products.media.assigned_variants_help')}
327
- </p>
328
- <div className="flex flex-wrap gap-2 pt-2">
329
- {variants.map((v) => {
330
- const id = v.id
331
- if (!id) return null
332
- const selected = selectedVariantIds.has(id)
333
- const label = variantLabel(v)
334
- return (
335
- <button
336
- key={id}
337
- type="button"
338
- onClick={() => toggleVariant(id)}
339
- aria-pressed={selected}
340
- className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
341
- selected
342
- ? 'border-primary bg-primary text-primary-foreground'
343
- : 'border-border bg-background text-muted-foreground hover:border-muted-foreground hover:text-foreground'
344
- }`}
345
- >
346
- {selected && <CheckIcon className="size-3" />}
347
- {label}
348
- </button>
349
- )
350
- })}
351
- </div>
352
- </Field>
353
- )}
354
-
355
- <Slot
356
- name={`${formKey}.media.form_fields`}
357
- context={{
358
- productId,
359
- media: entry,
360
- mediaIndex,
361
- fieldPrefix: `media.${mediaIndex}`,
362
- onSave,
363
- }}
364
- />
365
- </div>
366
-
367
- <SheetFooter>
243
+ <MediaEditorDialog
244
+ open={open}
245
+ onOpenChange={(o) => (o ? onOpenChange(o) : handleCancel())}
246
+ title={t('admin.products.media.edit_title')}
247
+ actions={
248
+ <>
368
249
  <Button type="button" variant="outline" disabled={saving} onClick={handleCancel}>
369
250
  {t('admin.actions.cancel')}
370
251
  </Button>
@@ -377,8 +258,130 @@ export function MediaEditSheet({
377
258
  >
378
259
  {t('admin.actions.done')}
379
260
  </Button>
380
- </SheetFooter>
381
- </SheetContent>
382
- </Sheet>
261
+ </>
262
+ }
263
+ preview={
264
+ <MediaPreview
265
+ fit="stage"
266
+ mediaType={entry.media_type}
267
+ previewUrl={previewUrl}
268
+ videoUrl={videoUrl}
269
+ embedUrl={parsedVideo?.embedUrl}
270
+ alt={alt}
271
+ focalPoint={focalPoint}
272
+ onFocalPointClick={handleFocalPointClick}
273
+ />
274
+ }
275
+ hint={
276
+ isImage && previewUrl ? (
277
+ <>
278
+ <span
279
+ aria-hidden
280
+ className={`size-3 shrink-0 rounded-full border-2 border-white ${focalPoint ? 'bg-blue-500' : ''}`}
281
+ />
282
+ <span>{t('admin.products.media.focal_point_help')}</span>
283
+ {focalPoint && (
284
+ <Button
285
+ type="button"
286
+ variant="ghost"
287
+ size="xs"
288
+ className="-me-1.5"
289
+ onClick={clearFocalPoint}
290
+ >
291
+ {t('admin.products.media.focal_point_reset')}
292
+ </Button>
293
+ )}
294
+ </>
295
+ ) : undefined
296
+ }
297
+ >
298
+ <MediaEditorSection icon={<InfoIcon />} title={t('admin.media_editor.information')}>
299
+ {isExternalVideo && (
300
+ <Field>
301
+ <FieldLabel htmlFor="media-video-url">
302
+ {t('admin.fields.media.external_video_url.label')}
303
+ </FieldLabel>
304
+ <Input
305
+ id="media-video-url"
306
+ value={externalVideoUrl}
307
+ aria-invalid={videoUrlInvalid || undefined}
308
+ onChange={(e) => setExternalVideoUrl(e.target.value)}
309
+ placeholder={t('admin.fields.media.external_video_url.placeholder')}
310
+ />
311
+ {videoUrlInvalid && (
312
+ <FieldError>{t('admin.products.media.video_url_invalid')}</FieldError>
313
+ )}
314
+ </Field>
315
+ )}
316
+
317
+ <Field>
318
+ <FieldLabel htmlFor="media-alt">{t('admin.fields.media.alt.label')}</FieldLabel>
319
+ <Input
320
+ id="media-alt"
321
+ value={alt}
322
+ onChange={(e) => setAlt(e.target.value)}
323
+ placeholder={t('admin.fields.media.alt.placeholder')}
324
+ />
325
+ </Field>
326
+
327
+ {isVideo && (
328
+ <Field>
329
+ <FieldLabel>{t('admin.fields.media.poster.label')}</FieldLabel>
330
+ <ImageUploadField
331
+ value={posterValue}
332
+ onChange={setPoster}
333
+ help={t('admin.fields.media.poster.help')}
334
+ />
335
+ </Field>
336
+ )}
337
+
338
+ {/* Plugin fields join the file's own, as on the media library's editor. */}
339
+ <Slot
340
+ name={`${formKey}.media.form_fields`}
341
+ context={{
342
+ productId,
343
+ media: entry,
344
+ mediaIndex,
345
+ fieldPrefix: `media.${mediaIndex}`,
346
+ onSave,
347
+ }}
348
+ />
349
+ </MediaEditorSection>
350
+
351
+ {variants.length > 0 && (
352
+ <MediaEditorSection
353
+ icon={<LayersIcon />}
354
+ title={t('admin.products.media.assigned_variants_label')}
355
+ >
356
+ <p className="-mt-2 text-muted-foreground text-xs">
357
+ {t('admin.products.media.assigned_variants_help')}
358
+ </p>
359
+ <div className="flex flex-wrap gap-2">
360
+ {variants.map((v) => {
361
+ const id = v.id
362
+ if (!id) return null
363
+ const selected = selectedVariantIds.has(id)
364
+ const label = variantLabel(v)
365
+ return (
366
+ <button
367
+ key={id}
368
+ type="button"
369
+ onClick={() => toggleVariant(id)}
370
+ aria-pressed={selected}
371
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
372
+ selected
373
+ ? 'border-primary bg-primary text-primary-foreground'
374
+ : 'border-border bg-background text-muted-foreground hover:border-muted-foreground hover:text-foreground'
375
+ }`}
376
+ >
377
+ {selected && <CheckIcon className="size-3" />}
378
+ {label}
379
+ </button>
380
+ )
381
+ })}
382
+ </div>
383
+ </MediaEditorSection>
384
+ )}
385
+ </MediaEditorDialog>
383
386
  )
384
387
  }