@spree/dashboard-core 6.0.2 → 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 (87) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +24 -24
  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 +58 -0
  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 +194 -331
  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 +20 -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 +128 -14
  56. package/src/locales/de.json +128 -14
  57. package/src/locales/en.json +128 -14
  58. package/src/locales/es.json +128 -14
  59. package/src/locales/fr.json +128 -14
  60. package/src/locales/ja.json +128 -14
  61. package/src/locales/pl.json +130 -14
  62. package/src/locales/vi.json +128 -14
  63. package/src/locales/zh-CN.json +128 -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 +204 -73
  72. package/src/products/product-form-mapping.test.ts +42 -1
  73. package/src/products/product-form-mapping.ts +20 -0
  74. package/src/products/product-schema.ts +8 -0
  75. package/src/products/product-types.ts +2 -0
  76. package/src/products/use-product-form-data.ts +30 -3
  77. package/src/products/variant-edit-sheet.tsx +3 -3
  78. package/src/products/variants-options-builder.tsx +13 -8
  79. package/src/products/variants-section.tsx +196 -4
  80. package/src/providers/auth-provider.tsx +2 -0
  81. package/src/providers/developer-inspector-provider.tsx +145 -0
  82. package/src/providers/json-link-resolver-provider.tsx +27 -0
  83. package/src/sellers/seller-schema.ts +107 -0
  84. package/src/tables/package-types.tsx +3 -3
  85. package/src/tables/stock-locations.tsx +15 -14
  86. package/src/virtual.d.ts +4 -0
  87. package/src/vite/index.ts +103 -5
@@ -13,11 +13,12 @@ import {
13
13
  useSortable,
14
14
  } from '@dnd-kit/sortable'
15
15
  import { CSS } from '@dnd-kit/utilities'
16
- import type { CurrencyCode } from '@spree/core'
16
+ import { type CurrencyCode, randomUUID } from '@spree/core'
17
17
  import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
18
18
  import {
19
19
  getApiClient,
20
- MediaPickerSheet,
20
+ MediaPickerDialog,
21
+ mediaPickerListParams,
21
22
  parseVideoUrl,
22
23
  ResourceMultiAutocomplete,
23
24
  Slot,
@@ -34,16 +35,20 @@ import {
34
35
  CardContent,
35
36
  CardHeader,
36
37
  CardTitle,
38
+ Checkbox,
37
39
  ContextMenu,
38
40
  ContextMenuContent,
39
41
  ContextMenuItem,
40
42
  ContextMenuSeparator,
41
43
  ContextMenuTrigger,
44
+ CurrencyLabel,
45
+ cn,
42
46
  DragHandle,
43
47
  Field,
44
48
  FieldError,
45
49
  FieldLabel,
46
50
  Input,
51
+ Item,
47
52
  RichTextEditor,
48
53
  Select,
49
54
  SelectContent,
@@ -102,7 +107,6 @@ import {
102
107
  collectionAutocompleteProps,
103
108
  useFormCategories as useCategories,
104
109
  useFormCollections as useCollections,
105
- useFormDeleteProductMedia as useDeleteProductMedia,
106
110
  useFormDeliveryProfiles as useDeliveryProfiles,
107
111
  useFormOptionTypesByIds as useOptionTypesByIds,
108
112
  useFormProductType as useProductType,
@@ -238,13 +242,13 @@ export function PricesCard({
238
242
  value={currency}
239
243
  onValueChange={(next) => setCurrency(currencies.find((code) => code === next) ?? null)}
240
244
  >
241
- <SelectTrigger size="sm" className="w-24">
242
- <SelectValue />
245
+ <SelectTrigger size="sm" className="w-28">
246
+ <SelectValue>{(code: string) => <CurrencyLabel code={code} />}</SelectValue>
243
247
  </SelectTrigger>
244
248
  <SelectContent>
245
249
  {currencies.map((c) => (
246
250
  <SelectItem key={c} value={c}>
247
- {c}
251
+ <CurrencyLabel code={c} />
248
252
  </SelectItem>
249
253
  ))}
250
254
  </SelectContent>
@@ -320,12 +324,14 @@ export function MediaCard({
320
324
  }) {
321
325
  const { t } = useTranslation()
322
326
  const fileUpload = useFileUpload()
323
- const deleteMedia = useDeleteProductMedia(productId ?? '')
324
327
  const confirm = useConfirm()
325
328
  const [pending, setPending] = useState<PendingUpload[]>([])
326
329
  const [editingIndex, setEditingIndex] = useState<number | null>(null)
327
330
  const [addingVideo, setAddingVideo] = useState(false)
328
331
  const [pickingFromLibrary, setPickingFromLibrary] = useState(false)
332
+ // Tiles picked for a bulk action, by their stable sortable id so a reorder
333
+ // keeps the selection on the same files.
334
+ const [selected, setSelected] = useState<Set<string>>(new Set())
329
335
  const mediaLibrary = getApiClient().mediaLibrary
330
336
  const fileInputRef = useRef<HTMLInputElement>(null)
331
337
 
@@ -368,7 +374,7 @@ export function MediaCard({
368
374
  async (files: FileList | File[]) => {
369
375
  const fileArray = Array.from(files)
370
376
  for (const file of fileArray) {
371
- const uploadId = crypto.randomUUID()
377
+ const uploadId = randomUUID()
372
378
  const preview = URL.createObjectURL(file)
373
379
  setPending((prev) => [...prev, { id: uploadId, file, preview, progress: 'uploading' }])
374
380
  try {
@@ -381,6 +387,7 @@ export function MediaCard({
381
387
  {
382
388
  signed_id: result.signedId,
383
389
  alt: file.name,
390
+ filename: file.name,
384
391
  position: current.length + 1,
385
392
  media_type: file.type.startsWith('video/')
386
393
  ? ('video' as const)
@@ -445,7 +452,7 @@ export function MediaCard({
445
452
  ...entries.map((entry, index) => ({
446
453
  ...entry,
447
454
  position: current.length + index + 1,
448
- uploadId: crypto.randomUUID(),
455
+ uploadId: randomUUID(),
449
456
  })),
450
457
  ],
451
458
  { shouldDirty: true },
@@ -475,6 +482,7 @@ export function MediaCard({
475
482
  picked.map((media) => ({
476
483
  source_media_id: media.id,
477
484
  alt: media.alt ?? undefined,
485
+ filename: media.filename ?? null,
478
486
  media_type: (media.media_type ?? 'image') as MediaType,
479
487
  previewUrl: media.small_url ?? media.original_url ?? undefined,
480
488
  videoUrl: media.video_url ?? null,
@@ -484,49 +492,55 @@ export function MediaCard({
484
492
  [appendMedia],
485
493
  )
486
494
 
487
- const handleDelete = useCallback(
488
- async (index: number) => {
495
+ /**
496
+ * Takes the rows at `indices` out of the form. Saved rows are only marked
497
+ * for removal — the product save deletes them — so a removal is an unsaved
498
+ * change like any other, undone by Discard. Pre-save uploads just leave.
499
+ */
500
+ const removeEntries = useCallback(
501
+ (indices: number[]) => {
489
502
  const current = form.getValues('media') ?? []
490
- const entry = current[index]
491
- if (!entry) return
492
- const confirmed = await confirm({
493
- message: t('admin.products.media.delete_confirm'),
494
- variant: 'destructive',
495
- confirmLabel: t('admin.actions.delete'),
496
- })
497
- if (!confirmed) return
498
-
499
- // Persisted entries call the dedicated DELETE endpoint first so we
500
- // don't drop the form-state entry until the server actually removed it.
501
- // Pre-save entries (signed_id only, no id) just disappear from form.
502
- if (entry.id && productId) {
503
- try {
504
- await deleteMedia.mutateAsync(entry.id)
505
- } catch {
506
- toastManager.add({ type: 'error', title: t('admin.errors.failed_to_delete') })
507
- return
508
- }
503
+ const removed = new Set(indices.filter((index) => current[index]))
504
+ if (removed.size === 0) return
505
+
506
+ // A pre-save upload's preview is a Blob URL that Discard can never bring
507
+ // back (it was never in the saved state), so release it now.
508
+ for (const index of removed) {
509
+ const entry = current[index]
510
+ if (!entry.id && entry.previewUrl?.startsWith('blob:'))
511
+ URL.revokeObjectURL(entry.previewUrl)
509
512
  }
510
513
 
511
- // Release the Blob URL we created at upload time so the browser can
512
- // reclaim its backing memory. Server-served previewUrls aren't blob:
513
- // URLs and don't need revocation.
514
- if (entry.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(entry.previewUrl)
514
+ const removedIds = [...removed].flatMap((index) => current[index].id ?? [])
515
+ if (removedIds.length > 0) {
516
+ const marked = form.getValues('removed_media_ids') ?? []
517
+ form.setValue('removed_media_ids', [...marked, ...removedIds], { shouldDirty: true })
518
+ }
515
519
 
516
- const next = current.filter((_, i) => i !== index).map((m, i) => ({ ...m, position: i + 1 }))
520
+ const next = current
521
+ .filter((_, i) => !removed.has(i))
522
+ .map((m, i) => ({ ...m, position: i + 1 }))
517
523
  form.setValue('media', next, { shouldDirty: true })
518
524
 
519
525
  // Close or shift the edit sheet so it can't reference a stale index.
520
- // If the deleted row was being edited, close. If the deleted row was
521
- // BEFORE the open one, shift down by one.
522
- setEditingIndex((current) => {
523
- if (current == null) return current
524
- if (current === index) return null
525
- if (current > index) return current - 1
526
- return current
526
+ setEditingIndex((open) => {
527
+ if (open == null || removed.has(open)) return null
528
+ return open - [...removed].filter((i) => i < open).length
527
529
  })
528
530
  },
529
- [form, productId, deleteMedia, confirm, t],
531
+ [form],
532
+ )
533
+
534
+ const handleDelete = useCallback(
535
+ async (index: number) => {
536
+ const confirmed = await confirm({
537
+ message: t('admin.products.media.delete_confirm'),
538
+ variant: 'destructive',
539
+ confirmLabel: t('admin.actions.remove'),
540
+ })
541
+ if (confirmed) removeEntries([index])
542
+ },
543
+ [confirm, removeEntries, t],
530
544
  )
531
545
 
532
546
  // Revoke any remaining blob: previewUrls when the card unmounts (the
@@ -544,14 +558,74 @@ export function MediaCard({
544
558
 
545
559
  const editingEntry = editingIndex !== null ? items[editingIndex] : null
546
560
 
561
+ // Read against the current rows, so a file removed elsewhere drops out.
562
+ const selectedIndices = sortableIds.flatMap((id, i) => (selected.has(id) ? [i] : []))
563
+ const selecting = selectedIndices.length > 0
564
+ const allSelected = selecting && selectedIndices.length === items.length
565
+
566
+ function toggleSelected(id: string) {
567
+ setSelected((prev) => {
568
+ const next = new Set(prev)
569
+ if (next.has(id)) next.delete(id)
570
+ else next.add(id)
571
+ return next
572
+ })
573
+ }
574
+
575
+ async function handleBulkDelete() {
576
+ const count = selectedIndices.length
577
+ const confirmed = await confirm({
578
+ message: t('admin.products.media.delete_selected_confirm', { count }),
579
+ variant: 'destructive',
580
+ confirmLabel: t('admin.actions.remove'),
581
+ })
582
+ if (!confirmed) return
583
+ removeEntries(selectedIndices)
584
+ setSelected(new Set())
585
+ }
586
+
547
587
  return (
548
588
  <>
549
589
  <Card className="scroll-mt-[calc(var(--spacing-header-height)+1.5rem)]">
550
- <CardHeader>
551
- <CardTitle>
552
- <ImageIcon className="size-4" />
553
- {t('admin.pages.products.section_media')}
554
- </CardTitle>
590
+ {/* While selecting, the bar's checkbox, count and action sit on one
591
+ centred line at the title row's height. */}
592
+ <CardHeader className={selecting ? 'items-center' : undefined}>
593
+ {selecting ? (
594
+ <>
595
+ {/* The card keeps its name while the bar stands in for it. */}
596
+ <CardTitle className="sr-only">{t('admin.pages.products.section_media')}</CardTitle>
597
+ <div className="flex items-center gap-2 font-medium text-base">
598
+ <Checkbox
599
+ checked={allSelected}
600
+ indeterminate={!allSelected}
601
+ onCheckedChange={() =>
602
+ setSelected(allSelected ? new Set() : new Set(sortableIds))
603
+ }
604
+ aria-label={t('admin.products.media.select_all')}
605
+ />
606
+ {t('admin.products.media.selected_count', { count: selectedIndices.length })}
607
+ </div>
608
+ <CardAction className="flex">
609
+ <Button
610
+ type="button"
611
+ variant="destructive-ghost"
612
+ size="sm"
613
+ // A 28px button in a row sized for the title would push the
614
+ // header taller than its resting height.
615
+ className="-my-[3.5px]"
616
+ onClick={handleBulkDelete}
617
+ >
618
+ <TrashIcon className="size-4" />
619
+ {t('admin.actions.remove')}
620
+ </Button>
621
+ </CardAction>
622
+ </>
623
+ ) : (
624
+ <CardTitle>
625
+ <ImageIcon className="size-4" />
626
+ {t('admin.pages.products.section_media')}
627
+ </CardTitle>
628
+ )}
555
629
  </CardHeader>
556
630
  <CardContent className="flex flex-col gap-4">
557
631
  {(items.length > 0 || pending.length > 0) && (
@@ -566,14 +640,17 @@ export function MediaCard({
566
640
  downloadUrl={media.downloadUrl}
567
641
  alt={media.alt ?? ''}
568
642
  mediaType={media.media_type ?? 'image'}
569
- onEdit={() => setEditingIndex(index)}
643
+ selected={selected.has(sortableIds[index])}
644
+ selecting={selecting}
645
+ onToggleSelect={() => toggleSelected(sortableIds[index])}
570
646
  onDelete={() => handleDelete(index)}
647
+ onEdit={() => setEditingIndex(index)}
571
648
  />
572
649
  ))}
573
650
  {pending.map((upload) => (
574
651
  <div
575
652
  key={upload.id}
576
- className="relative aspect-square overflow-hidden rounded-lg border border-border bg-muted"
653
+ className="relative aspect-square overflow-hidden rounded-xl border border-border bg-muted"
577
654
  >
578
655
  {upload.file.type.startsWith('video/') ? (
579
656
  <video
@@ -614,7 +691,7 @@ export function MediaCard({
614
691
  onClick={() => fileInputRef.current?.click()}
615
692
  >
616
693
  <ImagePlusIcon className="size-8 text-muted-foreground" />
617
- <p className="text-sm text-muted-foreground">{t('admin.products.media.drop_hint')}</p>
694
+ <p className="text-base text-muted-foreground">{t('admin.products.media.drop_hint')}</p>
618
695
  <p className="text-xs text-muted-foreground">
619
696
  {t('admin.products.media.file_types_hint')}
620
697
  </p>
@@ -652,19 +729,36 @@ export function MediaCard({
652
729
  </CardContent>
653
730
  </Card>
654
731
  <AddVideoDialog open={addingVideo} onOpenChange={setAddingVideo} onAdd={handleAddVideo} />
655
- {/* Not merely closed — the sheet reads the store context on mount, and
732
+ {/* Not merely closed — the dialog reads the store context on mount, and
656
733
  a panel without one has no library to pick from anyway. */}
657
734
  {mediaLibrary && (
658
- <MediaPickerSheet<Media>
735
+ <MediaPickerDialog<Media>
659
736
  open={pickingFromLibrary}
660
737
  onOpenChange={setPickingFromLibrary}
661
738
  queryKey="product-media-library"
662
- search={async (query) =>
739
+ // Products take video too, which offers the picker's Type filter.
740
+ accept="image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm,video/quicktime"
741
+ search={async (query, options) =>
663
742
  (await mediaLibrary?.list({
664
743
  limit: 48,
665
- ...(query ? { filename_cont: query } : {}),
744
+ ...mediaPickerListParams(query, options),
666
745
  })) ?? { data: [] }
667
746
  }
747
+ // Uploads land in the library and arrive picked, ready to add.
748
+ onUpload={
749
+ mediaLibrary.create
750
+ ? async (file) => {
751
+ const upload = await fileUpload.mutateAsync(file)
752
+ return (await mediaLibrary.create?.({
753
+ signed_id: upload.signedId,
754
+ alt: file.name,
755
+ // A video row validates its file as video; unset, it
756
+ // defaults to image and the upload is refused.
757
+ media_type: file.type.startsWith('video/') ? 'video' : 'image',
758
+ })) as Media
759
+ }
760
+ : undefined
761
+ }
668
762
  onConfirm={handleAddFromLibrary}
669
763
  />
670
764
  )}
@@ -691,8 +785,11 @@ function SortableMediaThumbnail({
691
785
  alt,
692
786
  mediaType,
693
787
  downloadUrl,
694
- onEdit,
788
+ selected,
789
+ selecting,
790
+ onToggleSelect,
695
791
  onDelete,
792
+ onEdit,
696
793
  }: {
697
794
  sortableId: string
698
795
  previewUrl: string | null
@@ -700,8 +797,12 @@ function SortableMediaThumbnail({
700
797
  mediaType: MediaType
701
798
  /** Absent until the row is saved — a pre-save upload has no server URL. */
702
799
  downloadUrl?: string | null
703
- onEdit: () => void
800
+ selected: boolean
801
+ /** Any tile is selected: clicks select rather than edit, and boxes show. */
802
+ selecting: boolean
803
+ onToggleSelect: () => void
704
804
  onDelete: () => void
805
+ onEdit: () => void
705
806
  }) {
706
807
  const { t } = useTranslation()
707
808
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -726,16 +827,21 @@ function SortableMediaThumbnail({
726
827
  data-slot="media-thumbnail"
727
828
  role="button"
728
829
  tabIndex={0}
729
- onClick={onEdit}
830
+ aria-pressed={selecting ? selected : undefined}
831
+ // While any tile is selected a click adds to the selection, as in
832
+ // a file browser; otherwise it opens the edit sheet.
833
+ onClick={selecting ? onToggleSelect : onEdit}
730
834
  onKeyDown={(e) => {
731
835
  if (e.key === 'Enter' || e.key === ' ') {
732
836
  e.preventDefault()
733
- onEdit()
837
+ if (selecting) onToggleSelect()
838
+ else onEdit()
734
839
  }
735
840
  }}
736
- 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 ${
737
- isDragging ? 'opacity-40 ring-2 ring-primary/40' : ''
738
- }`}
841
+ className={cn(
842
+ 'group relative aspect-square cursor-pointer overflow-hidden rounded-xl border border-border-control bg-card outline-none transition-shadow focus-visible:shadow-field-focus',
843
+ isDragging && 'opacity-40 ring-2 ring-primary/40',
844
+ )}
739
845
  />
740
846
  }
741
847
  >
@@ -747,14 +853,14 @@ function SortableMediaThumbnail({
747
853
  muted
748
854
  playsInline
749
855
  preload="metadata"
750
- className="pointer-events-none size-full object-cover"
856
+ className="pointer-events-none size-full object-contain"
751
857
  />
752
858
  ) : previewUrl ? (
753
859
  <img
754
860
  src={previewUrl}
755
861
  alt={alt}
756
862
  draggable={false}
757
- className="pointer-events-none size-full object-cover"
863
+ className="pointer-events-none size-full object-contain"
758
864
  />
759
865
  ) : (
760
866
  <div className="flex size-full items-center justify-center text-muted-foreground">
@@ -766,8 +872,33 @@ function SortableMediaThumbnail({
766
872
  </div>
767
873
  )}
768
874
 
875
+ {/* Hover darkens the tile so its checkbox and grip stand out; a picked
876
+ tile is washed out instead, so the picked files read at a glance
877
+ without a ring around each one. */}
878
+ <span
879
+ aria-hidden
880
+ className={cn(
881
+ 'pointer-events-none absolute inset-0 transition-colors',
882
+ selected ? 'bg-card/50' : 'bg-black/0 group-hover:bg-black/50',
883
+ )}
884
+ />
885
+
886
+ <Checkbox
887
+ checked={selected}
888
+ onCheckedChange={onToggleSelect}
889
+ // Keep the click on the box from also opening the sheet.
890
+ onClick={(e) => e.stopPropagation()}
891
+ aria-label={t('admin.products.media.select_item', { name: alt })}
892
+ className={cn(
893
+ 'absolute top-2 left-2 z-10 bg-card transition-opacity',
894
+ !selecting &&
895
+ !selected &&
896
+ 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
897
+ )}
898
+ />
899
+
769
900
  {mediaType !== 'image' && (
770
- <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">
901
+ <span className="pointer-events-none absolute bottom-2 left-2 z-10 flex size-6 items-center justify-center rounded-full bg-black/60 text-white">
771
902
  <PlayIcon className="size-3 fill-current" />
772
903
  </span>
773
904
  )}
@@ -805,7 +936,7 @@ function SortableMediaThumbnail({
805
936
  <ContextMenuSeparator />
806
937
  <ContextMenuItem variant="destructive" onClick={onDelete}>
807
938
  <TrashIcon />
808
- {t('admin.actions.delete')}
939
+ {t('admin.actions.remove')}
809
940
  </ContextMenuItem>
810
941
  </ContextMenuContent>
811
942
  </ContextMenu>
@@ -869,13 +1000,13 @@ export function SEOCard({ form, product }: FormCardProps & { product?: Product }
869
1000
  </CardTitle>
870
1001
  </CardHeader>
871
1002
  <CardContent className="flex flex-col gap-4">
872
- <div className="rounded-lg border border-border p-4 space-y-1">
873
- <p className="text-sm font-medium text-blue-700 truncate">{previewTitle}</p>
1003
+ <Item variant="outline" className="flex-col items-stretch gap-1">
1004
+ <p className="text-base font-medium text-blue-700 truncate">{previewTitle}</p>
874
1005
  <p className="text-xs text-green-700 truncate">example.com/products/{previewSlug}</p>
875
1006
  {metaDescription && (
876
1007
  <p className="text-xs text-muted-foreground line-clamp-2">{metaDescription}</p>
877
1008
  )}
878
- </div>
1009
+ </Item>
879
1010
 
880
1011
  <Field>
881
1012
  <FieldLabel htmlFor="product-slug">{t('admin.fields.slug.label')}</FieldLabel>
@@ -1278,12 +1409,12 @@ export function TaxCard({ form }: FormCardProps) {
1278
1409
  )}
1279
1410
 
1280
1411
  {overrides.length > 0 && (
1281
- <div className="rounded-md border bg-muted/30 p-3 text-sm">
1412
+ <Item variant="outline" className="flex-col items-stretch gap-1">
1282
1413
  <p className="font-medium">{t('admin.products.tax.variant_overrides_heading')}</p>
1283
- <p className="text-muted-foreground mt-1">
1414
+ <p className="text-muted-foreground">
1284
1415
  {t('admin.products.tax.variant_overrides_description')}
1285
1416
  </p>
1286
- <ul className="mt-3 space-y-2">
1417
+ <ul className="mt-2 space-y-2">
1287
1418
  {overrides.map(({ variant, index }) => {
1288
1419
  const categoryName =
1289
1420
  taxCategories.find((c) => c.id === variant.tax_category_id)?.name ??
@@ -1309,7 +1440,7 @@ export function TaxCard({ form }: FormCardProps) {
1309
1440
  )
1310
1441
  })}
1311
1442
  </ul>
1312
- </div>
1443
+ </Item>
1313
1444
  )}
1314
1445
  </CardContent>
1315
1446
  </Card>
@@ -1,11 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
+ omitUnchangedListFields,
3
4
  prepareVariantsTaxForProductSave,
4
5
  variantHasTaxCategoryOverride,
5
6
  variantToFormValues,
6
7
  variantToWirePayload,
7
8
  } from './product-form-mapping'
8
- import type { VariantFormValues } from './product-schema'
9
+ import type { ProductFormValues, VariantFormValues } from './product-schema'
9
10
  import type { PanelVariant } from './product-types'
10
11
 
11
12
  // variantToFormValues reads a handful of fields; cast a minimal fixture rather
@@ -129,3 +130,43 @@ describe('variant tax category overrides', () => {
129
130
  expect(variantToWirePayload(prepared[0], 0).tax_category_id).toBeNull()
130
131
  })
131
132
  })
133
+
134
+ describe('omitUnchangedListFields', () => {
135
+ const payload = {
136
+ name: 'Linen shirt',
137
+ category_ids: ['ctg_1'],
138
+ collection_ids: ['coll_1'],
139
+ tags: ['summer'],
140
+ }
141
+
142
+ it('sends a list when its field changed', () => {
143
+ expect(omitUnchangedListFields(payload, { category_ids: true })).toEqual({
144
+ name: 'Linen shirt',
145
+ category_ids: ['ctg_1'],
146
+ })
147
+ expect(omitUnchangedListFields(payload, { tags: true, collection_ids: true })).toEqual({
148
+ name: 'Linen shirt',
149
+ collection_ids: ['coll_1'],
150
+ tags: ['summer'],
151
+ })
152
+ })
153
+
154
+ // React Hook Form marks an array field dirty per item when it compares item by item.
155
+ it('reads a per-item dirty flag', () => {
156
+ expect(omitUnchangedListFields(payload, { category_ids: [false, true] })).toEqual({
157
+ name: 'Linen shirt',
158
+ category_ids: ['ctg_1'],
159
+ })
160
+ expect(omitUnchangedListFields(payload, { category_ids: [false] })).toEqual({
161
+ name: 'Linen shirt',
162
+ })
163
+ })
164
+
165
+ // Another tab, a bulk action or an import may have added to a list since the
166
+ // page loaded; sending the loaded list back would remove those additions.
167
+ it('leaves the lists out when their fields did not change', () => {
168
+ const dirtyFields: Partial<Record<keyof ProductFormValues, boolean>> = { name: true }
169
+
170
+ expect(omitUnchangedListFields(payload, dirtyFields)).toEqual({ name: 'Linen shirt' })
171
+ })
172
+ })
@@ -110,6 +110,7 @@ export function mediaToFormValues(media: PanelMedia, index: number, formKey = 'p
110
110
  // on a video row resolves to its poster, so none of them work here.
111
111
  videoUrl: media.video_url,
112
112
  downloadUrl: media.download_url,
113
+ filename: media.filename ?? null,
113
114
  }
114
115
  }
115
116
 
@@ -195,6 +196,25 @@ export function withExtensionRowValues(
195
196
  }
196
197
  }
197
198
 
199
+ // The API replaces each of these lists with what a save sends.
200
+ const LIST_FIELDS = ['category_ids', 'collection_ids', 'tags'] as const
201
+
202
+ /**
203
+ * The save payload without the category, collection and tag lists the merchant
204
+ * left unchanged, so a save keeps what something else added since the page loaded.
205
+ */
206
+ export function omitUnchangedListFields(
207
+ payload: Record<string, unknown>,
208
+ dirtyFields: Partial<Record<(typeof LIST_FIELDS)[number], unknown>>,
209
+ ): Record<string, unknown> {
210
+ const trimmed = { ...payload }
211
+ for (const field of LIST_FIELDS) {
212
+ const dirty = dirtyFields[field]
213
+ if (!(Array.isArray(dirty) ? dirty.some(Boolean) : dirty)) delete trimmed[field]
214
+ }
215
+ return trimmed
216
+ }
217
+
198
218
  // Strip UI-only fields (stock_location_name) and undefined entries so the
199
219
  // PATCH body matches the Admin API VariantUpdateParams shape exactly. The
200
220
  // Spree::Product#variants= setter reconciles by id, creates new entries,
@@ -129,6 +129,8 @@ export const mediaFormSchema = z
129
129
  focal_point_y: z.number().min(0).max(1).nullable().optional(),
130
130
  // UI-only — strip at submit.
131
131
  previewUrl: z.string().optional(),
132
+ // The file's name, to tell rows apart where alt text is blank. UI-only.
133
+ filename: z.string().nullable().optional(),
132
134
  // Larger rendition for the edit sheet; previewUrl is grid-sized and
133
135
  // visibly pixelates when blown up. Absent before save, where the blob URL
134
136
  // in previewUrl is full resolution anyway.
@@ -225,6 +227,11 @@ export const productFormSchema = z.object({
225
227
  // new uploads ride the same product PATCH.
226
228
  media: z.array(mediaFormSchema).optional(),
227
229
 
230
+ // Saved media the merchant removed. The product save leaves omitted media
231
+ // alone, so these go through the media DELETE endpoint once the save
232
+ // succeeds — removal stays an unsaved change, undone by Discard.
233
+ removed_media_ids: z.array(z.string()).optional(),
234
+
228
235
  // Downloadable files, staged pre-save on the new product page and shipped in
229
236
  // the product POST. Edit uses the live API-driven card instead, so this stays
230
237
  // empty there.
@@ -274,6 +281,7 @@ export function newProductFormDefaults(): ProductFormValues {
274
281
  ],
275
282
  custom_fields: [],
276
283
  media: [],
284
+ removed_media_ids: [],
277
285
  digital_assets: [],
278
286
  product_publications: [],
279
287
  }
@@ -33,6 +33,8 @@ export interface PanelMedia {
33
33
  large_url?: string | null
34
34
  xlarge_url?: string | null
35
35
  download_url?: string | null
36
+ /** The file's own name; absent on a panel whose serializer omits it. */
37
+ filename?: string | null
36
38
  }
37
39
 
38
40
  export interface PanelVariant {