@spree/dashboard-core 6.0.3 → 6.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vite/index.js +81 -5
- package/package.json +19 -19
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +1 -1
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +189 -323
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +19 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +120 -14
- package/src/locales/de.json +120 -14
- package/src/locales/en.json +120 -14
- package/src/locales/es.json +120 -14
- package/src/locales/fr.json +120 -14
- package/src/locales/ja.json +120 -14
- package/src/locales/pl.json +120 -14
- package/src/locales/vi.json +120 -14
- package/src/locales/zh-CN.json +120 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +201 -70
- package/src/products/product-form-mapping.ts +1 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -17,7 +17,8 @@ import { type CurrencyCode, randomUUID } from '@spree/core'
|
|
|
17
17
|
import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
|
|
18
18
|
import {
|
|
19
19
|
getApiClient,
|
|
20
|
-
|
|
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-
|
|
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
|
|
|
@@ -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)
|
|
@@ -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
|
-
|
|
488
|
-
|
|
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
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
<
|
|
735
|
+
<MediaPickerDialog<Media>
|
|
659
736
|
open={pickingFromLibrary}
|
|
660
737
|
onOpenChange={setPickingFromLibrary}
|
|
661
738
|
queryKey="product-media-library"
|
|
662
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
837
|
+
if (selecting) onToggleSelect()
|
|
838
|
+
else onEdit()
|
|
734
839
|
}
|
|
735
840
|
}}
|
|
736
|
-
className={
|
|
737
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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.
|
|
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
|
-
<
|
|
873
|
-
<p className="text-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
|
1414
|
+
<p className="text-muted-foreground">
|
|
1284
1415
|
{t('admin.products.tax.variant_overrides_description')}
|
|
1285
1416
|
</p>
|
|
1286
|
-
<ul className="mt-
|
|
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
|
-
</
|
|
1443
|
+
</Item>
|
|
1313
1444
|
)}
|
|
1314
1445
|
</CardContent>
|
|
1315
1446
|
</Card>
|
|
@@ -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
|
|
|
@@ -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 {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { useCallback } from 'react'
|
|
2
3
|
import type {
|
|
3
4
|
PanelCollection,
|
|
4
5
|
PanelDeliveryProfile,
|
|
@@ -162,9 +163,35 @@ export function useFormCartonPackageTypes() {
|
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
166
|
+
* Deletes the media a product form marked for removal, after the product
|
|
167
|
+
* itself saved. Resolves to how many deletes failed, so the form can say so.
|
|
168
|
+
*/
|
|
169
|
+
export function useCommitRemovedProductMedia(productId: string) {
|
|
170
|
+
const client = getApiClient()
|
|
171
|
+
const queryClient = useQueryClient()
|
|
172
|
+
const mediaKey = useResourceKey('products', productId, 'media')
|
|
173
|
+
|
|
174
|
+
return useCallback(
|
|
175
|
+
async (mediaIds: string[] | undefined) => {
|
|
176
|
+
// Independent deletes, so all at once: a bulk removal of twenty files
|
|
177
|
+
// waits on one round trip rather than twenty.
|
|
178
|
+
const results = await Promise.allSettled(
|
|
179
|
+
(mediaIds ?? []).map((mediaId) => client.deleteProductMedia?.(productId, mediaId)),
|
|
180
|
+
)
|
|
181
|
+
const failed = results.filter((result) => result.status === 'rejected').length
|
|
182
|
+
if (mediaIds?.length) await queryClient.invalidateQueries({ queryKey: mediaKey })
|
|
183
|
+
return failed
|
|
184
|
+
},
|
|
185
|
+
[client, productId, queryClient, mediaKey],
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Removing a file already on the product, straight away.
|
|
191
|
+
*
|
|
192
|
+
* @deprecated The product form now removes files on save — mark them in
|
|
193
|
+
* `removed_media_ids` and commit with `useCommitRemovedProductMedia`. Kept
|
|
194
|
+
* until the next minor release.
|
|
168
195
|
*/
|
|
169
196
|
export function useFormDeleteProductMedia(productId: string) {
|
|
170
197
|
const client = getApiClient()
|
|
@@ -315,7 +315,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
|
|
|
315
315
|
|
|
316
316
|
{cartonTypesSupported && (
|
|
317
317
|
<FormSection title={t('admin.products.variants.sheet.packing')}>
|
|
318
|
-
<p className="text-
|
|
318
|
+
<p className="text-base text-muted-foreground">
|
|
319
319
|
{t('admin.products.variants.sheet.packing_help')}
|
|
320
320
|
</p>
|
|
321
321
|
{knownEmpty && (
|
|
@@ -447,7 +447,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
|
|
|
447
447
|
)}
|
|
448
448
|
|
|
449
449
|
<FormSection title={t('admin.products.variants.sheet.customs')}>
|
|
450
|
-
<p className="text-
|
|
450
|
+
<p className="text-base text-muted-foreground">
|
|
451
451
|
{t('admin.products.variants.sheet.customs_help')}
|
|
452
452
|
</p>
|
|
453
453
|
<div className="grid grid-cols-2 gap-3">
|
|
@@ -503,7 +503,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
|
|
|
503
503
|
</FormSection>
|
|
504
504
|
|
|
505
505
|
<FormSection title={t('admin.products.variants.sheet.ordering')}>
|
|
506
|
-
<p className="text-
|
|
506
|
+
<p className="text-base text-muted-foreground">
|
|
507
507
|
{t('admin.products.variants.sheet.ordering_help')}
|
|
508
508
|
</p>
|
|
509
509
|
<div className="grid grid-cols-2 gap-3">
|