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
@@ -0,0 +1 @@
export * from "./section-row"
@@ -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>
)
}
@@ -7,11 +7,7 @@ import {
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { client } from "../../lib/client" import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { import { ProductDeleteRes, ProductRes } from "../../types/api-responses"
ProductDeleteRes,
ProductListRes,
ProductRes,
} from "../../types/api-responses"
import { queryClient } from "../../lib/medusa" import { queryClient } from "../../lib/medusa"
const PRODUCTS_QUERY_KEY = "products" as const 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 const VARIANTS_QUERY_KEY = "product_variants" as const
export const variantsQueryKeys = queryKeysFactory(VARIANTS_QUERY_KEY) 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 = ( export const useProductVariant = (
productId: string, productId: string,
variantId: string, variantId: string,
@@ -55,6 +108,26 @@ export const useProductVariants = (
return { ...data, ...rest } 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 = ( export const useDeleteVariant = (
productId: string, productId: string,
variantId: string, variantId: string,
@@ -78,7 +151,7 @@ export const useProduct = (
id: string, id: string,
query?: Record<string, any>, query?: Record<string, any>,
options?: Omit< options?: Omit<
UseQueryOptions<ProductRes, Error, ProductRes, QueryKey>, UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey" "queryFn" | "queryKey"
> >
) => { ) => {
@@ -94,7 +167,7 @@ export const useProduct = (
export const useProducts = ( export const useProducts = (
query?: Record<string, any>, query?: Record<string, any>,
options?: Omit< options?: Omit<
UseQueryOptions<ProductListRes, Error, ProductListRes, QueryKey>, UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey" "queryFn" | "queryKey"
> >
) => { ) => {
@@ -1,4 +1,5 @@
import { Address } from "@medusajs/medusa" import { Address } from "@medusajs/medusa"
import { countries } from "./countries"
export const isSameAddress = (a: Address | null, b: Address | null) => { export const isSameAddress = (a: Address | null, b: Address | null) => {
if (!a || !b) { if (!a || !b) {
@@ -75,3 +76,12 @@ export const getFormattedAddress = ({
return formattedAddress 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
}
@@ -1,16 +1,12 @@
import { import { ProductDeleteRes, ProductListRes } from "../../types/api-responses"
ProductDeleteRes,
ProductListRes,
ProductRes,
} from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common" import { deleteRequest, getRequest, postRequest } from "./common"
async function retrieveProduct(id: string, query?: Record<string, any>) { 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) { async function createProduct(payload: any) {
return postRequest<ProductRes>(`/admin/products`, payload) return postRequest<any>(`/admin/products`, payload)
} }
async function listProducts(query?: Record<string, any>) { 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) { 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) { 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) 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) { async function deleteVariant(productId: string, variantId: string) {
return deleteRequest<any>( return deleteRequest<any>(
`/admin/products/${productId}/variants/${variantId}` `/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 = { export const products = {
retrieve: retrieveProduct, retrieve: retrieveProduct,
list: listProducts, list: listProducts,
@@ -54,5 +76,9 @@ export const products = {
delete: deleteProduct, delete: deleteProduct,
retrieveVariant, retrieveVariant,
listVariants, listVariants,
updateVariant,
deleteVariant, deleteVariant,
createOption,
updateOption,
deleteOption,
} }
@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui" import { Button, Input } from "@medusajs/ui"
import { useAdminCreateProductOption } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { z } from "zod" import { z } from "zod"
@@ -10,6 +9,7 @@ import {
RouteDrawer, RouteDrawer,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useCreateProductOption } from "../../../../../hooks/api/products"
type EditProductOptionsFormProps = { type EditProductOptionsFormProps = {
product: Product product: Product
@@ -32,7 +32,7 @@ export const CreateProductOptionForm = ({
resolver: zodResolver(CreateProductOptionSchema), resolver: zodResolver(CreateProductOptionSchema),
}) })
const { mutateAsync, isLoading } = useAdminCreateProductOption(product.id) const { mutateAsync, isLoading } = useCreateProductOption(product.id)
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values, { mutateAsync(values, {
@@ -87,7 +87,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
const { product_categories, isLoading: isLoadingCategories } = useCategories() const { product_categories, isLoading: isLoadingCategories } = useCategories()
const options = form.watch("options") const options = form.watch("options")
const optionPermutations = permutations(options) const optionPermutations = permutations(options ?? [])
// const { append } = useFieldArray({ // const { append } = useFieldArray({
// name: "images", // name: "images",
@@ -396,7 +396,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
control={form.control} control={form.control}
name="options" name="options"
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
const normalizedValue = value.map((v) => { const normalizedValue = (value ?? []).map((v) => {
return { return {
key: v.title, key: v.title,
value: v.values.join(","), value: v.values.join(","),
@@ -443,7 +443,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
control={form.control} control={form.control}
name="variants" name="variants"
render={({ field: { value, onChange, ...field } }) => { render={({ field: { value, onChange, ...field } }) => {
const selectedOptions = value.map((v) => v.options) const selectedOptions = (value ?? []).map(
(v) => v.options
)
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional> <Form.Label optional>
@@ -56,10 +56,6 @@ export const CreateProductForm = () => {
const form = useForm<Schema>({ const form = useForm<Schema>({
defaultValues: { defaultValues: {
title: "",
subtitle: "",
handle: "",
description: "",
discountable: true, discountable: true,
tags: [], tags: [],
sales_channels: [], sales_channels: [],
@@ -83,11 +79,12 @@ export const CreateProductForm = () => {
length: values.length ? parseFloat(values.length) : undefined, length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined, height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined, weight: values.weight ? parseFloat(values.weight) : undefined,
variants: values.variants, variants: values.variants.map((variant) => ({
...variant,
prices: [],
})),
} as any } as any
delete reqData.sales_channels
await mutateAsync(reqData, { await mutateAsync(reqData, {
onSuccess: ({ product }) => { onSuccess: ({ product }) => {
handleSuccess(`../${product.id}`) handleSuccess(`../${product.id}`)
@@ -3,6 +3,8 @@ import { Product } from "@medusajs/medusa"
import { Container, Heading, Text } from "@medusajs/ui" import { Container, Heading, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
import { getFormattedCountry } from "../../../../../lib/addresses"
type ProductAttributeSectionProps = { type ProductAttributeSectionProps = {
product: Product product: Product
@@ -31,62 +33,16 @@ export const ProductAttributeSection = ({
]} ]}
/> />
</div> </div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4"> <SectionRow title={t("fields.height")} value={product.height} />
<Text size="small" weight="plus" leading="compact"> <SectionRow title={t("fields.width")} value={product.width} />
{t("fields.height")} <SectionRow title={t("fields.length")} value={product.length} />
</Text> <SectionRow title={t("fields.weight")} value={product.weight} />
<Text size="small" leading="compact" className="text-pretty"> <SectionRow title={t("fields.midCode")} value={product.mid_code} />
{product.height ?? "-"} <SectionRow title={t("fields.hsCode")} value={product.hs_code} />
</Text> <SectionRow
</div> title={t("fields.countryOfOrigin")}
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4"> value={getFormattedCountry(product.origin_country)}
<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>
</Container> </Container>
) )
} }
@@ -5,6 +5,22 @@ import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProduct } from "../../../../../hooks/api/products" 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 = { type ProductGeneralSectionProps = {
product: Product product: Product
@@ -45,7 +61,9 @@ export const ProductGeneralSection = ({
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
<Heading>{product.title}</Heading> <Heading>{product.title}</Heading>
<div className="flex items-center gap-x-4"> <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 <ActionMenu
groups={[ groups={[
{ {
@@ -70,38 +88,14 @@ export const ProductGeneralSection = ({
/> />
</div> </div>
</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"> <SectionRow title={t("fields.description")} value={product.description} />
{t("fields.description")} <SectionRow title={t("fields.subtitle")} value={product.subtitle} />
</Text> <SectionRow title={t("fields.handle")} value={`/${product.handle}`} />
<Text size="small" leading="compact" className="text-pretty"> <SectionRow
{product.description} title={t("fields.discountable")}
</Text> value={product.discountable ? t("fields.true") : t("fields.false")}
</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>
</Container> </Container>
) )
} }
@@ -1,8 +1,64 @@
import { PencilSquare, Plus } from "@medusajs/icons" import { PencilSquare, Plus, Trash } from "@medusajs/icons"
import { Product, ProductOption } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { Badge, Container, Heading, Text } from "@medusajs/ui" import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" 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 = { type ProductOptionSectionProps = {
product: Product product: Product
@@ -31,50 +87,27 @@ export const ProductOptionSection = ({
]} ]}
/> />
</div> </div>
{product.options.map((option) => { {product.options.map((option) => {
return ( return (
<div <SectionRow
title={option.title}
key={option.id} key={option.id}
className="text-ui-fg-subtle grid grid-cols-[1fr_1fr_28px] items-start gap-4 px-6 py-4" value={option.values.map((val) => {
> return (
<Text size="small" leading="compact" weight="plus"> <Badge
{option.title} key={val.value}
</Text> size="2xsmall"
<div className="flex flex-wrap gap-1"> className="flex min-w-[20px] items-center justify-center"
{getUnqiueValues(option).map((value) => { >
return ( {val.value}
<Badge </Badge>
key={value} )
size="2xsmall" })}
className="flex min-w-[20px] items-center justify-center" actions={<OptionActions product={product} option={option} />}
> />
{value}
</Badge>
)
})}
</div>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `options/${option.id}/edit`,
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
) )
})} })}
</Container> </Container>
) )
} }
const getUnqiueValues = (option: ProductOption) => {
const values = option.values.map((v) => v.value)
return Array.from(new Set(values))
}
@@ -1,9 +1,10 @@
import { PencilSquare } from "@medusajs/icons" import { PencilSquare } from "@medusajs/icons"
import { Product } from "@medusajs/medusa" 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 { useTranslation } from "react-i18next"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
type ProductOrganizationSectionProps = { type ProductOrganizationSectionProps = {
product: Product product: Product
@@ -32,58 +33,49 @@ export const ProductOrganizationSection = ({
]} ]}
/> />
</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"> <SectionRow
{t("fields.tags")} title={t("fields.tags")}
</Text> value={
<div className="flex flex-wrap items-center gap-1"> product.tags.length > 0
{product.tags.length > 0
? product.tags.map((tag) => ( ? product.tags.map((tag) => (
<Badge key={tag.id} className="w-fit" size="2xsmall" asChild> <Badge key={tag.id} className="w-fit" size="2xsmall" asChild>
<Link to={`/products?tags=${tag.id}`}>{tag.value}</Link> <Link to={`/products?tags=${tag.id}`}>{tag.value}</Link>
</Badge> </Badge>
)) ))
: "-"} : undefined
</div> }
</div> />
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4"> <SectionRow
<Text size="small" weight="plus" leading="compact"> title={t("fields.type")}
{t("fields.type")} value={
</Text> product.type ? (
{product.type ? ( <Badge size="2xsmall" className="w-fit" asChild>
<Badge size="2xsmall" className="w-fit" asChild> <Link to={`/products?type_id=${product.type_id}`}>
<Link to={`/products?type_id=${product.type_id}`}> {product.type.value}
{product.type.value} </Link>
</Link> </Badge>
</Badge> ) : undefined
) : ( }
<Text size="small" leading="compact"> />
-
</Text> <SectionRow
)} title={t("fields.collection")}
</div> value={
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4"> product.collection ? (
<Text size="small" weight="plus" leading="compact"> <Badge size="2xsmall" color="blue" className="w-fit" asChild>
{t("fields.collection")} <Link to={`/collections/${product.collection.id}`}>
</Text> {product.collection.title}
{product.collection ? ( </Link>
<Badge size="2xsmall" color="blue" className="w-fit" asChild> </Badge>
<Link to={`/collections/${product.collection.id}`}> ) : undefined
{product.collection.title} }
</Link> />
</Badge>
) : ( <SectionRow
<Text size="small" leading="compact"> title={t("fields.categories")}
- value={
</Text> product.categories?.length > 0
)}
</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
? product.categories.map((pcat) => ( ? product.categories.map((pcat) => (
<Badge <Badge
key={pcat.id} key={pcat.id}
@@ -95,9 +87,9 @@ export const ProductOrganizationSection = ({
<Link to={`/categories/${pcat.id}`}>{pcat.name}</Link> <Link to={`/categories/${pcat.id}`}>{pcat.name}</Link>
</Badge> </Badge>
)) ))
: "-"} : undefined
</div> }
</div> />
</Container> </Container>
) )
} }
@@ -1,19 +1,19 @@
import { Channels, PencilSquare } from "@medusajs/icons" import { Channels, PencilSquare } from "@medusajs/icons"
import { Product } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { Container, Heading, Text, Tooltip } from "@medusajs/ui" import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
// import { useAdminSalesChannels } from "medusa-react"
import { Trans, useTranslation } from "react-i18next" import { Trans, useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
type ProductSalesChannelSectionProps = { type ProductSalesChannelSectionProps = {
product: Product product: Product
} }
// TODO: The fetched sales channel doesn't contain all necessary info
export const ProductSalesChannelSection = ({ export const ProductSalesChannelSection = ({
product, product,
}: ProductSalesChannelSectionProps) => { }: ProductSalesChannelSectionProps) => {
// const { count } = useAdminSalesChannels() const { count } = useSalesChannels()
const count = 0
const { t } = useTranslation() const { t } = useTranslation()
const availableInSalesChannels = const availableInSalesChannels =
@@ -29,6 +29,9 @@ export const ProductVariantSection = ({
product.id, product.id,
{ {
...searchParams, ...searchParams,
},
{
keepPreviousData: true,
} }
) )
@@ -69,38 +69,38 @@ export const useProductVariantTableColumns = (product?: Product) => {
const { t } = useTranslation() const { t } = useTranslation()
const optionColumns = useMemo(() => { const optionColumns = useMemo(() => {
return product if (!product) {
? product.options?.map((o) => { return []
return columnHelper.display({ }
id: o.id, return product.options?.map((option) => {
header: () => ( return columnHelper.display({
<div className="flex h-full w-full items-center"> id: option.id,
<span className="truncate">{o.title}</span> header: () => (
</div> <div className="flex h-full w-full items-center">
), <span className="truncate">{option.title}</span>
cell: ({ row }) => { </div>
const value = row.original.options.find( ),
(op) => op.option_id === o.id 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 (
return <PlaceholderCell /> <div className="flex h-full w-full items-center overflow-hidden">
} <Badge
size="2xsmall"
return ( className="flex min-w-[20px] items-center justify-center"
<div className="flex h-full w-full items-center overflow-hidden"> >
<Badge {variantOpt.option_value.value}
size="2xsmall" </Badge>
className="flex min-w-[20px] items-center justify-center" </div>
> )
{value.value} },
</Badge> })
</div> })
)
},
})
})
: []
}, [product]) }, [product])
return useMemo( return useMemo(
@@ -1,13 +1,14 @@
import { AdminProductsRes } from "@medusajs/medusa" import { AdminProductsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js" import { Response } from "@medusajs/medusa-js"
import { adminProductKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom" 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) => ({ const productDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id), queryKey: productsQueryKeys.detail(id),
queryFn: async () => medusa.admin.products.retrieve(id), queryFn: async () => client.products.retrieve(id),
}) })
export const productLoader = async ({ params }: LoaderFunctionArgs) => { export const productLoader = async ({ params }: LoaderFunctionArgs) => {
@@ -16,6 +16,7 @@ import sideBefore from "medusa-admin:widgets/product/details/side/before"
import { ProductOrganizationSection } from "./components/product-organization-section" import { ProductOrganizationSection } from "./components/product-organization-section"
import { useProduct } from "../../../hooks/api/products" import { useProduct } from "../../../hooks/api/products"
// TODO: Use product domain translations only
export const ProductDetail = () => { export const ProductDetail = () => {
const initialData = useLoaderData() as Awaited< const initialData = useLoaderData() as Awaited<
ReturnType<typeof productLoader> ReturnType<typeof productLoader>
@@ -43,31 +44,13 @@ export const ProductDetail = () => {
</div> </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} /> <ProductGeneralSection product={product} />
<ProductMediaSection product={product} /> <ProductMediaSection product={product} />
<ProductOptionSection product={product} /> <ProductOptionSection product={product} />
<ProductVariantSection 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) => { {after.widgets.map((w, i) => {
return ( return (
<div key={i}> <div key={i}>
@@ -75,9 +58,12 @@ export const ProductDetail = () => {
</div> </div>
) )
})} })}
<JsonViewSection data={product} root="product" />
<div className="hidden lg:block">
<JsonViewSection data={product} root="product" />
</div>
</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) => { {sideBefore.widgets.map((w, i) => {
return ( return (
<div key={i}> <div key={i}>
@@ -95,6 +81,10 @@ export const ProductDetail = () => {
</div> </div>
) )
})} })}
<div className="lg:hidden">
<JsonViewSection data={product} root="product" />
</div>
</div> </div>
</div> </div>
<Outlet /> <Outlet />
@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { ProductOption } from "@medusajs/medusa" import { ProductOption } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui" import { Button, Input } from "@medusajs/ui"
import { useAdminUpdateProductOption } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { z } from "zod" import { z } from "zod"
@@ -10,6 +9,7 @@ import {
RouteDrawer, RouteDrawer,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useUpdateProductOption } from "../../../../../hooks/api/products"
type EditProductOptionFormProps = { type EditProductOptionFormProps = {
option: ProductOption option: ProductOption
@@ -32,8 +32,9 @@ export const CreateProductOptionForm = ({
resolver: zodResolver(CreateProductOptionSchema), resolver: zodResolver(CreateProductOptionSchema),
}) })
const { mutateAsync, isLoading } = useAdminUpdateProductOption( const { mutateAsync, isLoading } = useUpdateProductOption(
option.product_id option.product_id,
option.id
) )
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Product, ProductOption, ProductVariant } from "@medusajs/medusa" import { Product, ProductOption, ProductVariant } from "@medusajs/medusa"
import { Button, Heading, Input, Switch } from "@medusajs/ui" import { Button, Heading, Input, Switch } from "@medusajs/ui"
import { useAdminUpdateVariant } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { z } from "zod" import { z } from "zod"
@@ -17,6 +16,7 @@ import {
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { castNumber } from "../../../../../lib/cast-number" import { castNumber } from "../../../../../lib/cast-number"
import { optionalInt } from "../../../../../lib/validation" import { optionalInt } from "../../../../../lib/validation"
import { useUpdateProductVariant } from "../../../../../hooks/api/products"
type ProductEditVariantFormProps = { type ProductEditVariantFormProps = {
product: Product product: Product
@@ -83,7 +83,10 @@ export const ProductEditVariantForm = ({
resolver: zodResolver(ProductEditVariantSchema), resolver: zodResolver(ProductEditVariantSchema),
}) })
const { mutateAsync, isLoading } = useAdminUpdateVariant(product.id) const { mutateAsync, isLoading } = useUpdateProductVariant(
product.id,
variant.id
)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
const parseNumber = (value?: string | number) => { const parseNumber = (value?: string | number) => {
@@ -1,10 +1,10 @@
import { adminProductKeys, adminStoreKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom" import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa" import { medusa, queryClient } from "../../../lib/medusa"
import { productsQueryKeys } from "../../../hooks/api/products"
const queryKey = (id: string) => { const queryKey = (id: string) => {
return [adminProductKeys.detail(id), adminStoreKeys.details()] return [productsQueryKeys.detail(id)]
} }
const queryFn = async (id: string) => { const queryFn = async (id: string) => {
@@ -1,12 +1,12 @@
import { AdminProductsListRes } from "@medusajs/medusa" import { AdminProductsListRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js" import { Response } from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query" import { QueryClient } from "@tanstack/react-query"
import { adminProductKeys } from "medusa-react"
import { medusa, queryClient } from "../../../lib/medusa" import { medusa, queryClient } from "../../../lib/medusa"
import { productsQueryKeys } from "../../../hooks/api/products"
const productsListQuery = () => ({ 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 }), queryFn: async () => medusa.admin.products.list({ limit: 20, offset: 0 }),
}) })
@@ -3,7 +3,6 @@ import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
import { Image, Product } from "@medusajs/medusa" import { Image, Product } from "@medusajs/medusa"
import { Button, CommandBar, Tooltip, clx } from "@medusajs/ui" import { Button, CommandBar, Tooltip, clx } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { useAdminUpdateProduct, useMedusa } from "medusa-react"
import { Fragment, useCallback, useState } from "react" import { Fragment, useCallback, useState } from "react"
import { useFieldArray, useForm } from "react-hook-form" import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -19,6 +18,7 @@ import {
FileType, FileType,
FileUpload, FileUpload,
} from "../../../../../components/common/file-upload" } from "../../../../../components/common/file-upload"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type ProductMediaViewProps = { type ProductMediaViewProps = {
product: Product product: Product
@@ -73,8 +73,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
keyName: "field_id", keyName: "field_id",
}) })
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id) const { mutateAsync, isLoading } = useUpdateProduct(product.id)
const { client } = useMedusa()
const handleSubmit = form.handleSubmit(async ({ media }) => { const handleSubmit = form.handleSubmit(async ({ media }) => {
const urls = media.map((m) => m.url) const urls = media.map((m) => m.url)
@@ -86,15 +85,20 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
if (filesToUpload.length) { if (filesToUpload.length) {
const files = filesToUpload.map((m) => m.file) as File[] const files = filesToUpload.map((m) => m.file) as File[]
const uploads = await client.admin.uploads // TODO: Implement upload to Medusa
.create(files) // const uploads = await client.admin.uploads
.then((res) => { // .create(files)
return res.uploads // .then((res) => {
}) // return res.uploads
.catch((_err) => { // })
// Show error message // .catch((_err) => {
return null // // Show error message
}) // return null
// })
const uploads = files.map((file) => ({
url: URL.createObjectURL(file),
}))
if (!uploads) { if (!uploads) {
return return
@@ -1,7 +1,6 @@
import { Product, SalesChannel } from "@medusajs/medusa" import { Product, SalesChannel } from "@medusajs/medusa"
import { Button, Checkbox } from "@medusajs/ui" import { Button, Checkbox } from "@medusajs/ui"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { useAdminSalesChannels, useAdminUpdateProduct } from "medusa-react"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" 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 { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/use-sales-channel-table-filters"
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query" import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type EditSalesChannelsFormProps = { type EditSalesChannelsFormProps = {
product: Product product: Product
@@ -63,15 +64,14 @@ export const EditSalesChannelsForm = ({
const { searchParams, raw } = useSalesChannelTableQuery({ const { searchParams, raw } = useSalesChannelTableQuery({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}) })
const { sales_channels, count, isLoading, isError, error } = const { sales_channels, count, isLoading, isError, error } = useSalesChannels(
useAdminSalesChannels( {
{ ...searchParams,
...searchParams, },
}, {
{ keepPreviousData: true,
keepPreviousData: true, }
} )
)
const filters = useSalesChannelTableFilters() const filters = useSalesChannelTableFilters()
const columns = useColumns() const columns = useColumns()
@@ -90,9 +90,7 @@ export const EditSalesChannelsForm = ({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}) })
const { mutateAsync, isLoading: isMutating } = useAdminUpdateProduct( const { mutateAsync, isLoading: isMutating } = useUpdateProduct(product.id)
product.id
)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
const arr = data.sales_channels ?? [] const arr = data.sales_channels ?? []
@@ -24,6 +24,7 @@ export const defaultAdminProductsVariantFields = [
"barcode", "barcode",
"*prices", "*prices",
"*options", "*options",
"*options.option_value",
] ]
export const retrieveVariantConfig = { export const retrieveVariantConfig = {
@@ -81,10 +82,10 @@ export const defaultAdminProductFields = [
"*options.values", "*options.values",
"*tags", "*tags",
"*images", "*images",
"*sales_channels",
"*variants", "*variants",
"*variants.prices", "*variants.prices",
"*variants.options", "*variants.options",
"*variants.options.option_value",
"*sales_channels", "*sales_channels",
] ]