@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,1167 @@
1
+ import {
2
+ DndContext,
3
+ type DragEndEvent,
4
+ KeyboardSensor,
5
+ PointerSensor,
6
+ useSensor,
7
+ useSensors,
8
+ } from '@dnd-kit/core'
9
+ import {
10
+ rectSortingStrategy,
11
+ SortableContext,
12
+ sortableKeyboardCoordinates,
13
+ useSortable,
14
+ } from '@dnd-kit/sortable'
15
+ import { CSS } from '@dnd-kit/utilities'
16
+ import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
17
+ import {
18
+ getApiClient,
19
+ MediaPickerSheet,
20
+ parseVideoUrl,
21
+ ResourceMultiAutocomplete,
22
+ TagCombobox,
23
+ useDirectUpload,
24
+ useOptionalStore,
25
+ } from '@spree/dashboard-core'
26
+ import {
27
+ Button,
28
+ Card,
29
+ CardAction,
30
+ CardContent,
31
+ CardHeader,
32
+ CardTitle,
33
+ ContextMenu,
34
+ ContextMenuContent,
35
+ ContextMenuItem,
36
+ ContextMenuSeparator,
37
+ ContextMenuTrigger,
38
+ DragHandle,
39
+ Field,
40
+ FieldError,
41
+ FieldLabel,
42
+ Input,
43
+ RichTextEditor,
44
+ Select,
45
+ SelectContent,
46
+ SelectItem,
47
+ SelectTrigger,
48
+ SelectValue,
49
+ StatusCard as SharedStatusCard,
50
+ Textarea,
51
+ toastManager,
52
+ useConfirm,
53
+ } from '@spree/dashboard-ui'
54
+ import {
55
+ DownloadIcon,
56
+ FilmIcon,
57
+ ImagePlusIcon,
58
+ LibraryIcon,
59
+ Loader2Icon,
60
+ PencilIcon,
61
+ PlayIcon,
62
+ TrashIcon,
63
+ } from '@spree/dashboard-ui/icons'
64
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
65
+ import { type Control, Controller, type UseFormReturn, useWatch } from 'react-hook-form'
66
+ import { useTranslation } from 'react-i18next'
67
+ import { AddVideoDialog } from './add-video-dialog'
68
+ import { InventorySection } from './inventory-section'
69
+ import { MediaEditSheet } from './media-edit-sheet'
70
+ import { ProductBulkPriceEditor } from './product-bulk-price-editor'
71
+ import type {
72
+ PanelMedia as Media,
73
+ PanelProduct as Product,
74
+ PanelVariant as Variant,
75
+ } from './product-types'
76
+ import {
77
+ categoryAutocompleteProps,
78
+ collectionAutocompleteProps,
79
+ useFormCategories as useCategories,
80
+ useFormCollections as useCollections,
81
+ useFormDeleteProductMedia as useDeleteProductMedia,
82
+ useFormDeliveryProfiles as useDeliveryProfiles,
83
+ useFormOptionTypesByIds as useOptionTypesByIds,
84
+ useFormProductType as useProductType,
85
+ useFormProductTypes as useProductTypes,
86
+ useFormTaxCategories as useTaxCategories,
87
+ } from './use-product-form-data'
88
+ import { VariantsSection } from './variants-section'
89
+
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ type FormCardProps = { form: UseFormReturn<ProductFormValues, any, any> }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // General
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * The description editor a panel supplies.
99
+ *
100
+ * The operator's dashboard passes its media-aware editor, so a merchant can
101
+ * embed a file from the library. A seller has no library to embed from, so
102
+ * they get the plain editor — which is the default, keeping the card usable
103
+ * without the injection.
104
+ */
105
+ export type RichTextEditorComponent = React.ComponentType<{
106
+ id?: string
107
+ ariaLabel?: string
108
+ value?: string
109
+ onChange: (value: string) => void
110
+ placeholder?: string
111
+ }>
112
+
113
+ export function GeneralCard({
114
+ form,
115
+ descriptionEditor: DescriptionEditor = RichTextEditor,
116
+ }: FormCardProps & { descriptionEditor?: RichTextEditorComponent }) {
117
+ const { t } = useTranslation()
118
+ const { errors } = form.formState
119
+ return (
120
+ <Card>
121
+ <CardHeader>
122
+ <CardTitle>{t('admin.pages.products.section_basics')}</CardTitle>
123
+ </CardHeader>
124
+ <CardContent className="flex flex-col gap-4">
125
+ <Field>
126
+ <FieldLabel htmlFor="product-name">{t('admin.fields.name.label')}</FieldLabel>
127
+ <Input
128
+ id="product-name"
129
+ placeholder={t('admin.fields.product.name.placeholder')}
130
+ aria-invalid={!!errors.name || undefined}
131
+ {...form.register('name')}
132
+ />
133
+ <FieldError errors={[errors.name]} />
134
+ </Field>
135
+ <Field>
136
+ <FieldLabel htmlFor="product-description">
137
+ {t('admin.fields.description.label')}
138
+ </FieldLabel>
139
+ <Controller
140
+ name="description"
141
+ control={form.control}
142
+ render={({ field }) => (
143
+ <DescriptionEditor
144
+ id="product-description"
145
+ ariaLabel={t('admin.fields.description.label')}
146
+ value={field.value}
147
+ onChange={field.onChange}
148
+ placeholder={t('admin.fields.product.description.placeholder')}
149
+ />
150
+ )}
151
+ />
152
+ </Field>
153
+ </CardContent>
154
+ </Card>
155
+ )
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Variants (just a passthrough so the page composition reads top-down)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export function VariantsCard({ form, seedFromType }: FormCardProps & { seedFromType?: boolean }) {
163
+ return <VariantsSection form={form} seedFromType={seedFromType} />
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Prices — inline form-backed editor. Mirrors InventoryCard: a Card around a
168
+ // section that reads/writes form state directly. Save rides the parent
169
+ // product form's Save button; no modal, no snapshot, no separate
170
+ // save/discard. Currency switching is a view-only change because the form
171
+ // already holds every currency's prices for every variant.
172
+ // ---------------------------------------------------------------------------
173
+
174
+ export function PricesCard({
175
+ form,
176
+ productName,
177
+ currencies: currenciesProp,
178
+ defaultCurrency: defaultCurrencyProp,
179
+ }: FormCardProps & {
180
+ productName: string
181
+ /**
182
+ * A panel with no store context supplies these itself. The operator's has a
183
+ * StoreProvider; a seller's tenant is a seller and the store is derived
184
+ * server-side, so it passes the currencies its profile reports.
185
+ */
186
+ currencies?: string[]
187
+ defaultCurrency?: string
188
+ }) {
189
+ const { t } = useTranslation()
190
+ const store = useOptionalStore()
191
+ const currencies = currenciesProp ?? store?.currencies ?? []
192
+ const defaultCurrency = defaultCurrencyProp ?? store?.defaultCurrency ?? 'USD'
193
+ // Not `useState(defaultCurrency)` alone: the store (or the seller's profile)
194
+ // arrives after the first render, so a state seeded from the fallback would
195
+ // stick and price everything in the wrong currency.
196
+ const [selectedCurrency, setCurrency] = useState<string | null>(null)
197
+ const currency = selectedCurrency ?? defaultCurrency
198
+
199
+ // Until the real default arrives there is no currency to price in, and an
200
+ // amount typed against the `USD` fallback would be saved under it — the
201
+ // header then flips to the true currency while the wrong row rides along.
202
+ const currencyResolved = Boolean(defaultCurrencyProp ?? store?.defaultCurrency)
203
+
204
+ return (
205
+ <Card>
206
+ <CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
207
+ <CardTitle>{t('admin.common.prices')}</CardTitle>
208
+ {currencies.length > 1 && (
209
+ <Select value={currency} onValueChange={setCurrency}>
210
+ <SelectTrigger size="sm" className="w-24">
211
+ <SelectValue />
212
+ </SelectTrigger>
213
+ <SelectContent>
214
+ {currencies.map((c) => (
215
+ <SelectItem key={c} value={c}>
216
+ {c}
217
+ </SelectItem>
218
+ ))}
219
+ </SelectContent>
220
+ </Select>
221
+ )}
222
+ </CardHeader>
223
+ {/* `p-0` like every other card holding a table: the grid draws its own
224
+ cell borders to the card's edges, and padding here insets it into a
225
+ floating block with a gutter on all four sides. */}
226
+ <CardContent className="p-0">
227
+ {currencyResolved ? (
228
+ <ProductBulkPriceEditor form={form} currency={currency} productName={productName} />
229
+ ) : (
230
+ <div className="m-3 h-24 animate-pulse rounded-md bg-muted" />
231
+ )}
232
+ </CardContent>
233
+ </Card>
234
+ )
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Media — edit-only. On create, render a "save first" empty state so the
239
+ // merchant sees where uploads will go but can't trigger them.
240
+ // ---------------------------------------------------------------------------
241
+
242
+ interface PendingUpload {
243
+ id: string
244
+ file: File
245
+ preview: string
246
+ progress: 'uploading' | 'attaching' | 'done' | 'error'
247
+ }
248
+
249
+ // What the gallery accepts. `accept` on the file input only constrains the OS
250
+ // picker — a drop bypasses it entirely, so handleDrop filters against the same
251
+ // list or an unsupported file uploads and then fails the whole product save.
252
+ const ACCEPTED_MEDIA = ['image/', 'video/mp4', 'video/webm', 'video/quicktime']
253
+
254
+ function isAcceptedMedia(file: File): boolean {
255
+ return ACCEPTED_MEDIA.some((type) =>
256
+ type.endsWith('/') ? file.type.startsWith(type) : file.type === type,
257
+ )
258
+ }
259
+
260
+ // Unified, form-backed media card. Single source of truth: form.media.
261
+ // Both new and edit pages use the same component; the only difference is
262
+ // whether form.media starts empty (new) or pre-hydrated from the persisted
263
+ // product (edit, via productToFormValues). Uploads, alt edits, reorders,
264
+ // and variant_ids assignments all live in form state and ride the same
265
+ // product POST/PATCH. The dedicated DELETE /media endpoint stays — we
266
+ // don't ship deletes inline (no implicit-omission semantics).
267
+ export function MediaCard({
268
+ productId,
269
+ variants,
270
+ form,
271
+ }: {
272
+ productId?: string
273
+ // The MediaEditSheet's "assign to variant" pill row needs the list of
274
+ // server-persisted variants — only those have an id that can ride the
275
+ // PATCH's media[].variant_ids. Form-state variants without a server id
276
+ // can't be assigned until the merchant saves.
277
+ variants?: Variant[]
278
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
279
+ form: UseFormReturn<ProductFormValues, any, any>
280
+ }) {
281
+ const { t } = useTranslation()
282
+ const directUpload = useDirectUpload()
283
+ const deleteMedia = useDeleteProductMedia(productId ?? '')
284
+ const confirm = useConfirm()
285
+ const [pending, setPending] = useState<PendingUpload[]>([])
286
+ const [editingIndex, setEditingIndex] = useState<number | null>(null)
287
+ const [addingVideo, setAddingVideo] = useState(false)
288
+ const [pickingFromLibrary, setPickingFromLibrary] = useState(false)
289
+ const mediaLibrary = getApiClient().mediaLibrary
290
+ const fileInputRef = useRef<HTMLInputElement>(null)
291
+
292
+ const items = useWatch({ control: form.control, name: 'media' }) ?? []
293
+
294
+ // dnd-kit needs a stable id per row. Persisted items have one; new uploads
295
+ // get their uploadId (assigned at completion). signed_id is fine as a
296
+ // fallback for items hydrated server-side without an uploadId.
297
+ const sortableIds = useMemo(
298
+ () => items.map((m, i) => m.id ?? m.uploadId ?? m.signed_id ?? `idx-${i}`),
299
+ [items],
300
+ )
301
+
302
+ const sensors = useSensors(
303
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
304
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
305
+ )
306
+
307
+ const handleDragEnd = useCallback(
308
+ (event: DragEndEvent) => {
309
+ const { active, over } = event
310
+ if (!over || active.id === over.id) return
311
+ const current = form.getValues('media') ?? []
312
+ const fromIndex = sortableIds.indexOf(String(active.id))
313
+ const toIndex = sortableIds.indexOf(String(over.id))
314
+ if (fromIndex === -1 || toIndex === -1) return
315
+ const next = [...current]
316
+ const [moved] = next.splice(fromIndex, 1)
317
+ next.splice(toIndex, 0, moved)
318
+ form.setValue(
319
+ 'media',
320
+ next.map((m, i) => ({ ...m, position: i + 1 })),
321
+ { shouldDirty: true },
322
+ )
323
+ },
324
+ [form, sortableIds],
325
+ )
326
+
327
+ const handleFiles = useCallback(
328
+ async (files: FileList | File[]) => {
329
+ const fileArray = Array.from(files)
330
+ for (const file of fileArray) {
331
+ const uploadId = crypto.randomUUID()
332
+ const preview = URL.createObjectURL(file)
333
+ setPending((prev) => [...prev, { id: uploadId, file, preview, progress: 'uploading' }])
334
+ try {
335
+ const result = await directUpload.mutateAsync(file)
336
+ const current = form.getValues('media') ?? []
337
+ form.setValue(
338
+ 'media',
339
+ [
340
+ ...current,
341
+ {
342
+ signed_id: result.signedId,
343
+ alt: file.name,
344
+ position: current.length + 1,
345
+ media_type: file.type.startsWith('video/')
346
+ ? ('video' as const)
347
+ : ('image' as const),
348
+ previewUrl: preview,
349
+ // Lets an uploaded video play in the editor before it is saved.
350
+ videoUrl: file.type.startsWith('video/') ? preview : null,
351
+ uploadId,
352
+ },
353
+ ],
354
+ { shouldDirty: true },
355
+ )
356
+ setPending((prev) => prev.filter((p) => p.id !== uploadId))
357
+ } catch (err) {
358
+ console.error(`Upload failed for ${file.name}:`, err)
359
+ setPending((prev) =>
360
+ prev.map((p) => (p.id === uploadId ? { ...p, progress: 'error' as const } : p)),
361
+ )
362
+ const message = err instanceof Error ? err.message : t('admin.errors.unexpected')
363
+ toastManager.add({
364
+ type: 'error',
365
+ title: t('admin.products.media.upload_failed', { name: file.name, message }),
366
+ })
367
+ }
368
+ }
369
+ },
370
+ [directUpload, form, t],
371
+ )
372
+
373
+ const handleDrop = useCallback(
374
+ (e: React.DragEvent) => {
375
+ e.preventDefault()
376
+ const accepted = Array.from(e.dataTransfer.files).filter(isAcceptedMedia)
377
+ const rejected = e.dataTransfer.files.length - accepted.length
378
+
379
+ if (rejected > 0)
380
+ toastManager.add({
381
+ type: 'error',
382
+ title: t('admin.products.media.unsupported_file', { count: rejected }),
383
+ })
384
+ if (accepted.length > 0) handleFiles(accepted)
385
+ },
386
+ [handleFiles, t],
387
+ )
388
+
389
+ const handleDragOver = useCallback((e: React.DragEvent) => {
390
+ e.preventDefault()
391
+ }, [])
392
+
393
+ // An external video has no file to upload — it goes straight into form state
394
+ // with its link, and the provider's own still stands in as the preview.
395
+ const handleAddVideo = useCallback(
396
+ (url: string) => {
397
+ const current = form.getValues('media') ?? []
398
+ form.setValue(
399
+ 'media',
400
+ [
401
+ ...current,
402
+ {
403
+ media_type: 'external_video' as const,
404
+ external_video_url: url,
405
+ position: current.length + 1,
406
+ previewUrl: parseVideoUrl(url)?.thumbnailUrl ?? undefined,
407
+ uploadId: crypto.randomUUID(),
408
+ },
409
+ ],
410
+ { shouldDirty: true },
411
+ )
412
+ },
413
+ [form],
414
+ )
415
+
416
+ // Files picked from the library become ordinary form entries carrying the
417
+ // source's id. The product save places them, sharing the file rather than
418
+ // uploading a second copy.
419
+ const handleAddFromLibrary = useCallback(
420
+ (picked: Media[]) => {
421
+ const current = form.getValues('media') ?? []
422
+ form.setValue(
423
+ 'media',
424
+ [
425
+ ...current,
426
+ ...picked.map((media, index) => ({
427
+ source_media_id: media.id,
428
+ alt: media.alt ?? undefined,
429
+ media_type: (media.media_type ?? 'image') as MediaType,
430
+ position: current.length + index + 1,
431
+ previewUrl: media.small_url ?? media.original_url ?? undefined,
432
+ videoUrl: media.video_url ?? null,
433
+ posterUrl: media.poster_url ?? null,
434
+ uploadId: crypto.randomUUID(),
435
+ })),
436
+ ],
437
+ { shouldDirty: true },
438
+ )
439
+ },
440
+ [form],
441
+ )
442
+
443
+ const handleDelete = useCallback(
444
+ async (index: number) => {
445
+ const current = form.getValues('media') ?? []
446
+ const entry = current[index]
447
+ if (!entry) return
448
+ const confirmed = await confirm({
449
+ message: t('admin.products.media.delete_confirm'),
450
+ variant: 'destructive',
451
+ confirmLabel: t('admin.actions.delete'),
452
+ })
453
+ if (!confirmed) return
454
+
455
+ // Persisted entries call the dedicated DELETE endpoint first so we
456
+ // don't drop the form-state entry until the server actually removed it.
457
+ // Pre-save entries (signed_id only, no id) just disappear from form.
458
+ if (entry.id && productId) {
459
+ try {
460
+ await deleteMedia.mutateAsync(entry.id)
461
+ } catch {
462
+ toastManager.add({ type: 'error', title: t('admin.errors.failed_to_delete') })
463
+ return
464
+ }
465
+ }
466
+
467
+ // Release the Blob URL we created at upload time so the browser can
468
+ // reclaim its backing memory. Server-served previewUrls aren't blob:
469
+ // URLs and don't need revocation.
470
+ if (entry.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(entry.previewUrl)
471
+
472
+ const next = current.filter((_, i) => i !== index).map((m, i) => ({ ...m, position: i + 1 }))
473
+ form.setValue('media', next, { shouldDirty: true })
474
+
475
+ // Close or shift the edit sheet so it can't reference a stale index.
476
+ // If the deleted row was being edited, close. If the deleted row was
477
+ // BEFORE the open one, shift down by one.
478
+ setEditingIndex((current) => {
479
+ if (current == null) return current
480
+ if (current === index) return null
481
+ if (current > index) return current - 1
482
+ return current
483
+ })
484
+ },
485
+ [form, productId, deleteMedia, confirm, t],
486
+ )
487
+
488
+ // Revoke any remaining blob: previewUrls when the card unmounts (the
489
+ // merchant navigated away mid-edit without saving). form.getValues is a
490
+ // stable RHF method — listing it in deps wouldn't change effect timing
491
+ // but it keeps the linter happy.
492
+ useEffect(() => {
493
+ return () => {
494
+ const current = form.getValues('media') ?? []
495
+ for (const m of current) {
496
+ if (m.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(m.previewUrl)
497
+ }
498
+ }
499
+ }, [form])
500
+
501
+ const editingEntry = editingIndex !== null ? items[editingIndex] : null
502
+
503
+ return (
504
+ <>
505
+ <Card className="scroll-mt-[calc(var(--spacing-header-height)+1.5rem)]">
506
+ <CardHeader>
507
+ <CardTitle>{t('admin.pages.products.section_media')}</CardTitle>
508
+ </CardHeader>
509
+ <CardContent className="flex flex-col gap-4">
510
+ {(items.length > 0 || pending.length > 0) && (
511
+ <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
512
+ <SortableContext items={sortableIds} strategy={rectSortingStrategy}>
513
+ <div className="grid grid-cols-4 gap-3">
514
+ {items.map((media, index) => (
515
+ <SortableMediaThumbnail
516
+ key={sortableIds[index]}
517
+ sortableId={sortableIds[index]}
518
+ previewUrl={media.previewUrl ?? null}
519
+ downloadUrl={media.downloadUrl}
520
+ alt={media.alt ?? ''}
521
+ mediaType={media.media_type ?? 'image'}
522
+ onEdit={() => setEditingIndex(index)}
523
+ onDelete={() => handleDelete(index)}
524
+ />
525
+ ))}
526
+ {pending.map((upload) => (
527
+ <div
528
+ key={upload.id}
529
+ className="relative aspect-square overflow-hidden rounded-lg border border-border bg-muted"
530
+ >
531
+ {upload.file.type.startsWith('video/') ? (
532
+ <video
533
+ src={upload.preview}
534
+ muted
535
+ playsInline
536
+ preload="metadata"
537
+ className="size-full object-cover opacity-60"
538
+ />
539
+ ) : (
540
+ <img
541
+ src={upload.preview}
542
+ alt=""
543
+ className="size-full object-cover opacity-60"
544
+ />
545
+ )}
546
+ <div className="absolute inset-0 flex items-center justify-center">
547
+ {upload.progress === 'error' ? (
548
+ <span className="text-xs text-destructive font-medium">
549
+ {t('admin.products.media.upload_status_failed')}
550
+ </span>
551
+ ) : (
552
+ <Loader2Icon className="size-5 animate-spin text-muted-foreground" />
553
+ )}
554
+ </div>
555
+ </div>
556
+ ))}
557
+ </div>
558
+ </SortableContext>
559
+ </DndContext>
560
+ )}
561
+
562
+ <button
563
+ type="button"
564
+ onDrop={handleDrop}
565
+ onDragOver={handleDragOver}
566
+ className="flex w-full 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 cursor-pointer"
567
+ onClick={() => fileInputRef.current?.click()}
568
+ >
569
+ <ImagePlusIcon className="size-8 text-muted-foreground" />
570
+ <p className="text-sm text-muted-foreground">{t('admin.products.media.drop_hint')}</p>
571
+ <p className="text-xs text-muted-foreground">
572
+ {t('admin.products.media.file_types_hint')}
573
+ </p>
574
+ </button>
575
+ <input
576
+ ref={fileInputRef}
577
+ type="file"
578
+ accept={ACCEPTED_MEDIA.map((type) => (type.endsWith('/') ? `${type}*` : type)).join(
579
+ ',',
580
+ )}
581
+ multiple
582
+ className="hidden"
583
+ onChange={(e) => e.target.files && handleFiles(e.target.files)}
584
+ />
585
+
586
+ <div className="flex flex-wrap gap-2 self-start">
587
+ {/* Only a panel with a library to browse offers this. */}
588
+ {mediaLibrary && (
589
+ <Button type="button" variant="outline" onClick={() => setPickingFromLibrary(true)}>
590
+ <LibraryIcon />
591
+ {t('admin.products.media.add_from_library')}
592
+ </Button>
593
+ )}
594
+
595
+ <Button type="button" variant="outline" onClick={() => setAddingVideo(true)}>
596
+ <FilmIcon />
597
+ {t('admin.products.media.add_video')}
598
+ </Button>
599
+ </div>
600
+ </CardContent>
601
+ </Card>
602
+ <AddVideoDialog open={addingVideo} onOpenChange={setAddingVideo} onAdd={handleAddVideo} />
603
+ {/* Not merely closed — the sheet reads the store context on mount, and
604
+ a panel without one has no library to pick from anyway. */}
605
+ {mediaLibrary && (
606
+ <MediaPickerSheet<Media>
607
+ open={pickingFromLibrary}
608
+ onOpenChange={setPickingFromLibrary}
609
+ queryKey="product-media-library"
610
+ search={async (query) =>
611
+ (await mediaLibrary?.list({
612
+ limit: 48,
613
+ ...(query ? { filename_cont: query } : {}),
614
+ })) ?? { data: [] }
615
+ }
616
+ onConfirm={handleAddFromLibrary}
617
+ />
618
+ )}
619
+ {editingEntry && editingIndex !== null && (
620
+ <MediaEditSheet
621
+ form={form}
622
+ mediaIndex={editingIndex}
623
+ variants={variants ?? []}
624
+ open
625
+ onOpenChange={(open) => {
626
+ if (!open) setEditingIndex(null)
627
+ }}
628
+ />
629
+ )}
630
+ </>
631
+ )
632
+ }
633
+
634
+ function SortableMediaThumbnail({
635
+ sortableId,
636
+ previewUrl,
637
+ alt,
638
+ mediaType,
639
+ downloadUrl,
640
+ onEdit,
641
+ onDelete,
642
+ }: {
643
+ sortableId: string
644
+ previewUrl: string | null
645
+ alt: string
646
+ mediaType: MediaType
647
+ /** Absent until the row is saved — a pre-save upload has no server URL. */
648
+ downloadUrl?: string | null
649
+ onEdit: () => void
650
+ onDelete: () => void
651
+ }) {
652
+ const { t } = useTranslation()
653
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
654
+ id: sortableId,
655
+ })
656
+
657
+ const style = {
658
+ transform: CSS.Transform.toString(transform),
659
+ transition,
660
+ }
661
+
662
+ return (
663
+ <ContextMenu>
664
+ <ContextMenuTrigger
665
+ render={
666
+ // Clicking opens the edit sheet; dragging is the corner grip's job.
667
+ // The whole tile being the drag surface left no way to click it.
668
+ // biome-ignore lint/a11y/useSemanticElements: the grip inside rules out a <button>
669
+ <div
670
+ ref={setNodeRef}
671
+ style={style}
672
+ data-slot="media-thumbnail"
673
+ role="button"
674
+ tabIndex={0}
675
+ onClick={onEdit}
676
+ onKeyDown={(e) => {
677
+ if (e.key === 'Enter' || e.key === ' ') {
678
+ e.preventDefault()
679
+ onEdit()
680
+ }
681
+ }}
682
+ className={`group relative aspect-square cursor-pointer bg-muted overflow-hidden rounded-md border border-border transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ${
683
+ isDragging ? 'opacity-40 ring-2 ring-primary/40' : ''
684
+ }`}
685
+ />
686
+ }
687
+ >
688
+ {previewUrl && mediaType === 'video' ? (
689
+ // An uploaded video has no still until a poster is set, so its own
690
+ // first frame stands in — an <img> would render a broken icon.
691
+ <video
692
+ src={previewUrl}
693
+ muted
694
+ playsInline
695
+ preload="metadata"
696
+ className="pointer-events-none size-full object-cover"
697
+ />
698
+ ) : previewUrl ? (
699
+ <img
700
+ src={previewUrl}
701
+ alt={alt}
702
+ draggable={false}
703
+ className="pointer-events-none size-full object-cover"
704
+ />
705
+ ) : (
706
+ <div className="flex size-full items-center justify-center text-muted-foreground">
707
+ {mediaType === 'image' ? (
708
+ <ImagePlusIcon className="size-6" />
709
+ ) : (
710
+ <FilmIcon className="size-6" />
711
+ )}
712
+ </div>
713
+ )}
714
+
715
+ {mediaType !== 'image' && (
716
+ <span className="pointer-events-none absolute left-1.5 top-1.5 z-10 flex size-6 items-center justify-center rounded-full bg-black/60 text-white">
717
+ <PlayIcon className="size-3 fill-current" />
718
+ </span>
719
+ )}
720
+
721
+ <DragHandle
722
+ attributes={attributes}
723
+ listeners={listeners}
724
+ // Stop the click reaching the tile behind it, or picking the grip up
725
+ // would also open the sheet. touch-none lets a touch drag start
726
+ // without the browser treating it as a scroll.
727
+ onClick={(e) => e.stopPropagation()}
728
+ className="absolute right-1.5 top-1.5 z-10 h-auto w-auto touch-none rounded-md bg-background/80 p-1 opacity-0 shadow-sm backdrop-blur transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
729
+ />
730
+ </ContextMenuTrigger>
731
+
732
+ {/* Right-click is the way in here; clicking a tile drags it, and the
733
+ edit sheet is reachable from this menu. */}
734
+ <ContextMenuContent>
735
+ <ContextMenuItem onClick={onEdit}>
736
+ <PencilIcon />
737
+ {t('admin.actions.edit')}
738
+ </ContextMenuItem>
739
+ {downloadUrl && (
740
+ // The URL carries a Content-Disposition attachment header, so
741
+ // navigating to it downloads rather than opening a page.
742
+ <ContextMenuItem
743
+ onClick={() => {
744
+ window.location.href = downloadUrl
745
+ }}
746
+ >
747
+ <DownloadIcon />
748
+ {t('admin.media_library.download')}
749
+ </ContextMenuItem>
750
+ )}
751
+ <ContextMenuSeparator />
752
+ <ContextMenuItem variant="destructive" onClick={onDelete}>
753
+ <TrashIcon />
754
+ {t('admin.actions.delete')}
755
+ </ContextMenuItem>
756
+ </ContextMenuContent>
757
+ </ContextMenu>
758
+ )
759
+ }
760
+
761
+ // ---------------------------------------------------------------------------
762
+ // Inventory
763
+ // ---------------------------------------------------------------------------
764
+
765
+ export function InventoryCard({
766
+ form,
767
+ stockLocationHref,
768
+ actions,
769
+ }: FormCardProps & { stockLocationHref?: (id: string) => string; actions?: React.ReactNode }) {
770
+ const { t } = useTranslation()
771
+ return (
772
+ <Card>
773
+ <CardHeader>
774
+ <CardTitle>{t('admin.pages.products.section_inventory')}</CardTitle>
775
+ {actions && <CardAction>{actions}</CardAction>}
776
+ </CardHeader>
777
+ <CardContent className="p-0">
778
+ <InventorySection form={form} stockLocationHref={stockLocationHref} />
779
+ </CardContent>
780
+ </Card>
781
+ )
782
+ }
783
+
784
+ // ---------------------------------------------------------------------------
785
+ // Custom Fields — edit-only placeholder ("save first")
786
+ // ---------------------------------------------------------------------------
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // SEO
790
+ // ---------------------------------------------------------------------------
791
+
792
+ export function SEOCard({ form, product }: FormCardProps & { product?: Product }) {
793
+ const { t } = useTranslation()
794
+ const slug = form.watch('slug')
795
+ const metaTitle = form.watch('meta_title')
796
+ const metaDescription = form.watch('meta_description')
797
+ const name = form.watch('name')
798
+ const { errors } = form.formState
799
+
800
+ const previewTitle = metaTitle || product?.name || name || ''
801
+ const previewSlug =
802
+ slug || product?.slug || t('admin.pages.products.new.preview_slug_placeholder')
803
+
804
+ return (
805
+ <Card>
806
+ <CardHeader>
807
+ <CardTitle>{t('admin.pages.products.section_seo')}</CardTitle>
808
+ </CardHeader>
809
+ <CardContent className="flex flex-col gap-4">
810
+ <div className="rounded-lg border border-border p-4 space-y-1">
811
+ <p className="text-sm font-medium text-blue-700 truncate">{previewTitle}</p>
812
+ <p className="text-xs text-green-700 truncate">example.com/products/{previewSlug}</p>
813
+ {metaDescription && (
814
+ <p className="text-xs text-muted-foreground line-clamp-2">{metaDescription}</p>
815
+ )}
816
+ </div>
817
+
818
+ <Field>
819
+ <FieldLabel htmlFor="product-slug">{t('admin.fields.slug.label')}</FieldLabel>
820
+ <Input
821
+ id="product-slug"
822
+ placeholder={t('admin.products.seo.slug_placeholder')}
823
+ aria-invalid={!!errors.slug || undefined}
824
+ {...form.register('slug')}
825
+ />
826
+ <FieldError errors={[errors.slug]} />
827
+ </Field>
828
+ <Field>
829
+ <FieldLabel htmlFor="product-meta-title">{t('admin.fields.meta_title.label')}</FieldLabel>
830
+ <Input
831
+ id="product-meta-title"
832
+ placeholder={t('admin.products.seo.meta_title_placeholder')}
833
+ aria-invalid={!!errors.meta_title || undefined}
834
+ {...form.register('meta_title')}
835
+ />
836
+ <FieldError errors={[errors.meta_title]} />
837
+ </Field>
838
+ <Field>
839
+ <FieldLabel htmlFor="product-meta-description">
840
+ {t('admin.fields.meta_description.label')}
841
+ </FieldLabel>
842
+ <Textarea
843
+ id="product-meta-description"
844
+ rows={3}
845
+ placeholder={t('admin.products.seo.meta_description_placeholder')}
846
+ aria-invalid={!!errors.meta_description || undefined}
847
+ {...form.register('meta_description')}
848
+ />
849
+ <FieldError errors={[errors.meta_description]} />
850
+ </Field>
851
+ </CardContent>
852
+ </Card>
853
+ )
854
+ }
855
+
856
+ // ---------------------------------------------------------------------------
857
+ // Status
858
+ // ---------------------------------------------------------------------------
859
+
860
+ const ASSIGNABLE_STATUSES = ['draft', 'active', 'archived']
861
+
862
+ export function StatusCard({
863
+ form,
864
+ reviewActions,
865
+ }: FormCardProps & {
866
+ /**
867
+ * Approve / Reject, supplied by the panel that can run them. Rendered only
868
+ * while the product is actually in review, so an ordinary product's card is
869
+ * unchanged.
870
+ */
871
+ reviewActions?: React.ReactNode
872
+ }) {
873
+ const { t } = useTranslation()
874
+ const status = useWatch({ control: form.control, name: 'status' })
875
+
876
+ // A product in a review status sits on one nobody assigns — it got there by
877
+ // a seller submitting or an operator deciding. Listing it anyway is what
878
+ // lets the picker render the product's actual state instead of a raw value
879
+ // with no matching option (docs/plans/6.0-seller-product-submission.md).
880
+ //
881
+ // Only `proposed` locks the control. That is a decision still to be made,
882
+ // and it is made with Approve or Reject. `rejected` is a decision already
883
+ // made, so the operator can move the product on from here like any other —
884
+ // overriding their own refusal is not a review action, and leaving the
885
+ // picker disabled there was a dead end with no way out.
886
+ const inReview = Boolean(status) && !ASSIGNABLE_STATUSES.includes(status as string)
887
+ const awaitingDecision = status === 'proposed'
888
+ const values = inReview ? [status as string, ...ASSIGNABLE_STATUSES] : ASSIGNABLE_STATUSES
889
+
890
+ return (
891
+ <SharedStatusCard<ProductFormValues>
892
+ control={form.control as Control<ProductFormValues>}
893
+ name="status"
894
+ title={t('admin.fields.status.label')}
895
+ label={t('admin.fields.status.label')}
896
+ disabled={awaitingDecision}
897
+ description={awaitingDecision ? t('admin.pages.products.status_under_review') : undefined}
898
+ actions={inReview ? reviewActions : undefined}
899
+ options={values.map((value) => ({
900
+ value,
901
+ label: t(`admin.pages.products.status_options.${value}`),
902
+ }))}
903
+ />
904
+ )
905
+ }
906
+
907
+ // ---------------------------------------------------------------------------
908
+ // Categorization
909
+ // ---------------------------------------------------------------------------
910
+
911
+ export function CategorizationCard({ form }: FormCardProps) {
912
+ const { t } = useTranslation()
913
+ // Each picker shows only when the panel's client registers its source: a
914
+ // seller's registers types and delivery profiles but not the filing
915
+ // vocabulary (categories, collections, tags), which is the operator's.
916
+ const client = getApiClient()
917
+ const { data: categoriesData } = useCategories()
918
+ const { data: collectionsData } = useCollections()
919
+ const { data: productTypesData } = useProductTypes()
920
+ const productTypes = productTypesData?.data ?? []
921
+ const { data: deliveryProfilesData } = useDeliveryProfiles()
922
+ const deliveryProfiles = useMemo(() => deliveryProfilesData?.data ?? [], [deliveryProfilesData])
923
+ // A product with no profile of its own ships on the store default, so the
924
+ // select shows that rather than an empty trigger the merchant has to guess at.
925
+ const defaultDeliveryProfileId = deliveryProfiles.find((profile) => profile.default)?.id
926
+ const selectedProductTypeId = form.watch('product_type_id') as string | null | undefined
927
+ const { data: selectedProductType } = useProductType(selectedProductTypeId ?? undefined)
928
+ // Resolved by id: the type may reference option types beyond the first page
929
+ // of the global list, which would otherwise drop out of the hint.
930
+ const { data: typeOptionTypesData } = useOptionTypesByIds(selectedProductType?.option_type_ids)
931
+ const optionTypes = useMemo(() => typeOptionTypesData?.data ?? [], [typeOptionTypesData])
932
+
933
+ // The server seeds the type's categories onto the product when it is saved.
934
+ // Prefilling them here means the merchant sees what they are about to get and
935
+ // can still edit the list first. Additive, like the server: nothing they
936
+ // already picked is removed.
937
+ // Named rather than counted: on a product that already has variants the
938
+ // builder won't surface these, so the name is the only signal the merchant
939
+ // gets about what the type contributes.
940
+ const typeOptionTypeNames = useMemo(() => {
941
+ // Served already named where the panel has no option-type list to resolve
942
+ // ids against — a seller's API sends labels for exactly this reason.
943
+ const labels = selectedProductType?.option_type_labels
944
+ if (labels?.length) return labels
945
+
946
+ const ids = selectedProductType?.option_type_ids ?? []
947
+ return ids
948
+ .map((id) => optionTypes.find((optionType) => optionType.id === id))
949
+ .filter((optionType) => optionType !== undefined)
950
+ .map((optionType) => optionType.label ?? optionType.name ?? '')
951
+ .filter(Boolean)
952
+ }, [selectedProductType?.option_type_labels, selectedProductType?.option_type_ids, optionTypes])
953
+
954
+ useEffect(() => {
955
+ // Only where the panel files products at all: a seller's client registers
956
+ // no categories, so seeding them would dirty the form with ids their API
957
+ // drops on save — an unsaved-changes prompt over a field they cannot see.
958
+ if (!client.categories) return
959
+
960
+ const categoryIds = selectedProductType?.category_ids
961
+ if (!categoryIds?.length) return
962
+
963
+ const current = (form.getValues('category_ids') as string[] | undefined) ?? []
964
+ const missing = categoryIds.filter((id) => !current.includes(id))
965
+ if (missing.length === 0) return
966
+
967
+ form.setValue('category_ids', [...current, ...missing], { shouldDirty: true })
968
+ }, [selectedProductType, form, client.categories])
969
+ // Automatic collections rebuild their members from rules, so a hand-picked
970
+ // membership would be dropped on the next regeneration — offer manual only.
971
+ // Memoized: `initialItems` feeds a useEffect + useMemo inside
972
+ // ResourceMultiAutocomplete, so a fresh array each render would re-run both
973
+ // on every keystroke in the product form.
974
+ const manualCollections = useMemo(
975
+ () => collectionsData?.data.filter((collection) => !collection.automatic),
976
+ [collectionsData],
977
+ )
978
+ return (
979
+ <Card>
980
+ <CardHeader>
981
+ <CardTitle>{t('admin.pages.products.section_categorization')}</CardTitle>
982
+ </CardHeader>
983
+ <CardContent className="flex flex-col gap-4">
984
+ {client.productTypes && (
985
+ <Field>
986
+ <FieldLabel>{t('admin.fields.product.product_type_id.label')}</FieldLabel>
987
+ <Controller
988
+ name="product_type_id"
989
+ control={form.control}
990
+ render={({ field }) => (
991
+ <Select value={field.value ?? ''} onValueChange={(v) => field.onChange(v || null)}>
992
+ <SelectTrigger className="w-full">
993
+ <SelectValue placeholder={t('admin.products.product_type_placeholder')}>
994
+ {(v) =>
995
+ productTypes.find((pt) => pt.id === v)?.name ??
996
+ t('admin.products.product_type_placeholder')
997
+ }
998
+ </SelectValue>
999
+ </SelectTrigger>
1000
+ <SelectContent>
1001
+ {productTypes.map((productType) => (
1002
+ <SelectItem key={productType.id} value={productType.id}>
1003
+ {productType.name}
1004
+ </SelectItem>
1005
+ ))}
1006
+ </SelectContent>
1007
+ </Select>
1008
+ )}
1009
+ />
1010
+ <span className="text-muted-foreground text-xs">
1011
+ {t('admin.fields.product.product_type_id.help')}
1012
+ </span>
1013
+ {typeOptionTypeNames.length > 0 && (
1014
+ <span className="text-muted-foreground text-xs">
1015
+ {t('admin.products.product_type_adds_options', {
1016
+ options: typeOptionTypeNames.join(', '),
1017
+ count: typeOptionTypeNames.length,
1018
+ })}
1019
+ </span>
1020
+ )}
1021
+ </Field>
1022
+ )}
1023
+
1024
+ {/* A panel whose client registers no delivery profiles cannot write
1025
+ one, so it does not offer the field rather than showing an edit
1026
+ that goes nowhere. Gated on the registration, not on the rows
1027
+ having arrived: keyed on the list, the field would vanish and
1028
+ reappear as the fetch settles. */}
1029
+ {client.deliveryProfiles && (
1030
+ <Field>
1031
+ <FieldLabel>{t('admin.fields.product.delivery_profile_id.label')}</FieldLabel>
1032
+ <Controller
1033
+ name="delivery_profile_id"
1034
+ control={form.control}
1035
+ render={({ field }) => (
1036
+ <Select
1037
+ value={field.value ?? defaultDeliveryProfileId ?? ''}
1038
+ onValueChange={(v) => field.onChange(v || null)}
1039
+ >
1040
+ <SelectTrigger className="w-full">
1041
+ <SelectValue>
1042
+ {(v) => deliveryProfiles.find((profile) => profile.id === v)?.name ?? ''}
1043
+ </SelectValue>
1044
+ </SelectTrigger>
1045
+ <SelectContent>
1046
+ {deliveryProfiles.map((profile) => (
1047
+ <SelectItem key={profile.id} value={profile.id}>
1048
+ {profile.name}
1049
+ </SelectItem>
1050
+ ))}
1051
+ </SelectContent>
1052
+ </Select>
1053
+ )}
1054
+ />
1055
+ <span className="text-muted-foreground text-xs">
1056
+ {t('admin.fields.product.delivery_profile_id.help')}
1057
+ </span>
1058
+ </Field>
1059
+ )}
1060
+
1061
+ {client.categories && (
1062
+ <Field>
1063
+ <FieldLabel>{t('admin.fields.product.category_ids.label')}</FieldLabel>
1064
+ <Controller
1065
+ name="category_ids"
1066
+ control={form.control}
1067
+ render={({ field }) => (
1068
+ <ResourceMultiAutocomplete
1069
+ {...categoryAutocompleteProps('product-edit-category-picker')}
1070
+ initialItems={categoriesData?.data}
1071
+ value={field.value ?? []}
1072
+ onChange={field.onChange}
1073
+ />
1074
+ )}
1075
+ />
1076
+ </Field>
1077
+ )}
1078
+
1079
+ {client.collections && (
1080
+ <Field>
1081
+ <FieldLabel>{t('admin.fields.product.collection_ids.label')}</FieldLabel>
1082
+ <Controller
1083
+ name="collection_ids"
1084
+ control={form.control}
1085
+ render={({ field }) => (
1086
+ <ResourceMultiAutocomplete
1087
+ {...collectionAutocompleteProps('product-edit-collection-picker')}
1088
+ initialItems={manualCollections}
1089
+ value={field.value ?? []}
1090
+ onChange={field.onChange}
1091
+ />
1092
+ )}
1093
+ />
1094
+ <span className="text-muted-foreground text-xs">
1095
+ {t('admin.fields.product.collection_ids.help')}
1096
+ </span>
1097
+ </Field>
1098
+ )}
1099
+
1100
+ {/* Tags are the store's own vocabulary, so only a panel that manages
1101
+ it offers the field (docs/plans/6.0-multi-vendor-marketplace.md). */}
1102
+ {client.tags && (
1103
+ <Field>
1104
+ <FieldLabel>{t('admin.fields.product.tags.label')}</FieldLabel>
1105
+ <Controller
1106
+ name="tags"
1107
+ control={form.control}
1108
+ render={({ field }) => (
1109
+ <TagCombobox
1110
+ taggableType="Spree::Product"
1111
+ value={field.value ?? []}
1112
+ onChange={field.onChange}
1113
+ />
1114
+ )}
1115
+ />
1116
+ </Field>
1117
+ )}
1118
+ </CardContent>
1119
+ </Card>
1120
+ )
1121
+ }
1122
+
1123
+ // ---------------------------------------------------------------------------
1124
+ // Tax
1125
+ // ---------------------------------------------------------------------------
1126
+
1127
+ export function TaxCard({ form }: FormCardProps) {
1128
+ const { t } = useTranslation()
1129
+ const { data: taxCategoriesResponse } = useTaxCategories()
1130
+ const taxCategories = taxCategoriesResponse?.data ?? []
1131
+
1132
+ return (
1133
+ <Card>
1134
+ <CardHeader>
1135
+ <CardTitle>{t('admin.fields.tax.label')}</CardTitle>
1136
+ </CardHeader>
1137
+ <CardContent>
1138
+ <Field>
1139
+ <FieldLabel>{t('admin.fields.tax_category_id.label')}</FieldLabel>
1140
+ <Controller
1141
+ name="tax_category_id"
1142
+ control={form.control}
1143
+ render={({ field }) => (
1144
+ <Select value={field.value ?? ''} onValueChange={(v) => field.onChange(v || null)}>
1145
+ <SelectTrigger className="w-full">
1146
+ <SelectValue placeholder={t('admin.products.tax_category_placeholder')}>
1147
+ {(v) =>
1148
+ taxCategories.find((c) => c.id === v)?.name ??
1149
+ t('admin.products.tax_category_placeholder')
1150
+ }
1151
+ </SelectValue>
1152
+ </SelectTrigger>
1153
+ <SelectContent>
1154
+ {taxCategories.map((cat) => (
1155
+ <SelectItem key={cat.id} value={cat.id}>
1156
+ {cat.name}
1157
+ </SelectItem>
1158
+ ))}
1159
+ </SelectContent>
1160
+ </Select>
1161
+ )}
1162
+ />
1163
+ </Field>
1164
+ </CardContent>
1165
+ </Card>
1166
+ )
1167
+ }