feat: Add support for price setting and updates for products (#7037)
We still need to add a `batch` method for variants, but I'll do that in a separate PR
This commit is contained in:
@@ -58,6 +58,7 @@
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"saveAsDraft": "Save as draft",
|
||||
"publish": "Publish",
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
@@ -157,6 +158,7 @@
|
||||
"organization": "Organize",
|
||||
"editOrganization": "Edit Organization",
|
||||
"editOptions": "Edit Options",
|
||||
"editPrices": "Edit prices",
|
||||
"media": {
|
||||
"label": "Media",
|
||||
"editHint": "Add media to the product to showcase it in your storefront.",
|
||||
|
||||
@@ -29,19 +29,19 @@ type FieldCoordinates = {
|
||||
|
||||
export interface DataGridRootProps<
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
> {
|
||||
data?: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
state: UseFormReturn<TFieldValues>
|
||||
getSubRows: (row: TData) => TData[] | undefined
|
||||
getSubRows?: (row: TData) => TData[]
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 40
|
||||
|
||||
export const DataGridRoot = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
>({
|
||||
data = [],
|
||||
columns,
|
||||
|
||||
@@ -98,6 +98,11 @@ export const v2Routes: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../v2-routes/products/product-media"),
|
||||
},
|
||||
{
|
||||
path: "prices",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/products/product-prices"),
|
||||
},
|
||||
{
|
||||
path: "options/create",
|
||||
lazy: () =>
|
||||
|
||||
@@ -127,10 +127,13 @@ export const PricingCreateForm = () => {
|
||||
) => {
|
||||
form.clearErrors(fields)
|
||||
|
||||
const values = fields.reduce((acc, key) => {
|
||||
acc[key] = form.getValues(key)
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
const values = fields.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = form.getValues(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
)
|
||||
|
||||
const validationResult = schema.safeParse(values)
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { UseFormReturn, useWatch } from "react-hook-form"
|
||||
import { CreateProductSchemaType } from "../product-create/schema"
|
||||
import { DataGrid } from "../../../components/grid/data-grid"
|
||||
import { useCurrencies } from "../../../hooks/api/currencies"
|
||||
import { useStore } from "../../../hooks/api/store"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useMemo } from "react"
|
||||
import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell"
|
||||
import { CurrencyCell } from "../../../components/grid/grid-cells/common/currency-cell"
|
||||
import { DataGridMeta } from "../../../components/grid/types"
|
||||
|
||||
type VariantPricingFormProps = {
|
||||
form: UseFormReturn<CreateProductSchemaType>
|
||||
}
|
||||
|
||||
export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
|
||||
const { store, isLoading: isStoreLoading } = useStore()
|
||||
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies(
|
||||
{
|
||||
code: store?.supported_currency_codes,
|
||||
limit: store?.supported_currency_codes?.length,
|
||||
},
|
||||
{
|
||||
enabled: !!store,
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useVariantPriceGridColumns({
|
||||
currencies,
|
||||
})
|
||||
|
||||
const variants = useWatch({
|
||||
control: form.control,
|
||||
name: "variants",
|
||||
}) as any
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col divide-y overflow-hidden">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={variants}
|
||||
isLoading={isStoreLoading || isCurrenciesLoading}
|
||||
state={form}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<ProductVariantDTO>()
|
||||
|
||||
export const useVariantPriceGridColumns = ({
|
||||
currencies = [],
|
||||
}: {
|
||||
currencies?: CurrencyDTO[]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const colDefs: ColumnDef<ProductVariantDTO>[] = useMemo(() => {
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: t("fields.title"),
|
||||
header: t("fields.title"),
|
||||
cell: ({ row }) => {
|
||||
const entity = row.original
|
||||
|
||||
return (
|
||||
<ReadonlyCell>
|
||||
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||
<span className="truncate">{entity.title}</span>
|
||||
</div>
|
||||
</ReadonlyCell>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...currencies.map((currency) => {
|
||||
return columnHelper.display({
|
||||
header: `Price ${currency.code.toUpperCase()}`,
|
||||
cell: ({ row, table }) => {
|
||||
return (
|
||||
<CurrencyCell
|
||||
currency={currency}
|
||||
meta={table.options.meta as DataGridMeta}
|
||||
field={`variants.${row.index}.prices.${currency.code}`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
}, [t, currencies])
|
||||
|
||||
return colDefs
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { UseFormReturn, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { CreateProductDetails } from "./create-product-details"
|
||||
import { useCreateProduct } from "../../../../../hooks/api/products"
|
||||
|
||||
const CreateProductSchema = zod.object({
|
||||
title: zod.string(),
|
||||
subtitle: zod.string().optional(),
|
||||
handle: zod.string().optional(),
|
||||
description: zod.string().optional(),
|
||||
discountable: zod.boolean(),
|
||||
type_id: zod.string().optional(),
|
||||
collection_id: zod.string().optional(),
|
||||
category_ids: zod.array(zod.string()).optional(),
|
||||
tags: zod.array(zod.string()).optional(),
|
||||
sales_channels: zod.array(zod.string()).optional(),
|
||||
origin_country: zod.string().optional(),
|
||||
material: zod.string().optional(),
|
||||
width: zod.string().optional(),
|
||||
length: zod.string().optional(),
|
||||
height: zod.string().optional(),
|
||||
weight: zod.string().optional(),
|
||||
mid_code: zod.string().optional(),
|
||||
hs_code: zod.string().optional(),
|
||||
options: zod.array(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
values: zod.array(zod.string()),
|
||||
})
|
||||
),
|
||||
variants: zod.array(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
options: zod.record(zod.string(), zod.string()),
|
||||
variant_rank: zod.number(),
|
||||
})
|
||||
),
|
||||
images: zod.array(zod.string()).optional(),
|
||||
thumbnail: zod.string().optional(),
|
||||
})
|
||||
|
||||
type Schema = zod.infer<typeof CreateProductSchema>
|
||||
export type CreateProductFormReturn = UseFormReturn<Schema>
|
||||
|
||||
export const CreateProductForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
discountable: true,
|
||||
tags: [],
|
||||
sales_channels: [],
|
||||
options: [],
|
||||
variants: [],
|
||||
images: [],
|
||||
},
|
||||
resolver: zodResolver(CreateProductSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = useCreateProduct()
|
||||
|
||||
const handleSubmit = form.handleSubmit(
|
||||
async (values) => {
|
||||
const reqData = {
|
||||
...values,
|
||||
is_giftcard: false,
|
||||
tags: values.tags?.map((tag) => ({ value: tag })),
|
||||
sales_channels: values.sales_channels?.map((sc) => ({ id: sc })),
|
||||
width: values.width ? parseFloat(values.width) : undefined,
|
||||
length: values.length ? parseFloat(values.length) : undefined,
|
||||
height: values.height ? parseFloat(values.height) : undefined,
|
||||
weight: values.weight ? parseFloat(values.weight) : undefined,
|
||||
variants: values.variants.map((variant) => ({
|
||||
...variant,
|
||||
prices: [],
|
||||
})),
|
||||
} as any
|
||||
|
||||
await mutateAsync(reqData, {
|
||||
onSuccess: ({ product }) => {
|
||||
handleSuccess(`../${product.id}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
console.log(err)
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button type="submit" size="small" isLoading={isLoading}>
|
||||
{t("actions.saveAsDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
||||
<div className="flex h-full w-full">
|
||||
<CreateProductDetails form={form} />
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./create-product-form"
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../components/route-modal"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
CreateProductSchema,
|
||||
CreateProductSchemaType,
|
||||
defaults,
|
||||
normalize,
|
||||
} from "../schema"
|
||||
import { useCreateProduct } from "../../../../hooks/api/products"
|
||||
import { ProductAttributesForm } from "./product-attributes-form"
|
||||
import { VariantPricingForm } from "../../common/variant-pricing-form"
|
||||
|
||||
enum Tab {
|
||||
PRODUCT = "product",
|
||||
PRICE = "price",
|
||||
}
|
||||
type TabState = Record<Tab, ProgressStatus>
|
||||
|
||||
export const CreateProductPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const [tab, setTab] = useState<Tab>(Tab.PRODUCT)
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const form = useForm<CreateProductSchemaType>({
|
||||
defaultValues: defaults,
|
||||
resolver: zodResolver(CreateProductSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = useCreateProduct()
|
||||
|
||||
const handleSubmit = form.handleSubmit(
|
||||
async (values, e) => {
|
||||
if (!(e?.nativeEvent instanceof SubmitEvent)) return
|
||||
const submitter = e?.nativeEvent?.submitter as HTMLButtonElement
|
||||
if (!(submitter instanceof HTMLButtonElement)) return
|
||||
const isDraftSubmission = submitter.dataset.name === "save-draft-button"
|
||||
|
||||
await mutateAsync(
|
||||
normalize({
|
||||
...values,
|
||||
status: (isDraftSubmission ? "draft" : "published") as any,
|
||||
}),
|
||||
{
|
||||
onSuccess: ({ product }) => {
|
||||
handleSuccess(`../${product.id}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
(err) => {
|
||||
console.log(err)
|
||||
}
|
||||
)
|
||||
const tabState: TabState = {
|
||||
[Tab.PRODUCT]: tab === Tab.PRODUCT ? "in-progress" : "completed",
|
||||
[Tab.PRICE]: tab === Tab.PRICE ? "in-progress" : "not-started",
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||
<ProgressTabs
|
||||
value={tab}
|
||||
onValueChange={(tab) => setTab(tab as Tab)}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex w-full items-center justify-between gap-x-4">
|
||||
<div className="-my-2 w-full max-w-[400px] border-l">
|
||||
<ProgressTabs.List className="grid w-full grid-cols-3">
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState.product}
|
||||
value={Tab.PRODUCT}
|
||||
>
|
||||
Products
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState.price}
|
||||
value={Tab.PRICE}
|
||||
>
|
||||
Prices
|
||||
</ProgressTabs.Trigger>
|
||||
</ProgressTabs.List>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
data-name="save-draft-button"
|
||||
variant="primary"
|
||||
size="small"
|
||||
key="submit-button"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{t("actions.saveAsDraft")}
|
||||
</Button>
|
||||
<PrimaryButton
|
||||
tab={tab}
|
||||
next={() => setTab(Tab.PRICE)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.PRODUCT}
|
||||
>
|
||||
<div className="flex h-full w-full">
|
||||
<ProductAttributesForm form={form} />
|
||||
</div>
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.PRICE}
|
||||
>
|
||||
<VariantPricingForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
</RouteFocusModal.Body>
|
||||
</ProgressTabs>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
|
||||
type PrimaryButtonProps = {
|
||||
tab: Tab
|
||||
next: (tab: Tab) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const PrimaryButton = ({ tab, next, isLoading }: PrimaryButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (tab === Tab.PRICE) {
|
||||
return (
|
||||
<Button
|
||||
data-name="publish-button"
|
||||
key="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{t("actions.publish")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key="next-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => next(tab)}
|
||||
>
|
||||
{t("actions.continue")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -14,27 +14,28 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
|
||||
import { SalesChannel } from "@medusajs/medusa"
|
||||
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||
import { Fragment, useMemo, useState } from "react"
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { HandleInput } from "../../../../../components/common/handle-input"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-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 { CreateProductFormReturn } from "./create-product-form"
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { FileUpload } from "../../../../../components/common/file-upload"
|
||||
import { List } from "../../../../../components/common/list"
|
||||
import { useProductTypes } from "../../../../../hooks/api/product-types"
|
||||
import { useCollections } from "../../../../../hooks/api/collections"
|
||||
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
|
||||
import { useCategories } from "../../../../../hooks/api/categories"
|
||||
import { useTags } from "../../../../../hooks/api/tags"
|
||||
import { Keypair } from "../../../../../components/common/keypair"
|
||||
import { CountrySelect } from "../../../../components/common/country-select"
|
||||
import { Form } from "../../../../components/common/form"
|
||||
import { HandleInput } from "../../../../components/common/handle-input"
|
||||
import { DataTable } from "../../../../components/table/data-table"
|
||||
import { useSalesChannelTableColumns } from "../../../../hooks/table/columns/use-sales-channel-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 { Combobox } from "../../../../components/common/combobox"
|
||||
import { FileUpload } from "../../../../components/common/file-upload"
|
||||
import { List } from "../../../../components/common/list"
|
||||
import { useProductTypes } from "../../../../hooks/api/product-types"
|
||||
import { useCollections } from "../../../../hooks/api/collections"
|
||||
import { useSalesChannels } from "../../../../hooks/api/sales-channels"
|
||||
import { useCategories } from "../../../../hooks/api/categories"
|
||||
import { useTags } from "../../../../hooks/api/tags"
|
||||
import { Keypair } from "../../../../components/common/keypair"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { CreateProductSchemaType } from "../schema"
|
||||
|
||||
type CreateProductPropsProps = {
|
||||
form: CreateProductFormReturn
|
||||
type ProductAttributesProps = {
|
||||
form: UseFormReturn<CreateProductSchemaType>
|
||||
}
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
@@ -76,7 +77,7 @@ const generateNameFromPermutation = (permutation: {
|
||||
return Object.values(permutation).join(" / ")
|
||||
}
|
||||
|
||||
export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
export const ProductAttributesForm = ({ form }: ProductAttributesProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, onOpenChange] = useState(false)
|
||||
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
|
||||
@@ -466,7 +467,6 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
generateNameFromPermutation(options),
|
||||
variant_rank: i,
|
||||
options,
|
||||
prices: [],
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -1,10 +1,10 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { CreateProductForm } from "./components/create-product-form"
|
||||
import { CreateProductPage } from "./components/create-product"
|
||||
|
||||
export const ProductCreate = () => {
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<CreateProductForm />
|
||||
<CreateProductPage />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CreateProductDTO, CreateProductVariantDTO } from "@medusajs/types"
|
||||
import * as zod from "zod"
|
||||
|
||||
export const CreateProductSchema = zod.object({
|
||||
title: zod.string(),
|
||||
subtitle: zod.string().optional(),
|
||||
handle: zod.string().optional(),
|
||||
description: zod.string().optional(),
|
||||
discountable: zod.boolean(),
|
||||
type_id: zod.string().optional(),
|
||||
collection_id: zod.string().optional(),
|
||||
category_ids: zod.array(zod.string()).optional(),
|
||||
tags: zod.array(zod.string()).optional(),
|
||||
sales_channels: zod.array(zod.string()).optional(),
|
||||
origin_country: zod.string().optional(),
|
||||
material: zod.string().optional(),
|
||||
width: zod.string().optional(),
|
||||
length: zod.string().optional(),
|
||||
height: zod.string().optional(),
|
||||
weight: zod.string().optional(),
|
||||
mid_code: zod.string().optional(),
|
||||
hs_code: zod.string().optional(),
|
||||
options: zod.array(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
values: zod.array(zod.string()),
|
||||
})
|
||||
),
|
||||
variants: zod.array(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
options: zod.record(zod.string(), zod.string()),
|
||||
variant_rank: zod.number(),
|
||||
prices: zod.record(zod.string(), zod.string()).optional(),
|
||||
})
|
||||
),
|
||||
images: zod.array(zod.string()).optional(),
|
||||
thumbnail: zod.string().optional(),
|
||||
})
|
||||
|
||||
export const defaults = {
|
||||
discountable: true,
|
||||
tags: [],
|
||||
sales_channels: [],
|
||||
options: [],
|
||||
variants: [],
|
||||
images: [],
|
||||
}
|
||||
|
||||
export const normalize = (
|
||||
values: CreateProductSchemaType & { status: CreateProductDTO["status"] }
|
||||
) => {
|
||||
const reqData = {
|
||||
...values,
|
||||
is_giftcard: false,
|
||||
tags: values.tags?.map((tag) => ({ value: tag })),
|
||||
sales_channels: values.sales_channels?.map((sc) => ({ id: sc })),
|
||||
width: values.width ? parseFloat(values.width) : undefined,
|
||||
length: values.length ? parseFloat(values.length) : undefined,
|
||||
height: values.height ? parseFloat(values.height) : undefined,
|
||||
weight: values.weight ? parseFloat(values.weight) : undefined,
|
||||
variants: normalizeVariants(values.variants as any),
|
||||
} as any
|
||||
|
||||
return reqData
|
||||
}
|
||||
|
||||
export const normalizeVariants = (
|
||||
variants: (Partial<CreateProductVariantDTO> & {
|
||||
prices?: Record<string, string>
|
||||
})[]
|
||||
) => {
|
||||
return variants.map((variant) => ({
|
||||
...variant,
|
||||
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
|
||||
currency_code: key,
|
||||
amount: value ? parseFloat(value) : 0,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export type CreateProductSchemaType = zod.infer<typeof CreateProductSchema>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus } from "@medusajs/icons"
|
||||
import { PencilSquare, Plus } from "@medusajs/icons"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -67,6 +67,11 @@ export const ProductVariantSection = ({
|
||||
to: `variants/create`,
|
||||
icon: <Plus />,
|
||||
},
|
||||
{
|
||||
label: t("products.editPrices"),
|
||||
to: `prices`,
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductPrices as Component } from "./product-prices"
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useUpdateProductVariant } from "../../../hooks/api/products"
|
||||
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { ExtendedProductDTO } from "../../../types/api-responses"
|
||||
import { VariantPricingForm } from "../common/variant-pricing-form"
|
||||
import { normalizeVariants } from "../product-create/schema"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
export const UpdateVariantPricesSchema = zod.object({
|
||||
variants: zod.array(
|
||||
zod.object({
|
||||
prices: zod.record(zod.string(), zod.string()).optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export type UpdateVariantPricesSchemaType = zod.infer<
|
||||
typeof UpdateVariantPricesSchema
|
||||
>
|
||||
|
||||
export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const form = useForm<UpdateVariantPricesSchemaType>({
|
||||
defaultValues: {
|
||||
variants: product.variants.map((variant: any) => ({
|
||||
prices: variant.prices.reduce((acc: any, price: any) => {
|
||||
acc[price.currency_code] = price.amount
|
||||
return acc
|
||||
}, {}),
|
||||
})) as any,
|
||||
},
|
||||
|
||||
resolver: zodResolver(UpdateVariantPricesSchema, {}),
|
||||
})
|
||||
|
||||
// TODO: Add batch update method here
|
||||
const { mutateAsync, isLoading } = useUpdateProductVariant(product.id, "")
|
||||
|
||||
const handleSubmit = form.handleSubmit(
|
||||
async (values) => {
|
||||
const reqData = { variants: normalizeVariants(values.variants) }
|
||||
await mutateAsync(reqData, {
|
||||
onSuccess: () => {
|
||||
handleSuccess(`../${product.id}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
console.log(err)
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex w-full items-center justify-end gap-x-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body>
|
||||
<VariantPricingForm form={form as any} />
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
import { useProduct } from "../../../hooks/api/products"
|
||||
import { PricingEdit } from "./pricing-edit"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
|
||||
export const ProductPrices = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { product, isLoading, isError, error } = useProduct(id!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{!isLoading && product && <PricingEdit product={product} />}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user