feat(product,dashboard): Allow re-ordering images (#10187)
* migration * fix snapshot * primarykey * init work on dnd * progress * dnd * undo changes * undo changes * undo changes * undo changes * fix firefox issue * lint * lint * lint * add changeset * undo changes to product module * set activator node * init work on service layer * alternative * switch to OneToMany * add tests * progress * update migration * update approach and remove all references to images in product.ts tests * handle delete images on empty array * fix config and order type * update changeset * rm flag * export type and fix type in test * fix type --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
co-authored by
Oli Juhl
parent
b12408dbd8
commit
1659c9be5d
+2
-2
@@ -7,13 +7,13 @@ import { Form } from "../../common/form"
|
||||
|
||||
type RouteModalFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{
|
||||
form: UseFormReturn<TFieldValues>
|
||||
blockSearch?: boolean
|
||||
blockSearchParams?: boolean
|
||||
onClose?: (isSubmitSuccessful: boolean) => void
|
||||
}>
|
||||
|
||||
export const RouteModalForm = <TFieldValues extends FieldValues = any>({
|
||||
form,
|
||||
blockSearch = false,
|
||||
blockSearchParams: blockSearch = false,
|
||||
children,
|
||||
onClose,
|
||||
}: RouteModalFormProps<TFieldValues>) => {
|
||||
|
||||
@@ -1578,6 +1578,12 @@
|
||||
"create": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"header": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1742,6 +1748,8 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"description",
|
||||
"header",
|
||||
"tabs",
|
||||
"errors",
|
||||
@@ -1990,6 +1998,9 @@
|
||||
"action"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"successToast": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2008,7 +2019,8 @@
|
||||
"galleryLabel",
|
||||
"downloadImageLabel",
|
||||
"deleteImageLabel",
|
||||
"emptyState"
|
||||
"emptyState",
|
||||
"successToast"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
@@ -386,6 +386,8 @@
|
||||
"successToast": "Produkz {{title}} angepasst."
|
||||
},
|
||||
"create": {
|
||||
"title": "Produkt erstellen",
|
||||
"description": "Erstellen Sie ein neues Produkt.",
|
||||
"header": "Allgemein",
|
||||
"tabs": {
|
||||
"details": "Details",
|
||||
@@ -490,7 +492,8 @@
|
||||
"header": "Noch keine Medien",
|
||||
"description": "Fügen Sie dem Produkt Medien hinzu, um es in Ihrem Schaufenster zu präsentieren.",
|
||||
"action": "Medien hinzufügen"
|
||||
}
|
||||
},
|
||||
"successToast": "Medien wurden erfolgreich aktualisiert."
|
||||
},
|
||||
"discountableHint": "Wenn diese Option deaktiviert ist, werden auf dieses Produkt keine Rabatte gewährt.",
|
||||
"noSalesChannels": "In keinem Vertriebskanal verfügbar",
|
||||
|
||||
@@ -386,6 +386,8 @@
|
||||
"successToast": "Product {{title}} was successfully updated."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Product",
|
||||
"description": "Create a new product.",
|
||||
"header": "General",
|
||||
"tabs": {
|
||||
"details": "Details",
|
||||
@@ -490,7 +492,8 @@
|
||||
"header": "No media yet",
|
||||
"description": "Add media to the product to showcase it in your storefront.",
|
||||
"action": "Add media"
|
||||
}
|
||||
},
|
||||
"successToast": "Media was successfully updated."
|
||||
},
|
||||
"discountableHint": "When unchecked, discounts will not be applied to this product.",
|
||||
"noSalesChannels": "Not available in any sales channels",
|
||||
|
||||
@@ -386,6 +386,8 @@
|
||||
"successToast": "Produkt {{title}} został pomyślnie zaktualizowany."
|
||||
},
|
||||
"create": {
|
||||
"title": "Utwórz produkt",
|
||||
"description": "Utwórz nowy produkt.",
|
||||
"header": "Ogólne",
|
||||
"tabs": {
|
||||
"details": "Szczegóły",
|
||||
@@ -490,7 +492,8 @@
|
||||
"header": "Brak mediów",
|
||||
"description": "Dodaj media do produktu, aby zaprezentować go w swoim sklepie.",
|
||||
"action": "Dodaj media"
|
||||
}
|
||||
},
|
||||
"successToast": "Media zostały pomyślnie zaktualizowane."
|
||||
},
|
||||
"discountableHint": "Jeśli odznaczone, rabaty nie będą stosowane do tego produktu.",
|
||||
"noSalesChannels": "Niedostępny w żadnych kanałach sprzedaży",
|
||||
@@ -2752,4 +2755,4 @@
|
||||
"seconds_one": "Drugi",
|
||||
"seconds_other": "Towary drugiej jakości"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,8 @@
|
||||
"successToast": "Ürün {{title}} başarıyla güncellendi."
|
||||
},
|
||||
"create": {
|
||||
"title": "Ürün Oluştur",
|
||||
"description": "Yeni bir ürün oluşturun.",
|
||||
"header": "Genel",
|
||||
"tabs": {
|
||||
"details": "Detaylar",
|
||||
@@ -490,7 +492,8 @@
|
||||
"header": "Henüz medya yok",
|
||||
"description": "Ürünü mağazanızda sergilemek için medya ekleyin.",
|
||||
"action": "Medya ekle"
|
||||
}
|
||||
},
|
||||
"successToast": "Medya başarıyla güncellendi."
|
||||
},
|
||||
"discountableHint": "İşaretlenmediğinde, bu ürüne indirim uygulanmayacaktır.",
|
||||
"noSalesChannels": "Hiçbir satış kanalında mevcut değil",
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./media-grid-view"
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { Tooltip, clx } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface MediaView {
|
||||
id?: string
|
||||
field_id: string
|
||||
url: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
interface MediaGridProps {
|
||||
media: MediaView[]
|
||||
selection: Record<string, boolean>
|
||||
onCheckedChange: (id: string) => (value: boolean) => void
|
||||
}
|
||||
|
||||
export const MediaGrid = ({
|
||||
media,
|
||||
selection,
|
||||
onCheckedChange,
|
||||
}: MediaGridProps) => {
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle size-full overflow-auto">
|
||||
<div className="grid h-fit auto-rows-auto grid-cols-4 gap-6 p-6">
|
||||
{media.map((m) => {
|
||||
return (
|
||||
<MediaGridItem
|
||||
onCheckedChange={onCheckedChange(m.id!)}
|
||||
checked={!!selection[m.id!]}
|
||||
key={m.field_id}
|
||||
media={m}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MediaGridItemProps {
|
||||
media: MediaView
|
||||
checked: boolean
|
||||
onCheckedChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
const MediaGridItem = ({
|
||||
media,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: MediaGridItemProps) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onCheckedChange(!checked)
|
||||
}, [checked, onCheckedChange])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full overflow-hidden rounded-lg outline-none"
|
||||
>
|
||||
{media.isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg absolute right-2 top-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100",
|
||||
{
|
||||
"opacity-100": checked,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"group relative inline-flex h-4 w-4 items-center justify-center outline-none "
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"text-ui-fg-on-inverted bg-ui-bg-component shadow-borders-base [&_path]:shadow-details-contrast-on-bg-interactive group-disabled:text-ui-fg-disabled group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base transition-fg h-[14px] w-[14px] rounded-[3px]",
|
||||
{
|
||||
"bg-ui-bg-interactive group-hover:bg-ui-bg-interactive shadow-borders-interactive-with-shadow":
|
||||
checked,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<div className="absolute inset-0">
|
||||
<CheckMini />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.5 } }}
|
||||
className="bg-ui-bg-subtle-hover absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Spinner className="text-ui-fg-subtle animate-spin" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<img
|
||||
src={media.url}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
+33
-25
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from "react"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
@@ -45,25 +46,40 @@ export const UploadMediaFormItem = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const hasInvalidFiles = (fileList: FileType[]) => {
|
||||
const invalidFile = fileList.find(
|
||||
(f) => !SUPPORTED_FORMATS.includes(f.file.type)
|
||||
)
|
||||
const hasInvalidFiles = useCallback(
|
||||
(fileList: FileType[]) => {
|
||||
const invalidFile = fileList.find(
|
||||
(f) => !SUPPORTED_FORMATS.includes(f.file.type)
|
||||
)
|
||||
|
||||
if (invalidFile) {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.invalidFileType", {
|
||||
name: invalidFile.file.name,
|
||||
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
|
||||
}),
|
||||
})
|
||||
if (invalidFile) {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.invalidFileType", {
|
||||
name: invalidFile.file.name,
|
||||
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
|
||||
}),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
return false
|
||||
},
|
||||
[form, t]
|
||||
)
|
||||
|
||||
const onUploaded = useCallback(
|
||||
(files: FileType[]) => {
|
||||
form.clearErrors("media")
|
||||
if (hasInvalidFiles(files)) {
|
||||
return
|
||||
}
|
||||
|
||||
files.forEach((f) => append({ ...f, isThumbnail: false }))
|
||||
},
|
||||
[form, append, hasInvalidFiles]
|
||||
)
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
@@ -87,15 +103,7 @@ export const UploadMediaFormItem = ({
|
||||
hint={t("products.media.uploadImagesHint")}
|
||||
hasError={!!form.formState.errors.media}
|
||||
formats={SUPPORTED_FORMATS}
|
||||
onUploaded={(files) => {
|
||||
form.clearErrors("media")
|
||||
if (hasInvalidFiles(files)) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: For now all files that get uploaded are not thumbnails, revisit this logic
|
||||
files.forEach((f) => append({ ...f, isThumbnail: false }))
|
||||
}}
|
||||
onUploaded={onUploaded}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
|
||||
+199
-46
@@ -1,7 +1,33 @@
|
||||
import { StackPerspective, ThumbnailBadge, Trash, XMark } from "@medusajs/icons"
|
||||
import {
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
DotsSix,
|
||||
StackPerspective,
|
||||
ThumbnailBadge,
|
||||
Trash,
|
||||
XMark,
|
||||
} from "@medusajs/icons"
|
||||
import { IconButton, Text } from "@medusajs/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
import { UseFormReturn, useFieldArray } from "react-hook-form"
|
||||
import { useState } from "react"
|
||||
import { useFieldArray, UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../../../components/common/action-menu"
|
||||
import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item"
|
||||
@@ -11,6 +37,16 @@ type ProductCreateMediaSectionProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const ProductCreateMediaSection = ({
|
||||
form,
|
||||
}: ProductCreateMediaSectionProps) => {
|
||||
@@ -20,6 +56,38 @@ export const ProductCreateMediaSection = ({
|
||||
keyName: "field_id",
|
||||
})
|
||||
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null)
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = fields.findIndex((item) => item.field_id === active.id)
|
||||
const newIndex = fields.findIndex((item) => item.field_id === over?.id)
|
||||
|
||||
form.setValue("media", arrayMove(fields, oldIndex, newIndex), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
const getOnDelete = (index: number) => {
|
||||
return () => {
|
||||
remove(index)
|
||||
@@ -52,20 +120,36 @@ export const ProductCreateMediaSection = ({
|
||||
return (
|
||||
<div id="media" className="flex flex-col gap-y-2">
|
||||
<UploadMediaFormItem form={form} append={append} showHint={false} />
|
||||
<ul className="flex flex-col gap-y-2">
|
||||
{fields.map((field, index) => {
|
||||
const { onDelete, onMakeThumbnail } = getItemHandlers(index)
|
||||
|
||||
return (
|
||||
<MediaItem
|
||||
key={field.id}
|
||||
field={field}
|
||||
onDelete={onDelete}
|
||||
onMakeThumbnail={onMakeThumbnail}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<DragOverlay dropAnimation={dropAnimationConfig}>
|
||||
{activeId ? (
|
||||
<MediaGridItemOverlay
|
||||
field={fields.find((m) => m.field_id === activeId)!}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
<ul className="flex flex-col gap-y-2">
|
||||
<SortableContext items={fields.map((field) => field.field_id)}>
|
||||
{fields.map((field, index) => {
|
||||
const { onDelete, onMakeThumbnail } = getItemHandlers(index)
|
||||
|
||||
return (
|
||||
<MediaItem
|
||||
key={field.field_id}
|
||||
field={field}
|
||||
onDelete={onDelete}
|
||||
onMakeThumbnail={onMakeThumbnail}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SortableContext>
|
||||
</ul>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -87,25 +171,62 @@ type MediaItemProps = {
|
||||
const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field.field_id })
|
||||
|
||||
const style = {
|
||||
opacity: isDragging ? 0.4 : undefined,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
if (!field.file) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="bg-ui-bg-component shadow-elevation-card-rest flex items-center justify-between rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="bg-ui-bg-base h-10 w-[30px] overflow-hidden rounded-md">
|
||||
<ThumbnailPreview file={field.file} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Text size="small" leading="compact">
|
||||
{field.file.name}
|
||||
</Text>
|
||||
<div className="flex items-center gap-x-1">
|
||||
{field.isThumbnail && <ThumbnailBadge />}
|
||||
<Text size="xsmall" leading="compact" className="text-ui-fg-subtle">
|
||||
{formatFileSize(field.file.size)}
|
||||
<li
|
||||
className="bg-ui-bg-component shadow-elevation-card-rest flex items-center justify-between rounded-lg px-3 py-2"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
type="button"
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={setActivatorNodeRef}
|
||||
className="cursor-grab touch-none active:cursor-grabbing"
|
||||
>
|
||||
<DotsSix className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="bg-ui-bg-base h-10 w-[30px] overflow-hidden rounded-md">
|
||||
<ThumbnailPreview url={field.url} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Text size="small" leading="compact">
|
||||
{field.file.name}
|
||||
</Text>
|
||||
<div className="flex items-center gap-x-1">
|
||||
{field.isThumbnail && <ThumbnailBadge />}
|
||||
<Text
|
||||
size="xsmall"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{formatFileSize(field.file.size)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,28 +266,60 @@ const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const ThumbnailPreview = ({ file }: { file?: File | null }) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null)
|
||||
const MediaGridItemOverlay = ({ field }: { field: MediaField }) => {
|
||||
return (
|
||||
<li className="bg-ui-bg-component shadow-elevation-card-rest flex items-center justify-between rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
size="small"
|
||||
className="cursor-grab touch-none active:cursor-grabbing"
|
||||
>
|
||||
<DotsSix className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="bg-ui-bg-base h-10 w-[30px] overflow-hidden rounded-md">
|
||||
<ThumbnailPreview url={field.url} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Text size="small" leading="compact">
|
||||
{field.file?.name}
|
||||
</Text>
|
||||
<div className="flex items-center gap-x-1">
|
||||
{field.isThumbnail && <ThumbnailBadge />}
|
||||
<Text
|
||||
size="xsmall"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{formatFileSize(field.file?.size ?? 0)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<ActionMenu groups={[]} />
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="transparent"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<XMark />
|
||||
</IconButton>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
setThumbnailUrl(objectUrl)
|
||||
|
||||
return () => URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [file])
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
const ThumbnailPreview = ({ url }: { url?: string | null }) => {
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
/>
|
||||
<img src={url} alt="" className="size-full object-cover object-center" />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+154
-158
@@ -14,7 +14,6 @@ import {
|
||||
} from "../../../../../extensions"
|
||||
import { useCreateProduct } from "../../../../../hooks/api/products"
|
||||
import { sdk } from "../../../../../lib/client"
|
||||
import { isFetchError } from "../../../../../lib/is-fetch-error"
|
||||
import {
|
||||
PRODUCT_CREATE_FORM_DEFAULTS,
|
||||
ProductCreateSchema,
|
||||
@@ -80,13 +79,10 @@ export const ProductCreateForm = ({
|
||||
return {}
|
||||
}
|
||||
|
||||
return regions.reduce(
|
||||
(acc, reg) => {
|
||||
acc[reg.id] = reg.currency_code
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
return regions.reduce((acc, reg) => {
|
||||
acc[reg.id] = reg.currency_code
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
}, [regions])
|
||||
|
||||
/**
|
||||
@@ -140,32 +136,34 @@ export const ProductCreateForm = ({
|
||||
|
||||
uploadedMedia = (await Promise.all(fileReqs)).flat()
|
||||
}
|
||||
|
||||
const { product } = await mutateAsync(
|
||||
normalizeProductFormValues({
|
||||
...payload,
|
||||
media: uploadedMedia,
|
||||
status: (isDraftSubmission ? "draft" : "published") as any,
|
||||
regionsCurrencyMap,
|
||||
})
|
||||
)
|
||||
|
||||
toast.success(
|
||||
t("products.create.successToast", {
|
||||
title: product.title,
|
||||
})
|
||||
)
|
||||
|
||||
handleSuccess(`../${product.id}`)
|
||||
} catch (error) {
|
||||
if (isFetchError(error) && error.status === 400) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message)
|
||||
} else {
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await mutateAsync(
|
||||
normalizeProductFormValues({
|
||||
...payload,
|
||||
media: uploadedMedia,
|
||||
status: (isDraftSubmission ? "draft" : "published") as any,
|
||||
regionsCurrencyMap,
|
||||
}),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
t("products.create.successToast", {
|
||||
title: data.product.title,
|
||||
})
|
||||
)
|
||||
|
||||
handleSuccess(`../${data.product.id}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const onNext = async (currentTab: Tab) => {
|
||||
@@ -210,143 +208,141 @@ export const ProductCreateForm = ({
|
||||
}
|
||||
|
||||
setTabState({ ...currentState })
|
||||
}, [tab])
|
||||
}, [tab, tabState])
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<KeyboundForm
|
||||
onKeyDown={(e) => {
|
||||
// We want to continue to the next tab on enter instead of saving as draft immediately
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<KeyboundForm
|
||||
onKeyDown={(e) => {
|
||||
// We want to continue to the next tab on enter instead of saving as draft immediately
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (tab !== Tab.VARIANTS) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onNext(tab)
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (tab !== Tab.VARIANTS) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onNext(tab)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<ProgressTabs
|
||||
value={tab}
|
||||
onValueChange={async (tab) => {
|
||||
const valid = await form.trigger()
|
||||
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
setTab(tab as Tab)
|
||||
}}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="-my-2 w-full border-l">
|
||||
<ProgressTabs.List className="justify-start-start flex w-full items-center">
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.DETAILS]}
|
||||
value={Tab.DETAILS}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{t("products.create.tabs.details")}
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.ORGANIZE]}
|
||||
value={Tab.ORGANIZE}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{t("products.create.tabs.organize")}
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.VARIANTS]}
|
||||
value={Tab.VARIANTS}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{t("products.create.tabs.variants")}
|
||||
</ProgressTabs.Trigger>
|
||||
{showInventoryTab && (
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.INVENTORY]}
|
||||
value={Tab.INVENTORY}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{t("products.create.tabs.inventory")}
|
||||
</ProgressTabs.Trigger>
|
||||
)}
|
||||
</ProgressTabs.List>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.DETAILS}
|
||||
>
|
||||
<ProductCreateDetailsForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.ORGANIZE}
|
||||
>
|
||||
<ProductCreateOrganizeForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.VARIANTS}
|
||||
>
|
||||
<ProductCreateVariantsForm
|
||||
form={form}
|
||||
store={store}
|
||||
regions={regions}
|
||||
pricePreferences={pricePreferences}
|
||||
/>
|
||||
</ProgressTabs.Content>
|
||||
{showInventoryTab && (
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.INVENTORY}
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<ProgressTabs
|
||||
value={tab}
|
||||
onValueChange={async (tab) => {
|
||||
const valid = await form.trigger()
|
||||
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
setTab(tab as Tab)
|
||||
}}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="-my-2 w-full border-l">
|
||||
<ProgressTabs.List className="justify-start-start flex w-full items-center">
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.DETAILS]}
|
||||
value={Tab.DETAILS}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
<ProductCreateInventoryKitForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
)}
|
||||
</RouteFocusModal.Body>
|
||||
</ProgressTabs>
|
||||
<RouteFocusModal.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button
|
||||
data-name={SAVE_DRAFT_BUTTON}
|
||||
size="small"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("actions.saveAsDraft")}
|
||||
</Button>
|
||||
<PrimaryButton
|
||||
tab={tab}
|
||||
next={onNext}
|
||||
isLoading={isPending}
|
||||
showInventoryTab={showInventoryTab}
|
||||
/>
|
||||
{t("products.create.tabs.details")}
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.ORGANIZE]}
|
||||
value={Tab.ORGANIZE}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{t("products.create.tabs.organize")}
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.VARIANTS]}
|
||||
value={Tab.VARIANTS}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{t("products.create.tabs.variants")}
|
||||
</ProgressTabs.Trigger>
|
||||
{showInventoryTab && (
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState[Tab.INVENTORY]}
|
||||
value={Tab.INVENTORY}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{t("products.create.tabs.inventory")}
|
||||
</ProgressTabs.Trigger>
|
||||
)}
|
||||
</ProgressTabs.List>
|
||||
</div>
|
||||
</RouteFocusModal.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteFocusModal.Form>
|
||||
</RouteFocusModal>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.DETAILS}
|
||||
>
|
||||
<ProductCreateDetailsForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.ORGANIZE}
|
||||
>
|
||||
<ProductCreateOrganizeForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.VARIANTS}
|
||||
>
|
||||
<ProductCreateVariantsForm
|
||||
form={form}
|
||||
store={store}
|
||||
regions={regions}
|
||||
pricePreferences={pricePreferences}
|
||||
/>
|
||||
</ProgressTabs.Content>
|
||||
{showInventoryTab && (
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.INVENTORY}
|
||||
>
|
||||
<ProductCreateInventoryKitForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
)}
|
||||
</RouteFocusModal.Body>
|
||||
</ProgressTabs>
|
||||
<RouteFocusModal.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button
|
||||
data-name={SAVE_DRAFT_BUTTON}
|
||||
size="small"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("actions.saveAsDraft")}
|
||||
</Button>
|
||||
<PrimaryButton
|
||||
tab={tab}
|
||||
next={onNext}
|
||||
isLoading={isPending}
|
||||
showInventoryTab={showInventoryTab}
|
||||
/>
|
||||
</div>
|
||||
</RouteFocusModal.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RouteFocusModal } from "../../../components/modals"
|
||||
import { useRegions } from "../../../hooks/api"
|
||||
import { usePricePreferences } from "../../../hooks/api/price-preferences"
|
||||
@@ -6,13 +7,15 @@ import { useStore } from "../../../hooks/api/store"
|
||||
import { ProductCreateForm } from "./components/product-create-form/product-create-form"
|
||||
|
||||
export const ProductCreate = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
store,
|
||||
isPending: isStorePending,
|
||||
isError: isStoreError,
|
||||
error: storeError,
|
||||
} = useStore({
|
||||
fields: "default_sales_channel",
|
||||
fields: "+default_sales_channel",
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -68,6 +71,12 @@ export const ProductCreate = () => {
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<RouteFocusModal.Title asChild>
|
||||
<span className="sr-only">{t("products.create.title")}</span>
|
||||
</RouteFocusModal.Title>
|
||||
<RouteFocusModal.Description asChild>
|
||||
<span className="sr-only">{t("products.create.description")}</span>
|
||||
</RouteFocusModal.Description>
|
||||
{ready && (
|
||||
<ProductCreateForm
|
||||
defaultChannel={sales_channel}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const normalizeProductFormValues = (
|
||||
status: HttpTypes.AdminProductStatus
|
||||
regionsCurrencyMap: Record<string, string>
|
||||
}
|
||||
) => {
|
||||
): HttpTypes.AdminCreateProduct => {
|
||||
const thumbnail = values.media?.find((media) => media.isThumbnail)?.url
|
||||
const images = values.media
|
||||
?.filter((media) => !media.isThumbnail)
|
||||
@@ -51,7 +51,7 @@ export const normalizeProductFormValues = (
|
||||
export const normalizeVariants = (
|
||||
variants: ProductCreateSchemaType["variants"],
|
||||
regionsCurrencyMap: Record<string, string>
|
||||
) => {
|
||||
): HttpTypes.AdminCreateProductVariant[] => {
|
||||
return variants.map((variant) => ({
|
||||
title: variant.title || Object.values(variant.options || {}).join(" / "),
|
||||
options: variant.options,
|
||||
@@ -60,7 +60,9 @@ export const normalizeVariants = (
|
||||
allow_backorder: !!variant.allow_backorder,
|
||||
inventory_items: variant
|
||||
.inventory!.map((i) => {
|
||||
const quantity = castNumber(i.required_quantity)
|
||||
const quantity = i.required_quantity
|
||||
? castNumber(i.required_quantity)
|
||||
: null
|
||||
|
||||
if (!i.inventory_item_id || !quantity) {
|
||||
return false
|
||||
@@ -71,7 +73,12 @@ export const normalizeVariants = (
|
||||
required_quantity: quantity,
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
.filter(
|
||||
(
|
||||
item
|
||||
): item is { required_quantity: number; inventory_item_id: string } =>
|
||||
item !== false
|
||||
),
|
||||
prices: Object.entries(variant.prices || {})
|
||||
.map(([key, value]: any) => {
|
||||
if (value === "" || value === undefined) {
|
||||
|
||||
+233
-14
@@ -1,12 +1,34 @@
|
||||
import {
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Button, CommandBar } from "@medusajs/ui"
|
||||
import { ThumbnailBadge } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, Checkbox, clx, CommandBar, toast, Tooltip } from "@medusajs/ui"
|
||||
import { Fragment, useCallback, useState } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { z } from "zod"
|
||||
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
@@ -14,7 +36,6 @@ import {
|
||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { sdk } from "../../../../../lib/client"
|
||||
import { MediaGrid } from "../../../common/components/media-grid-view"
|
||||
import { UploadMediaFormItem } from "../../../common/components/upload-media-form-item"
|
||||
import {
|
||||
EditProductMediaSchema,
|
||||
@@ -46,6 +67,38 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
keyName: "field_id",
|
||||
})
|
||||
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null)
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = fields.findIndex((item) => item.field_id === active.id)
|
||||
const newIndex = fields.findIndex((item) => item.field_id === over?.id)
|
||||
|
||||
form.setValue("media", arrayMove(fields, oldIndex, newIndex), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
const { mutateAsync, isPending } = useUpdateProduct(product.id!)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ media }) => {
|
||||
@@ -80,13 +133,16 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
images: withUpdatedUrls.map((file) => ({ url: file.url })),
|
||||
// Set thumbnail to empty string if no thumbnail is selected, as the API does not accept null
|
||||
thumbnail: thumbnail || "",
|
||||
thumbnail: thumbnail,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("products.media.successToast"))
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -142,7 +198,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
const selectionCount = Object.keys(selection).length
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form blockSearch form={form}>
|
||||
<RouteFocusModal.Form blockSearchParams form={form}>
|
||||
<KeyboundForm
|
||||
className="flex size-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
@@ -158,11 +214,44 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||
<div className="flex size-full flex-col-reverse lg:grid lg:grid-cols-[1fr_560px]">
|
||||
<MediaGrid
|
||||
media={fields}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
selection={selection}
|
||||
/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="bg-ui-bg-subtle size-full overflow-auto">
|
||||
<div className="grid h-fit auto-rows-auto grid-cols-4 gap-6 p-6">
|
||||
<SortableContext
|
||||
items={fields.map((m) => m.field_id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{fields.map((m) => {
|
||||
return (
|
||||
<MediaGridItem
|
||||
onCheckedChange={handleCheckedChange(m.id!)}
|
||||
checked={!!selection[m.id!]}
|
||||
key={m.field_id}
|
||||
media={m}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={dropAnimationConfig}>
|
||||
{activeId ? (
|
||||
<MediaGridItemOverlay
|
||||
media={fields.find((m) => m.field_id === activeId)!}
|
||||
checked={
|
||||
!!selection[
|
||||
fields.find((m) => m.field_id === activeId)!.id!
|
||||
]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
<div className="bg-ui-bg-base overflow-auto border-b px-6 py-4 lg:border-b-0 lg:border-l">
|
||||
<UploadMediaFormItem form={form} append={append} />
|
||||
</div>
|
||||
@@ -211,8 +300,8 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
}
|
||||
|
||||
const getDefaultValues = (
|
||||
images: HttpTypes.AdminProductImage[] | undefined,
|
||||
thumbnail: string | undefined
|
||||
images: HttpTypes.AdminProductImage[] | null | undefined,
|
||||
thumbnail: string | null | undefined
|
||||
) => {
|
||||
const media: Media[] =
|
||||
images?.map((image) => ({
|
||||
@@ -235,3 +324,133 @@ const getDefaultValues = (
|
||||
|
||||
return media
|
||||
}
|
||||
|
||||
interface MediaView {
|
||||
id?: string
|
||||
field_id: string
|
||||
url: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
interface MediaGridItemProps {
|
||||
media: MediaView
|
||||
checked: boolean
|
||||
onCheckedChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
const MediaGridItem = ({
|
||||
media,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: MediaGridItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
onCheckedChange(value)
|
||||
},
|
||||
[onCheckedChange]
|
||||
)
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: media.field_id })
|
||||
|
||||
const style = {
|
||||
opacity: isDragging ? 0.4 : undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full overflow-hidden rounded-lg outline-none"
|
||||
)}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{media.isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clx("absolute inset-0 cursor-grab touch-none outline-none", {
|
||||
"cursor-grabbing": isDragging,
|
||||
})}
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
<div
|
||||
className={clx("transition-fg absolute right-2 top-2 opacity-0", {
|
||||
"group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100":
|
||||
!isDragging && !checked,
|
||||
"opacity-100": checked,
|
||||
})}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={media.url}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MediaGridItemOverlay = ({
|
||||
media,
|
||||
checked,
|
||||
}: {
|
||||
media: MediaView
|
||||
checked: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className="shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full cursor-grabbing overflow-hidden rounded-lg outline-none">
|
||||
{media.isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<ThumbnailBadge />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clx("transition-fg absolute right-2 top-2 opacity-0", {
|
||||
"opacity-100": checked,
|
||||
})}
|
||||
>
|
||||
<Checkbox checked={checked} />
|
||||
</div>
|
||||
<img
|
||||
src={media.url}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+14
-13
@@ -24,39 +24,39 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const { mutateAsync, isLoading } = useUpdateProduct(product.id)
|
||||
const { mutateAsync, isPending } = useUpdateProduct(product.id)
|
||||
|
||||
const media = getMedia(product.images, product.thumbnail)
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurr((prev) => (prev + 1) % media.length)
|
||||
}, [media, isLoading])
|
||||
}, [media, isPending])
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurr((prev) => (prev - 1 + media.length) % media.length)
|
||||
}, [media, isLoading])
|
||||
}, [media, isPending])
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurr(index)
|
||||
},
|
||||
[isLoading]
|
||||
[isPending]
|
||||
)
|
||||
|
||||
const handleDownloadCurrent = () => {
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,9 +87,10 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
const mediaToKeep = product.images
|
||||
.filter((i) => i.id !== current.id)
|
||||
.map((i) => ({ url: i.url }))
|
||||
const mediaToKeep =
|
||||
product.images
|
||||
?.filter((i) => i.id !== current.id)
|
||||
.map((i) => ({ url: i.url })) || []
|
||||
|
||||
if (curr === media.length - 1) {
|
||||
setCurr((prev) => prev - 1)
|
||||
@@ -195,7 +196,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => {
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle relative size-full overflow-hidden">
|
||||
<div className="flex size-full items-center justify-center p-6">
|
||||
<div className="relative h-full w-fit">
|
||||
<div className="relative inline-block max-h-full max-w-full">
|
||||
{media[curr].isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
@@ -206,7 +207,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => {
|
||||
<img
|
||||
src={media[curr].url}
|
||||
alt=""
|
||||
className="object-fit shadow-elevation-card-rest size-full rounded-xl object-contain"
|
||||
className="object-fit shadow-elevation-card-rest max-h-[calc(100vh-200px)] w-auto rounded-xl object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/modals"
|
||||
import { useProduct } from "../../../hooks/api/products"
|
||||
import { ProductMediaView } from "./components/product-media-view"
|
||||
|
||||
export const ProductMedia = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
|
||||
const { product, isLoading, isError, error } = useProduct(id!)
|
||||
@@ -16,6 +18,12 @@ export const ProductMedia = () => {
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<RouteFocusModal.Title asChild>
|
||||
<span className="sr-only">{t("products.media.label")}</span>
|
||||
</RouteFocusModal.Title>
|
||||
<RouteFocusModal.Description asChild>
|
||||
<span className="sr-only">{t("products.media.editHint")}</span>
|
||||
</RouteFocusModal.Description>
|
||||
{ready && <ProductMediaView product={product} />}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user