feat(core-flows,product,types): scoped variant images (#13623)
* wip(product): variant images * fix: return type * wip: repo and list approach * fix: redo repo method, make test pass * fix: change getVariantImages impl * feat: update test * feat: API and core flows layer * wip: integration spec * fix: deterministic test * chore: refactor and simplify, cleanup, remove repo method * wip: batch add all images to all vairants * fix: remove, expand testing * refactor: pass variants instead of refetch * chore: expand integration test * feat: test multi assign route * fix: remove `/admin/products/:id/variants/images` route * feat: batch images to variant endpoint * fix: length assertion * feat: variant thumbnail * fix: send variant thumbnail by default * fix: product export test assertion * fix: test * feat: variant thumbnail on line item * fix: add missing list and count method, update types * feat: optimise variant images lookups * feat: thumbnail management in core flows * fix: typos, type, build * feat: cascade delete to pivot table, rm unused unused fields * feat(dashboard): variant images management UI (#13670) * wip(dashboard): setup variant media form * wip: cleanup table and images, wip check handler * feat: proper sidebar functionallity * fefat: add js-sdk and hooks * feat: allow only one selection * wip: lazy load variants in the table * feat: new variants management for images on product details * chore: refactor * wip: variant details page work * fix: cleanup media section, fix issues and types * feat: correct scoped images, cleanup in edit modal * feat: js sdk and hooks, filter out product images on variant details, labels, add API call and wrap UI * chore: cleanup * refacto: rename route * feat: thumbnail functionallity * fix: refresh checked after revalidation load * fix: rm unused, refactor type * Create thirty-clocks-refuse.md * feat: new add remove variant media layout * feat: new image add UX --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> * fix: table name in migration * chore: update changesets --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
@@ -124,6 +124,13 @@ export function getRouteMap({
|
||||
lazy: () =>
|
||||
import("../../routes/products/product-media"),
|
||||
},
|
||||
{
|
||||
path: "images/:image_id/variants",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/products/product-image-variants-edit"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "prices",
|
||||
lazy: () =>
|
||||
@@ -198,6 +205,13 @@ export function getRouteMap({
|
||||
"../../routes/product-variants/product-variant-manage-inventory-items"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "media",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/product-variants/product-variant-media"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
|
||||
@@ -419,3 +419,57 @@ export const useConfirmImportProducts = (
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useBatchImageVariants = (
|
||||
productId: string,
|
||||
imageId: string,
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminBatchImageVariantResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminBatchImageVariantRequest
|
||||
>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) =>
|
||||
sdk.admin.product.batchImageVariants(productId, imageId, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: productsQueryKeys.detail(productId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.details() })
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useBatchVariantImages = (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminBatchVariantImagesResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminBatchVariantImagesRequest
|
||||
>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) =>
|
||||
sdk.admin.product.batchVariantImages(productId, variantId, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: productsQueryKeys.detail(productId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: variantsQueryKeys.list({ productId }),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: variantsQueryKeys.detail(variantId),
|
||||
})
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { useQueryParams } from "../../use-query-params"
|
||||
|
||||
type UseProductTagTableQueryProps = {
|
||||
prefix?: string
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const useProductVariantTableQuery = ({
|
||||
prefix,
|
||||
pageSize = 20,
|
||||
}: UseProductTagTableQueryProps) => {
|
||||
const queryObject = useQueryParams(
|
||||
["offset", "q", "order", "created_at", "updated_at"],
|
||||
prefix
|
||||
)
|
||||
|
||||
const { offset, q, order, created_at, updated_at } = queryObject
|
||||
const searchParams: HttpTypes.AdminProductTagListParams = {
|
||||
limit: pageSize,
|
||||
offset: offset ? Number(offset) : 0,
|
||||
order,
|
||||
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||
q,
|
||||
}
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
raw: queryObject,
|
||||
}
|
||||
}
|
||||
@@ -450,6 +450,12 @@
|
||||
"idCopiedToClipboard": {
|
||||
"type": "string"
|
||||
},
|
||||
"editVariantImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"editImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"addReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -530,6 +536,8 @@
|
||||
"continue",
|
||||
"continueWithEmail",
|
||||
"idCopiedToClipboard",
|
||||
"editVariantImages",
|
||||
"editImages",
|
||||
"addReason",
|
||||
"addNote",
|
||||
"reset",
|
||||
@@ -1912,6 +1920,9 @@
|
||||
"editHint": {
|
||||
"type": "string"
|
||||
},
|
||||
"manageImageVariants": {
|
||||
"type": "string"
|
||||
},
|
||||
"makeThumbnail": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1969,11 +1980,27 @@
|
||||
},
|
||||
"successToast": {
|
||||
"type": "string"
|
||||
},
|
||||
"variantImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"showAvailableImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"availableImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"selectToAdd": {
|
||||
"type": "string"
|
||||
},
|
||||
"removeSelected": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"editHint",
|
||||
"manageImageVariants",
|
||||
"makeThumbnail",
|
||||
"uploadImagesLabel",
|
||||
"uploadImagesHint",
|
||||
@@ -1988,7 +2015,57 @@
|
||||
"downloadImageLabel",
|
||||
"deleteImageLabel",
|
||||
"emptyState",
|
||||
"successToast"
|
||||
"successToast",
|
||||
"variantImages",
|
||||
"showAvailableImages",
|
||||
"availableImages",
|
||||
"selectToAdd",
|
||||
"removeSelected"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"variantMedia": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"manageVariants": {
|
||||
"type": "string"
|
||||
},
|
||||
"addToMultipleVariants": {
|
||||
"type": "string"
|
||||
},
|
||||
"manageVariantsDescription": {
|
||||
"type": "string"
|
||||
},
|
||||
"successToast": {
|
||||
"type": "string"
|
||||
},
|
||||
"emptyState": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"header": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"action": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["header", "description", "action"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"manageVariants",
|
||||
"addToMultipleVariants",
|
||||
"manageVariantsDescription",
|
||||
"successToast",
|
||||
"emptyState"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -2736,6 +2813,7 @@
|
||||
"editOptions",
|
||||
"editPrices",
|
||||
"media",
|
||||
"variantMedia",
|
||||
"discountableHint",
|
||||
"noSalesChannels",
|
||||
"variantCount_one",
|
||||
@@ -7678,6 +7756,7 @@
|
||||
"campaign",
|
||||
"method",
|
||||
"allocation",
|
||||
"allocationTooltip",
|
||||
"addCondition",
|
||||
"clearAll",
|
||||
"taxInclusive",
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
"continue": "Continue",
|
||||
"continueWithEmail": "Continue with Email",
|
||||
"idCopiedToClipboard": "ID copied to clipboard",
|
||||
"editVariantImages": "Edit variant images",
|
||||
"editImages": "Edit images",
|
||||
"addReason": "Add Reason",
|
||||
"addNote": "Add Note",
|
||||
"reset": "Reset",
|
||||
@@ -506,6 +508,7 @@
|
||||
"media": {
|
||||
"label": "Media",
|
||||
"editHint": "Add media to the product to showcase it in your storefront.",
|
||||
"manageImageVariants": "Manage associated variants",
|
||||
"makeThumbnail": "Make thumbnail",
|
||||
"uploadImagesLabel": "Upload images",
|
||||
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
||||
@@ -521,10 +524,27 @@
|
||||
"deleteImageLabel": "Delete current image",
|
||||
"emptyState": {
|
||||
"header": "No media yet",
|
||||
"description": "Add media to the product to showcase it in your storefront.",
|
||||
"description": "Add media to showcase it in your storefront.",
|
||||
"action": "Add media"
|
||||
},
|
||||
"successToast": "Media was successfully updated."
|
||||
"successToast": "Media was successfully updated.",
|
||||
"variantImages": "Variant images",
|
||||
"showAvailableImages": "Show available images",
|
||||
"availableImages": "Available images",
|
||||
"selectToAdd": "Select to add to variant",
|
||||
"removeSelected": "Remove Selected"
|
||||
},
|
||||
"variantMedia": {
|
||||
"label": "Variant Media",
|
||||
"manageVariants": "Manage variants",
|
||||
"addToMultipleVariants": "Add to multiple variants",
|
||||
"manageVariantsDescription": "Manage associated variants for the image",
|
||||
"successToast": "Image variants successfully updated.",
|
||||
"emptyState": {
|
||||
"header": "No media yet",
|
||||
"description": "Add media to the variant to showcase it in your storefront.",
|
||||
"action": "Add media"
|
||||
}
|
||||
},
|
||||
"discountableHint": "When unchecked, discounts will not be applied to this product.",
|
||||
"noSalesChannels": "Not available in any sales channels",
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { VariantMediaSection } from "./variant-media-section"
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { PencilSquare, ThumbnailBadge } from "@medusajs/icons"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
type VariantMediaSectionProps = {
|
||||
variant: HttpTypes.AdminProductVariant
|
||||
}
|
||||
|
||||
export const VariantMediaSection = ({ variant }: VariantMediaSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// show only variant scoped images
|
||||
const media = (variant.images || []).filter((image) =>
|
||||
image.variants?.some((variant) => variant.id === variant.id)
|
||||
)
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("products.media.label")}</Heading>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.editImages"),
|
||||
to: "media",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{media.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-4 px-6 py-4">
|
||||
{media.map((i) => {
|
||||
return (
|
||||
<div
|
||||
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover transition-fg group relative aspect-square size-full overflow-hidden rounded-[8px]"
|
||||
key={i.id}
|
||||
>
|
||||
{i.url === variant.thumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<img src={i.url} className="size-full object-cover" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-y-4 pb-8 pt-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{t("products.media.emptyState.header")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-muted">
|
||||
{t("products.media.emptyState.description")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const VARIANT_DETAIL_FIELDS =
|
||||
"*inventory_items,*inventory_items.inventory,*inventory_items.inventory.location_levels,*options,*options.option,*prices,*prices.price_rules"
|
||||
"*inventory_items,*inventory_items.inventory,*inventory_items.inventory.location_levels,*options,*options.option,*prices,*prices.price_rules,+images.id,+images.url,+images.variants.id"
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import {
|
||||
InventorySectionPlaceholder,
|
||||
VariantInventorySection,
|
||||
} from "./components/variant-inventory-section"
|
||||
import { VariantMediaSection } from "./components/variant-media-section"
|
||||
import { VariantPricesSection } from "./components/variant-prices-section"
|
||||
import { VARIANT_DETAIL_FIELDS } from "./constants"
|
||||
import { variantLoader } from "./loader"
|
||||
@@ -61,6 +62,7 @@ export const ProductVariantDetail = () => {
|
||||
>
|
||||
<TwoColumnPage.Main>
|
||||
<VariantGeneralSection variant={variant} />
|
||||
<VariantMediaSection variant={variant} />
|
||||
{!variant.manage_inventory ? (
|
||||
<InventorySectionPlaceholder />
|
||||
) : (
|
||||
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus, 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 { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/modals"
|
||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||
import {
|
||||
useBatchVariantImages,
|
||||
useUpdateProductVariant,
|
||||
} from "../../../../../hooks/api/products"
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
const MediaSchema = z.object({
|
||||
image_ids: z.array(z.string()),
|
||||
thumbnail: z.string().nullable(),
|
||||
})
|
||||
|
||||
type MediaSchemaType = z.infer<typeof MediaSchema>
|
||||
|
||||
/**
|
||||
* Prop types
|
||||
*/
|
||||
type ProductVariantMediaViewProps = {
|
||||
variant: HttpTypes.AdminProductVariant & {
|
||||
images: HttpTypes.AdminProductImage[]
|
||||
}
|
||||
}
|
||||
|
||||
export const EditProductVariantMediaForm = ({
|
||||
variant,
|
||||
}: ProductVariantMediaViewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const allProductImages = variant.product?.images || []
|
||||
const allVariantImages = (variant.images || []).filter((image) =>
|
||||
image.variants?.some((variant) => variant.id === variant.id)
|
||||
)
|
||||
|
||||
const unassociatedImages = allProductImages.filter(
|
||||
(image) => !image.variants?.some((variant) => variant.id === variant.id)
|
||||
)
|
||||
|
||||
const [variantImages, setVariantImages] = useState<Record<string, true>>(() =>
|
||||
allVariantImages.reduce(
|
||||
// @eslint-disable-next-line
|
||||
(acc: Record<string, true>, image) => {
|
||||
acc[image.id] = true
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
)
|
||||
|
||||
const [selection, setSelection] = useState<Record<string, true>>({})
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
|
||||
const availableImages = unassociatedImages.filter(
|
||||
(image) => !variantImages[image.id!]
|
||||
)
|
||||
|
||||
const form = useForm<MediaSchemaType>({
|
||||
defaultValues: {
|
||||
image_ids: allVariantImages.map((image) => image.id!),
|
||||
thumbnail: variant.thumbnail,
|
||||
},
|
||||
resolver: zodResolver(MediaSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync: updateVariant } = useUpdateProductVariant(
|
||||
variant.product_id!,
|
||||
variant.id!
|
||||
)
|
||||
|
||||
const { mutateAsync, isPending } = useBatchVariantImages(
|
||||
variant.product_id!,
|
||||
variant.id!
|
||||
)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const currentVariantImageIds = data.image_ids
|
||||
const newVariantImageIds = Object.keys(variantImages).filter(
|
||||
(id) => variantImages[id]
|
||||
)
|
||||
|
||||
const imagesToAdd = newVariantImageIds.filter(
|
||||
(id) => !currentVariantImageIds.includes(id)
|
||||
)
|
||||
const imagesToRemove = currentVariantImageIds.filter(
|
||||
(id) => !newVariantImageIds.includes(id)
|
||||
)
|
||||
|
||||
if (data.thumbnail !== variant.thumbnail) {
|
||||
let thumbnail = data.thumbnail
|
||||
if (
|
||||
thumbnail &&
|
||||
![...currentVariantImageIds, ...newVariantImageIds].includes(thumbnail)
|
||||
) {
|
||||
thumbnail = null
|
||||
}
|
||||
updateVariant({
|
||||
thumbnail: data.thumbnail,
|
||||
}).catch((error) => {
|
||||
toast.error(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
// Update variant images
|
||||
await mutateAsync(
|
||||
{
|
||||
add: imagesToAdd,
|
||||
remove: imagesToRemove,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("products.media.successToast"))
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const handleAddImageToVariant = (imageId: string) => {
|
||||
setVariantImages((prev) => ({
|
||||
...prev,
|
||||
[imageId]: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCheckedChange = useCallback(
|
||||
(id: string) => {
|
||||
return (val: boolean) => {
|
||||
if (!val) {
|
||||
const { [id]: _, ...rest } = selection
|
||||
setSelection(rest)
|
||||
} else {
|
||||
setSelection((prev) => ({ ...prev, [id]: true }))
|
||||
}
|
||||
}
|
||||
},
|
||||
[selection]
|
||||
)
|
||||
|
||||
const handlePromoteToThumbnail = () => {
|
||||
const ids = Object.keys(selection)
|
||||
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedImage = allProductImages.find((image) => image.id === ids[0])
|
||||
if (selectedImage) {
|
||||
form.setValue("thumbnail", selectedImage.url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSelectedImages = () => {
|
||||
const selectedIds = Object.keys(selection)
|
||||
if (selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setVariantImages((prev) => {
|
||||
const newVariantImages = { ...prev }
|
||||
selectedIds.forEach((id) => {
|
||||
delete newVariantImages[id]
|
||||
})
|
||||
return newVariantImages
|
||||
})
|
||||
|
||||
setSelection({})
|
||||
}
|
||||
|
||||
const selectedImageThumbnail = form.watch("thumbnail")
|
||||
|
||||
const isSelectedImageThumbnail =
|
||||
variant.thumbnail &&
|
||||
Object.keys(selection).length === 1 &&
|
||||
selectedImageThumbnail ===
|
||||
variant.images.find((image) => image.id === Object.keys(selection)[0])
|
||||
?.url
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form blockSearchParams form={form}>
|
||||
<KeyboundForm
|
||||
className="flex size-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RouteFocusModal.Header />
|
||||
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||
<div className="relative flex size-full">
|
||||
<div className="bg-ui-bg-subtle flex-1 overflow-auto">
|
||||
<div className="flex items-center justify-between p-4 lg:hidden">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("products.media.variantImages")}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
>
|
||||
{t("products.media.showAvailableImages")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid h-fit auto-rows-auto grid-cols-2 gap-4 p-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-6 lg:p-6">
|
||||
{allProductImages
|
||||
.filter((image) => variantImages[image.id!])
|
||||
.map((image) => (
|
||||
<MediaGridItem
|
||||
key={image.id}
|
||||
media={image}
|
||||
checked={!!selection[image.id!]}
|
||||
onCheckedChange={handleCheckedChange(image.id!)}
|
||||
isThumbnail={image.url === form.watch("thumbnail")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar - always visible */}
|
||||
<div className="border-ui-border-base bg-ui-bg-base hidden w-80 border-l lg:block">
|
||||
<div className="border-ui-border-base border-b p-4">
|
||||
<div>
|
||||
<h3 className="ui-fg-base ">
|
||||
{t("products.media.availableImages")}
|
||||
</h3>
|
||||
<p className="text-ui-fg-dimmed mt-1 text-sm">
|
||||
{t("products.media.selectToAdd")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
{availableImages.map((image) => (
|
||||
<UnassociatedImageItem
|
||||
key={image.id}
|
||||
media={image}
|
||||
onAdd={() => handleAddImageToVariant(image.id!)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar - overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 lg:hidden"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-ui-bg-base border-ui-border-base absolute right-0 top-0 h-full w-80 border-l"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-ui-border-base border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="ui-fg-base text-sm font-medium">
|
||||
{t("products.media.availableImages")}
|
||||
</h3>
|
||||
<p className="ui-fg-muted mt-1 text-xs">
|
||||
{t("products.media.selectToAdd")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="small"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
{availableImages.map((image) => (
|
||||
<UnassociatedImageItem
|
||||
key={image.id}
|
||||
media={image}
|
||||
onAdd={() => handleAddImageToVariant(image.id!)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
<CommandBar open={Object.keys(selection).length > 0}>
|
||||
<CommandBar.Bar>
|
||||
<CommandBar.Value>
|
||||
{t("general.countSelected", {
|
||||
count: Object.keys(selection).length,
|
||||
})}
|
||||
</CommandBar.Value>
|
||||
<CommandBar.Seperator />
|
||||
{Object.keys(selection).length === 1 &&
|
||||
!isSelectedImageThumbnail && (
|
||||
<Fragment>
|
||||
<CommandBar.Command
|
||||
action={handlePromoteToThumbnail}
|
||||
label={t("products.media.makeThumbnail")}
|
||||
shortcut="t"
|
||||
/>
|
||||
<CommandBar.Seperator />
|
||||
</Fragment>
|
||||
)}
|
||||
<CommandBar.Command
|
||||
action={handleRemoveSelectedImages}
|
||||
label={t("products.media.removeSelected")}
|
||||
shortcut="r"
|
||||
/>
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
<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 size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
/* ******************* * MEDIA VIEW ******************* */
|
||||
|
||||
interface MediaView {
|
||||
id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface MediaGridItemProps {
|
||||
media: MediaView
|
||||
checked: boolean
|
||||
onCheckedChange: (value: boolean) => void
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const MediaGridItem = ({
|
||||
media,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
isThumbnail,
|
||||
}: MediaGridItemProps) => {
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
onCheckedChange(value)
|
||||
},
|
||||
[onCheckedChange]
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{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":
|
||||
!checked,
|
||||
"opacity-100": checked,
|
||||
})}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
<img src={media.url} className="size-full object-cover object-center" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UnassociatedImageItemProps {
|
||||
media: MediaView
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
const UnassociatedImageItem = ({
|
||||
media,
|
||||
onAdd,
|
||||
}: UnassociatedImageItemProps) => {
|
||||
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 cursor-pointer overflow-hidden rounded-lg outline-none"
|
||||
)}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg absolute inset-0 flex items-center justify-center bg-black/30 opacity-0",
|
||||
{
|
||||
"group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100":
|
||||
true,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="bg-ui-bg-base border-ui-border-base flex h-12 w-12 items-center justify-center rounded-full border shadow-lg">
|
||||
<Plus />
|
||||
</div>
|
||||
</div>
|
||||
<img src={media.url} className="size-full object-cover object-center" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./edit-product-variant-media-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductVariantMedia as Component } from "./product-variant-media"
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
import { RouteFocusModal } from "../../../components/modals"
|
||||
import { useProductVariant } from "../../../hooks/api/products"
|
||||
import { EditProductVariantMediaForm } from "./components/edit-product-variant-media-form"
|
||||
|
||||
type ProductMediaVariantsReponse = HttpTypes.AdminProductVariant & {
|
||||
images: HttpTypes.AdminProductImage[]
|
||||
}
|
||||
|
||||
export const ProductVariantMedia = () => {
|
||||
const { id, variant_id } = useParams()
|
||||
|
||||
const { variant, isLoading, isError, error } = useProductVariant(
|
||||
id!,
|
||||
variant_id!,
|
||||
{ fields: "*product,*product.images,*images,+images.variants.id" }
|
||||
)
|
||||
|
||||
const ready = !isLoading && variant
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{ready && (
|
||||
<EditProductVariantMediaForm
|
||||
variant={variant as ProductMediaVariantsReponse}
|
||||
/>
|
||||
)}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
+15
-3
@@ -12,7 +12,7 @@ import {
|
||||
} from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
@@ -24,6 +24,8 @@ type ProductMedisaSectionProps = {
|
||||
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const media = getMedia(product)
|
||||
@@ -66,7 +68,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
|
||||
const mediaToKeep = product.images
|
||||
.filter((i) => !ids.includes(i.id))
|
||||
.map((i) => ({ url: i.url}))
|
||||
.map((i) => ({ url: i.url }))
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
@@ -90,7 +92,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
label: t("actions.editImages"),
|
||||
to: "media?view=edit",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
@@ -175,6 +177,16 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
label={t("actions.delete")}
|
||||
shortcut="d"
|
||||
/>
|
||||
{Object.keys(selection).length === 1 && (
|
||||
<CommandBar.Command
|
||||
action={() => {
|
||||
navigate(`images/${Object.keys(selection)[0]}/variants`)
|
||||
setSelection({})
|
||||
}}
|
||||
label={t("products.media.manageImageVariants")}
|
||||
shortcut="m"
|
||||
/>
|
||||
)}
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
</Container>
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { VariantsTableForm } from "./variants-table-form"
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { Button, Checkbox, toast } from "@medusajs/ui"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
createColumnHelper,
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { AdminProduct } from "@medusajs/types"
|
||||
|
||||
import { _DataTable } from "../../../../../components/table/data-table"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import {
|
||||
useBatchImageVariants,
|
||||
useProductVariants,
|
||||
} from "../../../../../hooks/api"
|
||||
import { useProductVariantTableQuery } from "../../../../../hooks/table/query/use-product-variant-table-query"
|
||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type VariantsTableFormProps = {
|
||||
productId: string
|
||||
image: { id: string; variants: { id: string }[] }
|
||||
}
|
||||
|
||||
const BatchImageVariantsSchema = zod.object({
|
||||
variants: zod.array(zod.string()),
|
||||
})
|
||||
|
||||
const variantColumnHelper =
|
||||
createColumnHelper<NonNullable<AdminProduct["variants"]>[0]>()
|
||||
|
||||
export const VariantsTableForm = ({
|
||||
productId,
|
||||
image,
|
||||
}: VariantsTableFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const { mutateAsync, isPending } = useBatchImageVariants(productId, image.id)
|
||||
|
||||
const [variantSelection, setVariantSelection] = useState<RowSelectionState>(
|
||||
() =>
|
||||
image.variants?.reduce((acc, variant) => {
|
||||
acc[variant.id] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState) || {}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setVariantSelection(
|
||||
image.variants?.reduce((acc, variant) => {
|
||||
acc[variant.id] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState) || {}
|
||||
)
|
||||
}, [image.variants.length])
|
||||
|
||||
const form = useForm<zod.infer<typeof BatchImageVariantsSchema>>({
|
||||
defaultValues: {
|
||||
variants: image.variants?.map((variant) => variant.id) || [],
|
||||
},
|
||||
resolver: zodResolver(BatchImageVariantsSchema),
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const initialVariantIds =
|
||||
image?.variants?.map((variant) => variant.id) || []
|
||||
|
||||
const newVariantIds = Object.keys(variantSelection).filter(
|
||||
(k) => variantSelection[k]
|
||||
)
|
||||
|
||||
const variantsToAdd = newVariantIds.filter(
|
||||
(id) => !initialVariantIds.includes(id)
|
||||
)
|
||||
|
||||
const variantsToRemove = initialVariantIds.filter(
|
||||
(id) => !newVariantIds.includes(id)
|
||||
)
|
||||
|
||||
// TODO: remove thumbnail if variant is removed
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
add: variantsToAdd,
|
||||
remove: variantsToRemove,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("products.variantMedia.successToast"))
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
variantColumnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
variantColumnHelper.accessor("title", {
|
||||
header: () => t("fields.title"),
|
||||
cell: ({ getValue }) => {
|
||||
const title = getValue()
|
||||
return (
|
||||
<div className="flex h-full w-full items-center">
|
||||
<span className="truncate">{title || "-"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
variantColumnHelper.accessor("sku", {
|
||||
header: () => t("fields.sku"),
|
||||
cell: ({ getValue }) => {
|
||||
const sku = getValue()
|
||||
return (
|
||||
<div className="flex h-full w-full items-center">
|
||||
<span className="truncate font-mono text-sm">{sku || "-"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
variantColumnHelper.accessor("thumbnail", {
|
||||
header: () => t("fields.thumbnail"),
|
||||
cell: ({ getValue }) => {
|
||||
const isThumbnail = getValue() === image.url
|
||||
return (
|
||||
<div className="flex h-full w-full items-center">
|
||||
<span className="truncate text-sm">
|
||||
{isThumbnail ? t("fields.true") : t("fields.false")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (value) => {
|
||||
const state = typeof value === "function" ? value(variantSelection) : value
|
||||
setVariantSelection(state)
|
||||
const formState = Object.keys(state).filter((k) => state[k])
|
||||
|
||||
form.setValue("variants", formState, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
|
||||
const { searchParams, raw } = useProductVariantTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const {
|
||||
variants,
|
||||
count,
|
||||
isPending: isLoading,
|
||||
} = useProductVariants(
|
||||
productId,
|
||||
{
|
||||
...searchParams,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: variants || [],
|
||||
columns,
|
||||
count: count,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
pageSize: PAGE_SIZE,
|
||||
getRowId: (row) => row.id,
|
||||
rowSelection: {
|
||||
state: variantSelection,
|
||||
updater,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<KeyboundForm
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<RouteDrawer.Body className="flex flex-col gap-y-8 overflow-y-auto p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<_DataTable
|
||||
layout="fill"
|
||||
table={table}
|
||||
columns={columns}
|
||||
count={count}
|
||||
isLoading={isLoading}
|
||||
pageSize={PAGE_SIZE}
|
||||
queryObject={raw}
|
||||
pagination
|
||||
search
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductImageVariantsEdit as Component } from "./product-image-variants-edit"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { json, useParams } from "react-router-dom"
|
||||
|
||||
import { RouteDrawer } from "../../../components/modals"
|
||||
import { VariantsTableForm } from "./components/variants-table-form/variants-table-form"
|
||||
import { useProduct } from "../../../hooks/api"
|
||||
|
||||
type VariantImagesPartial = {
|
||||
id: string
|
||||
variants: { id: string }[]
|
||||
}
|
||||
|
||||
export const ProductImageVariantsEdit = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { id: product_id, image_id } = useParams<{
|
||||
id: string
|
||||
image_id: string
|
||||
}>()
|
||||
|
||||
const { product, isPending } = useProduct(
|
||||
product_id!,
|
||||
{ fields: "images.id,images.url,images.variants.id" },
|
||||
{
|
||||
enabled: !!product_id && !!image_id,
|
||||
}
|
||||
)
|
||||
|
||||
const image = product?.images?.find((image) => image.id === image_id)
|
||||
|
||||
if (!product_id || !image_id || isPending) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isPending && !image) {
|
||||
throw json({ message: `An image with ID ${image_id} was not found` }, 404)
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<img src={image!.url} className="h-20" />
|
||||
<div>
|
||||
<RouteDrawer.Title asChild>
|
||||
<Heading>{t("products.variantMedia.manageVariants")}</Heading>
|
||||
</RouteDrawer.Title>
|
||||
<RouteDrawer.Description>
|
||||
{t("products.variantMedia.manageVariantsDescription")}
|
||||
</RouteDrawer.Description>
|
||||
</div>
|
||||
</div>
|
||||
</RouteDrawer.Header>
|
||||
<VariantsTableForm
|
||||
productId={product_id}
|
||||
image={image! as VariantImagesPartial}
|
||||
/>
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
+1
@@ -128,6 +128,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
||||
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
|
||||
|
||||
await mutateAsync(
|
||||
|
||||
Reference in New Issue
Block a user