feat: Revamp product details page and several product fixes and cleanups (#6988)
This commit is contained in:
@@ -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"
|
||||
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"
|
||||
>
|
||||
) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -29,6 +29,9 @@ export const ProductVariantSection = ({
|
||||
product.id,
|
||||
{
|
||||
...searchParams,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
Reference in New Issue
Block a user