feat: Revamp product details page and several product fixes and cleanups (#6988)

This commit is contained in:
Stevche Radevski
2024-04-07 15:29:37 +02:00
committed by GitHub
parent 31b07aea3d
commit 4d6306f57b
24 changed files with 423 additions and 305 deletions

View File

@@ -0,0 +1 @@
export * from "./section-row"

View File

@@ -0,0 +1,33 @@
import { Text } from "@medusajs/ui"
export type SectionRowProps = {
title: string
value?: React.ReactNode | string | null
actions?: React.ReactNode
}
export const SectionRow = ({ title, value, actions }: SectionRowProps) => {
const isValueString = typeof value === "string" || !value
return (
<div
className={`text-ui-fg-subtle grid ${
!!actions ? "grid-cols-[1fr_1fr_28px]" : "grid-cols-2"
} items-center px-6 py-4`}
>
<Text size="small" weight="plus" leading="compact">
{title}
</Text>
{isValueString ? (
<Text size="small" leading="compact" className="text-pretty">
{value ?? "-"}
</Text>
) : (
<div className="flex flex-wrap gap-1">{value}</div>
)}
{actions && <div>{actions}</div>}
</div>
)
}

View File

@@ -7,11 +7,7 @@ import {
} from "@tanstack/react-query"
import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import {
ProductDeleteRes,
ProductListRes,
ProductRes,
} from "../../types/api-responses"
import { ProductDeleteRes, ProductRes } from "../../types/api-responses"
import { queryClient } from "../../lib/medusa"
const PRODUCTS_QUERY_KEY = "products" as const
@@ -20,6 +16,63 @@ export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
const VARIANTS_QUERY_KEY = "product_variants" as const
export const variantsQueryKeys = queryKeysFactory(VARIANTS_QUERY_KEY)
const OPTIONS_QUERY_KEY = "product_options" as const
export const optionsQueryKeys = queryKeysFactory(OPTIONS_QUERY_KEY)
export const useCreateProductOption = (
productId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) =>
client.products.createOption(productId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateProductOption = (
productId: string,
optionId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) =>
client.products.updateOption(productId, optionId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: optionsQueryKeys.detail(optionId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteProductOption = (
productId: string,
optionId: string,
options?: UseMutationOptions<any, Error, void>
) => {
return useMutation({
mutationFn: () => client.products.deleteOption(productId, optionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(optionId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useProductVariant = (
productId: string,
variantId: string,
@@ -55,6 +108,26 @@ export const useProductVariants = (
return { ...data, ...rest }
}
export const useUpdateProductVariant = (
productId: string,
variantId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) =>
client.products.updateVariant(productId, variantId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: variantsQueryKeys.detail(variantId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteVariant = (
productId: string,
variantId: string,
@@ -78,7 +151,7 @@ export const useProduct = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<ProductRes, Error, ProductRes, QueryKey>,
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
@@ -94,7 +167,7 @@ export const useProduct = (
export const useProducts = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<ProductListRes, Error, ProductListRes, QueryKey>,
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {

View File

@@ -1,4 +1,5 @@
import { Address } from "@medusajs/medusa"
import { countries } from "./countries"
export const isSameAddress = (a: Address | null, b: Address | null) => {
if (!a || !b) {
@@ -75,3 +76,12 @@ export const getFormattedAddress = ({
return formattedAddress
}
export const getFormattedCountry = (countryCode: string | null | undefined) => {
if (!countryCode) {
return ""
}
const country = countries.find((c) => c.iso_2 === countryCode)
return country ? country.display_name : countryCode
}

View File

@@ -1,16 +1,12 @@
import {
ProductDeleteRes,
ProductListRes,
ProductRes,
} from "../../types/api-responses"
import { ProductDeleteRes, ProductListRes } from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common"
async function retrieveProduct(id: string, query?: Record<string, any>) {
return getRequest<ProductRes>(`/admin/products/${id}`, query)
return getRequest<any>(`/admin/products/${id}`, query)
}
async function createProduct(payload: any) {
return postRequest<ProductRes>(`/admin/products`, payload)
return postRequest<any>(`/admin/products`, payload)
}
async function listProducts(query?: Record<string, any>) {
@@ -18,7 +14,7 @@ async function listProducts(query?: Record<string, any>) {
}
async function updateProduct(id: string, payload: any) {
return postRequest<ProductRes>(`/admin/products/${id}`, payload)
return postRequest<any>(`/admin/products/${id}`, payload)
}
async function deleteProduct(id: string) {
@@ -40,12 +36,38 @@ async function listVariants(productId: string, query?: Record<string, any>) {
return getRequest<any>(`/admin/products/${productId}/variants`, query)
}
async function updateVariant(
productId: string,
variantId: string,
payload: any
) {
return postRequest<any>(
`/admin/products/${productId}/variants/${variantId}`,
payload
)
}
async function deleteVariant(productId: string, variantId: string) {
return deleteRequest<any>(
`/admin/products/${productId}/variants/${variantId}`
)
}
async function createOption(productId: string, payload: any) {
return postRequest<any>(`/admin/products/${productId}/options`, payload)
}
async function updateOption(productId: string, optionId: string, payload: any) {
return postRequest<any>(
`/admin/products/${productId}/options/${optionId}`,
payload
)
}
async function deleteOption(productId: string, optionId: string) {
return deleteRequest<any>(`/admin/products/${productId}/options/${optionId}`)
}
export const products = {
retrieve: retrieveProduct,
list: listProducts,
@@ -54,5 +76,9 @@ export const products = {
delete: deleteProduct,
retrieveVariant,
listVariants,
updateVariant,
deleteVariant,
createOption,
updateOption,
deleteOption,
}

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui"
import { useAdminCreateProductOption } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
@@ -10,6 +9,7 @@ import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateProductOption } from "../../../../../hooks/api/products"
type EditProductOptionsFormProps = {
product: Product
@@ -32,7 +32,7 @@ export const CreateProductOptionForm = ({
resolver: zodResolver(CreateProductOptionSchema),
})
const { mutateAsync, isLoading } = useAdminCreateProductOption(product.id)
const { mutateAsync, isLoading } = useCreateProductOption(product.id)
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values, {

View File

@@ -87,7 +87,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
const { product_categories, isLoading: isLoadingCategories } = useCategories()
const options = form.watch("options")
const optionPermutations = permutations(options)
const optionPermutations = permutations(options ?? [])
// const { append } = useFieldArray({
// name: "images",
@@ -396,7 +396,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
control={form.control}
name="options"
render={({ field: { onChange, value } }) => {
const normalizedValue = value.map((v) => {
const normalizedValue = (value ?? []).map((v) => {
return {
key: v.title,
value: v.values.join(","),
@@ -443,7 +443,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
control={form.control}
name="variants"
render={({ field: { value, onChange, ...field } }) => {
const selectedOptions = value.map((v) => v.options)
const selectedOptions = (value ?? []).map(
(v) => v.options
)
return (
<Form.Item>
<Form.Label optional>

View File

@@ -56,10 +56,6 @@ export const CreateProductForm = () => {
const form = useForm<Schema>({
defaultValues: {
title: "",
subtitle: "",
handle: "",
description: "",
discountable: true,
tags: [],
sales_channels: [],
@@ -83,11 +79,12 @@ export const CreateProductForm = () => {
length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined,
variants: values.variants,
variants: values.variants.map((variant) => ({
...variant,
prices: [],
})),
} as any
delete reqData.sales_channels
await mutateAsync(reqData, {
onSuccess: ({ product }) => {
handleSuccess(`../${product.id}`)

View File

@@ -3,6 +3,8 @@ import { Product } from "@medusajs/medusa"
import { Container, Heading, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
import { getFormattedCountry } from "../../../../../lib/addresses"
type ProductAttributeSectionProps = {
product: Product
@@ -31,62 +33,16 @@ export const ProductAttributeSection = ({
]}
/>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.height")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{product.height ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.width")}
</Text>
<Text size="small" leading="compact">
{product.width ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.length")}
</Text>
<Text size="small" leading="compact">
{product.length ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.weight")}
</Text>
<Text size="small" leading="compact">
{product.weight ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.midCode")}
</Text>
<Text size="small" leading="compact">
{product.mid_code ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.hsCode")}
</Text>
<Text size="small" leading="compact">
{product.hs_code ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.countryOfOrigin")}
</Text>
<Text size="small" leading="compact">
{product.origin_country ?? "-"}
</Text>
</div>
<SectionRow title={t("fields.height")} value={product.height} />
<SectionRow title={t("fields.width")} value={product.width} />
<SectionRow title={t("fields.length")} value={product.length} />
<SectionRow title={t("fields.weight")} value={product.weight} />
<SectionRow title={t("fields.midCode")} value={product.mid_code} />
<SectionRow title={t("fields.hsCode")} value={product.hs_code} />
<SectionRow
title={t("fields.countryOfOrigin")}
value={getFormattedCountry(product.origin_country)}
/>
</Container>
)
}

View File

@@ -5,6 +5,22 @@ import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProduct } from "../../../../../hooks/api/products"
import { SectionRow } from "../../../../../components/common/section"
const productStatusColor = (status: string) => {
switch (status) {
case "draft":
return "purple"
case "proposed":
return "orange"
case "published":
return "green"
case "rejected":
return "red"
default:
return "purple"
}
}
type ProductGeneralSectionProps = {
product: Product
@@ -45,7 +61,9 @@ export const ProductGeneralSection = ({
<div className="flex items-center justify-between px-6 py-4">
<Heading>{product.title}</Heading>
<div className="flex items-center gap-x-4">
<StatusBadge color="green">Published</StatusBadge>
<StatusBadge color={productStatusColor(product.status)}>
{t(`products.productStatus.${product.status}`)}
</StatusBadge>
<ActionMenu
groups={[
{
@@ -70,38 +88,14 @@ export const ProductGeneralSection = ({
/>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.description")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{product.description}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.subtitle")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{product.subtitle ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.handle")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
/{product.handle}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.discountable")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{product.discountable ? t("fields.true") : t("fields.false")}
</Text>
</div>
<SectionRow title={t("fields.description")} value={product.description} />
<SectionRow title={t("fields.subtitle")} value={product.subtitle} />
<SectionRow title={t("fields.handle")} value={`/${product.handle}`} />
<SectionRow
title={t("fields.discountable")}
value={product.discountable ? t("fields.true") : t("fields.false")}
/>
</Container>
)
}

View File

@@ -1,8 +1,64 @@
import { PencilSquare, Plus } from "@medusajs/icons"
import { Product, ProductOption } from "@medusajs/medusa"
import { Badge, Container, Heading, Text } from "@medusajs/ui"
import { PencilSquare, Plus, Trash } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
import { useDeleteProductOption } from "../../../../../hooks/api/products"
const OptionActions = ({
product,
option,
}: {
product: Product
option: any
}) => {
const { t } = useTranslation()
const { mutateAsync } = useDeleteProductOption(product.id, option.id)
const prompt = usePrompt()
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("products.deleteWarning", {
title: product.title,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `options/${option.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}
type ProductOptionSectionProps = {
product: Product
@@ -31,50 +87,27 @@ export const ProductOptionSection = ({
]}
/>
</div>
{product.options.map((option) => {
return (
<div
<SectionRow
title={option.title}
key={option.id}
className="text-ui-fg-subtle grid grid-cols-[1fr_1fr_28px] items-start gap-4 px-6 py-4"
>
<Text size="small" leading="compact" weight="plus">
{option.title}
</Text>
<div className="flex flex-wrap gap-1">
{getUnqiueValues(option).map((value) => {
return (
<Badge
key={value}
size="2xsmall"
className="flex min-w-[20px] items-center justify-center"
>
{value}
</Badge>
)
})}
</div>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `options/${option.id}/edit`,
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
value={option.values.map((val) => {
return (
<Badge
key={val.value}
size="2xsmall"
className="flex min-w-[20px] items-center justify-center"
>
{val.value}
</Badge>
)
})}
actions={<OptionActions product={product} option={option} />}
/>
)
})}
</Container>
)
}
const getUnqiueValues = (option: ProductOption) => {
const values = option.values.map((v) => v.value)
return Array.from(new Set(values))
}

View File

@@ -1,9 +1,10 @@
import { PencilSquare } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { Badge, Container, Heading, Text } from "@medusajs/ui"
import { Badge, Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
type ProductOrganizationSectionProps = {
product: Product
@@ -32,58 +33,49 @@ export const ProductOrganizationSection = ({
]}
/>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.tags")}
</Text>
<div className="flex flex-wrap items-center gap-1">
{product.tags.length > 0
<SectionRow
title={t("fields.tags")}
value={
product.tags.length > 0
? product.tags.map((tag) => (
<Badge key={tag.id} className="w-fit" size="2xsmall" asChild>
<Link to={`/products?tags=${tag.id}`}>{tag.value}</Link>
</Badge>
))
: "-"}
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.type")}
</Text>
{product.type ? (
<Badge size="2xsmall" className="w-fit" asChild>
<Link to={`/products?type_id=${product.type_id}`}>
{product.type.value}
</Link>
</Badge>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.collection")}
</Text>
{product.collection ? (
<Badge size="2xsmall" color="blue" className="w-fit" asChild>
<Link to={`/collections/${product.collection.id}`}>
{product.collection.title}
</Link>
</Badge>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.categories")}
</Text>
<div className="flex flex-wrap items-center gap-1">
{product.categories?.length > 0
: undefined
}
/>
<SectionRow
title={t("fields.type")}
value={
product.type ? (
<Badge size="2xsmall" className="w-fit" asChild>
<Link to={`/products?type_id=${product.type_id}`}>
{product.type.value}
</Link>
</Badge>
) : undefined
}
/>
<SectionRow
title={t("fields.collection")}
value={
product.collection ? (
<Badge size="2xsmall" color="blue" className="w-fit" asChild>
<Link to={`/collections/${product.collection.id}`}>
{product.collection.title}
</Link>
</Badge>
) : undefined
}
/>
<SectionRow
title={t("fields.categories")}
value={
product.categories?.length > 0
? product.categories.map((pcat) => (
<Badge
key={pcat.id}
@@ -95,9 +87,9 @@ export const ProductOrganizationSection = ({
<Link to={`/categories/${pcat.id}`}>{pcat.name}</Link>
</Badge>
))
: "-"}
</div>
</div>
: undefined
}
/>
</Container>
)
}

View File

@@ -1,19 +1,19 @@
import { Channels, PencilSquare } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
// import { useAdminSalesChannels } from "medusa-react"
import { Trans, useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
type ProductSalesChannelSectionProps = {
product: Product
}
// TODO: The fetched sales channel doesn't contain all necessary info
export const ProductSalesChannelSection = ({
product,
}: ProductSalesChannelSectionProps) => {
// const { count } = useAdminSalesChannels()
const count = 0
const { count } = useSalesChannels()
const { t } = useTranslation()
const availableInSalesChannels =

View File

@@ -29,6 +29,9 @@ export const ProductVariantSection = ({
product.id,
{
...searchParams,
},
{
keepPreviousData: true,
}
)

View File

@@ -69,38 +69,38 @@ export const useProductVariantTableColumns = (product?: Product) => {
const { t } = useTranslation()
const optionColumns = useMemo(() => {
return product
? product.options?.map((o) => {
return columnHelper.display({
id: o.id,
header: () => (
<div className="flex h-full w-full items-center">
<span className="truncate">{o.title}</span>
</div>
),
cell: ({ row }) => {
const value = row.original.options.find(
(op) => op.option_id === o.id
)
if (!product) {
return []
}
return product.options?.map((option) => {
return columnHelper.display({
id: option.id,
header: () => (
<div className="flex h-full w-full items-center">
<span className="truncate">{option.title}</span>
</div>
),
cell: ({ row }) => {
const variantOpt: any = row.original.options.find(
(opt: any) => opt.option_value.option_id === option.id
)
if (!variantOpt) {
return <PlaceholderCell />
}
if (!value) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<Badge
size="2xsmall"
className="flex min-w-[20px] items-center justify-center"
>
{value.value}
</Badge>
</div>
)
},
})
})
: []
return (
<div className="flex h-full w-full items-center overflow-hidden">
<Badge
size="2xsmall"
className="flex min-w-[20px] items-center justify-center"
>
{variantOpt.option_value.value}
</Badge>
</div>
)
},
})
})
}, [product])
return useMemo(

View File

@@ -1,13 +1,14 @@
import { AdminProductsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminProductKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
import { queryClient } from "../../../lib/medusa"
import { productsQueryKeys } from "../../../hooks/api/products"
import { client } from "../../../lib/client"
const productDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.products.retrieve(id),
queryKey: productsQueryKeys.detail(id),
queryFn: async () => client.products.retrieve(id),
})
export const productLoader = async ({ params }: LoaderFunctionArgs) => {

View File

@@ -16,6 +16,7 @@ import sideBefore from "medusa-admin:widgets/product/details/side/before"
import { ProductOrganizationSection } from "./components/product-organization-section"
import { useProduct } from "../../../hooks/api/products"
// TODO: Use product domain translations only
export const ProductDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof productLoader>
@@ -43,31 +44,13 @@ export const ProductDetail = () => {
</div>
)
})}
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
<div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-x-4 lg:flex-row lg:items-start">
<div className="w-full flex flex-col gap-y-2">
<ProductGeneralSection product={product} />
<ProductMediaSection product={product} />
<ProductOptionSection product={product} />
<ProductVariantSection product={product} />
<div className="flex flex-col gap-y-2 xl:hidden">
{sideBefore.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
<ProductSalesChannelSection product={product} />
<ProductOrganizationSection product={product} />
<ProductAttributeSection product={product} />
{sideAfter.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
</div>
{after.widgets.map((w, i) => {
return (
<div key={i}>
@@ -75,9 +58,12 @@ export const ProductDetail = () => {
</div>
)
})}
<JsonViewSection data={product} root="product" />
<div className="hidden lg:block">
<JsonViewSection data={product} root="product" />
</div>
</div>
<div className="hidden w-full max-w-[400px] flex-col gap-y-2 xl:flex">
<div className="w-full lg:max-w-[400px] max-w-[100%] mt-2 lg:mt-0 flex flex-col gap-y-2">
{sideBefore.widgets.map((w, i) => {
return (
<div key={i}>
@@ -95,6 +81,10 @@ export const ProductDetail = () => {
</div>
)
})}
<div className="lg:hidden">
<JsonViewSection data={product} root="product" />
</div>
</div>
</div>
<Outlet />

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { ProductOption } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui"
import { useAdminUpdateProductOption } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
@@ -10,6 +9,7 @@ import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdateProductOption } from "../../../../../hooks/api/products"
type EditProductOptionFormProps = {
option: ProductOption
@@ -32,8 +32,9 @@ export const CreateProductOptionForm = ({
resolver: zodResolver(CreateProductOptionSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateProductOption(
option.product_id
const { mutateAsync, isLoading } = useUpdateProductOption(
option.product_id,
option.id
)
const handleSubmit = form.handleSubmit(async (values) => {

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product, ProductOption, ProductVariant } from "@medusajs/medusa"
import { Button, Heading, Input, Switch } from "@medusajs/ui"
import { useAdminUpdateVariant } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
@@ -17,6 +16,7 @@ import {
} from "../../../../../components/route-modal"
import { castNumber } from "../../../../../lib/cast-number"
import { optionalInt } from "../../../../../lib/validation"
import { useUpdateProductVariant } from "../../../../../hooks/api/products"
type ProductEditVariantFormProps = {
product: Product
@@ -83,7 +83,10 @@ export const ProductEditVariantForm = ({
resolver: zodResolver(ProductEditVariantSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateVariant(product.id)
const { mutateAsync, isLoading } = useUpdateProductVariant(
product.id,
variant.id
)
const handleSubmit = form.handleSubmit(async (data) => {
const parseNumber = (value?: string | number) => {

View File

@@ -1,10 +1,10 @@
import { adminProductKeys, adminStoreKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
import { productsQueryKeys } from "../../../hooks/api/products"
const queryKey = (id: string) => {
return [adminProductKeys.detail(id), adminStoreKeys.details()]
return [productsQueryKeys.detail(id)]
}
const queryFn = async (id: string) => {

View File

@@ -1,12 +1,12 @@
import { AdminProductsListRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query"
import { adminProductKeys } from "medusa-react"
import { medusa, queryClient } from "../../../lib/medusa"
import { productsQueryKeys } from "../../../hooks/api/products"
const productsListQuery = () => ({
queryKey: adminProductKeys.list({ limit: 20, offset: 0 }),
queryKey: productsQueryKeys.list({ limit: 20, offset: 0 }),
queryFn: async () => medusa.admin.products.list({ limit: 20, offset: 0 }),
})

View File

@@ -3,7 +3,6 @@ import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
import { Image, Product } from "@medusajs/medusa"
import { Button, CommandBar, Tooltip, clx } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import { useAdminUpdateProduct, useMedusa } from "medusa-react"
import { Fragment, useCallback, useState } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
@@ -19,6 +18,7 @@ import {
FileType,
FileUpload,
} from "../../../../../components/common/file-upload"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type ProductMediaViewProps = {
product: Product
@@ -73,8 +73,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
keyName: "field_id",
})
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id)
const { client } = useMedusa()
const { mutateAsync, isLoading } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async ({ media }) => {
const urls = media.map((m) => m.url)
@@ -86,15 +85,20 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
if (filesToUpload.length) {
const files = filesToUpload.map((m) => m.file) as File[]
const uploads = await client.admin.uploads
.create(files)
.then((res) => {
return res.uploads
})
.catch((_err) => {
// Show error message
return null
})
// TODO: Implement upload to Medusa
// const uploads = await client.admin.uploads
// .create(files)
// .then((res) => {
// return res.uploads
// })
// .catch((_err) => {
// // Show error message
// return null
// })
const uploads = files.map((file) => ({
url: URL.createObjectURL(file),
}))
if (!uploads) {
return

View File

@@ -1,7 +1,6 @@
import { Product, SalesChannel } from "@medusajs/medusa"
import { Button, Checkbox } from "@medusajs/ui"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { useAdminSalesChannels, useAdminUpdateProduct } from "medusa-react"
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
@@ -17,6 +16,8 @@ import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/
import { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/use-sales-channel-table-filters"
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type EditSalesChannelsFormProps = {
product: Product
@@ -63,15 +64,14 @@ export const EditSalesChannelsForm = ({
const { searchParams, raw } = useSalesChannelTableQuery({
pageSize: PAGE_SIZE,
})
const { sales_channels, count, isLoading, isError, error } =
useAdminSalesChannels(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const { sales_channels, count, isLoading, isError, error } = useSalesChannels(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const filters = useSalesChannelTableFilters()
const columns = useColumns()
@@ -90,9 +90,7 @@ export const EditSalesChannelsForm = ({
pageSize: PAGE_SIZE,
})
const { mutateAsync, isLoading: isMutating } = useAdminUpdateProduct(
product.id
)
const { mutateAsync, isLoading: isMutating } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => {
const arr = data.sales_channels ?? []

View File

@@ -24,6 +24,7 @@ export const defaultAdminProductsVariantFields = [
"barcode",
"*prices",
"*options",
"*options.option_value",
]
export const retrieveVariantConfig = {
@@ -81,10 +82,10 @@ export const defaultAdminProductFields = [
"*options.values",
"*tags",
"*images",
"*sales_channels",
"*variants",
"*variants.prices",
"*variants.options",
"*variants.options.option_value",
"*sales_channels",
]