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": {
|
"actions": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saveAsDraft": "Save as draft",
|
"saveAsDraft": "Save as draft",
|
||||||
|
"publish": "Publish",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
@@ -157,6 +158,7 @@
|
|||||||
"organization": "Organize",
|
"organization": "Organize",
|
||||||
"editOrganization": "Edit Organization",
|
"editOrganization": "Edit Organization",
|
||||||
"editOptions": "Edit Options",
|
"editOptions": "Edit Options",
|
||||||
|
"editPrices": "Edit prices",
|
||||||
"media": {
|
"media": {
|
||||||
"label": "Media",
|
"label": "Media",
|
||||||
"editHint": "Add media to the product to showcase it in your storefront.",
|
"editHint": "Add media to the product to showcase it in your storefront.",
|
||||||
|
|||||||
+3
-3
@@ -29,19 +29,19 @@ type FieldCoordinates = {
|
|||||||
|
|
||||||
export interface DataGridRootProps<
|
export interface DataGridRootProps<
|
||||||
TData,
|
TData,
|
||||||
TFieldValues extends FieldValues = FieldValues
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
> {
|
> {
|
||||||
data?: TData[]
|
data?: TData[]
|
||||||
columns: ColumnDef<TData>[]
|
columns: ColumnDef<TData>[]
|
||||||
state: UseFormReturn<TFieldValues>
|
state: UseFormReturn<TFieldValues>
|
||||||
getSubRows: (row: TData) => TData[] | undefined
|
getSubRows?: (row: TData) => TData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 40
|
const ROW_HEIGHT = 40
|
||||||
|
|
||||||
export const DataGridRoot = <
|
export const DataGridRoot = <
|
||||||
TData,
|
TData,
|
||||||
TFieldValues extends FieldValues = FieldValues
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
>({
|
>({
|
||||||
data = [],
|
data = [],
|
||||||
columns,
|
columns,
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export const v2Routes: RouteObject[] = [
|
|||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../v2-routes/products/product-media"),
|
import("../../v2-routes/products/product-media"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "prices",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../v2-routes/products/product-prices"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "options/create",
|
path: "options/create",
|
||||||
lazy: () =>
|
lazy: () =>
|
||||||
|
|||||||
+7
-4
@@ -127,10 +127,13 @@ export const PricingCreateForm = () => {
|
|||||||
) => {
|
) => {
|
||||||
form.clearErrors(fields)
|
form.clearErrors(fields)
|
||||||
|
|
||||||
const values = fields.reduce((acc, key) => {
|
const values = fields.reduce(
|
||||||
acc[key] = form.getValues(key)
|
(acc, key) => {
|
||||||
return acc
|
acc[key] = form.getValues(key)
|
||||||
}, {} as Record<string, unknown>)
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, unknown>
|
||||||
|
)
|
||||||
|
|
||||||
const validationResult = schema.safeParse(values)
|
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
|
||||||
|
}
|
||||||
-124
@@ -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
@@ -1 +0,0 @@
|
|||||||
export * from "./create-product-form"
|
|
||||||
+175
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+22
-22
@@ -14,27 +14,28 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
|
|||||||
import { SalesChannel } from "@medusajs/medusa"
|
import { SalesChannel } from "@medusajs/medusa"
|
||||||
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||||
import { Fragment, useMemo, useState } from "react"
|
import { Fragment, useMemo, useState } from "react"
|
||||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
import { CountrySelect } from "../../../../components/common/country-select"
|
||||||
import { Form } from "../../../../../components/common/form"
|
import { Form } from "../../../../components/common/form"
|
||||||
import { HandleInput } from "../../../../../components/common/handle-input"
|
import { HandleInput } from "../../../../components/common/handle-input"
|
||||||
import { DataTable } from "../../../../../components/table/data-table"
|
import { DataTable } from "../../../../components/table/data-table"
|
||||||
import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns"
|
import { useSalesChannelTableColumns } from "../../../../hooks/table/columns/use-sales-channel-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 { CreateProductFormReturn } from "./create-product-form"
|
import { Combobox } from "../../../../components/common/combobox"
|
||||||
import { Combobox } from "../../../../../components/common/combobox"
|
import { FileUpload } from "../../../../components/common/file-upload"
|
||||||
import { FileUpload } from "../../../../../components/common/file-upload"
|
import { List } from "../../../../components/common/list"
|
||||||
import { List } from "../../../../../components/common/list"
|
import { useProductTypes } from "../../../../hooks/api/product-types"
|
||||||
import { useProductTypes } from "../../../../../hooks/api/product-types"
|
import { useCollections } from "../../../../hooks/api/collections"
|
||||||
import { useCollections } from "../../../../../hooks/api/collections"
|
import { useSalesChannels } from "../../../../hooks/api/sales-channels"
|
||||||
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
|
import { useCategories } from "../../../../hooks/api/categories"
|
||||||
import { useCategories } from "../../../../../hooks/api/categories"
|
import { useTags } from "../../../../hooks/api/tags"
|
||||||
import { useTags } from "../../../../../hooks/api/tags"
|
import { Keypair } from "../../../../components/common/keypair"
|
||||||
import { Keypair } from "../../../../../components/common/keypair"
|
import { UseFormReturn } from "react-hook-form"
|
||||||
|
import { CreateProductSchemaType } from "../schema"
|
||||||
|
|
||||||
type CreateProductPropsProps = {
|
type ProductAttributesProps = {
|
||||||
form: CreateProductFormReturn
|
form: UseFormReturn<CreateProductSchemaType>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPPORTED_FORMATS = [
|
const SUPPORTED_FORMATS = [
|
||||||
@@ -76,7 +77,7 @@ const generateNameFromPermutation = (permutation: {
|
|||||||
return Object.values(permutation).join(" / ")
|
return Object.values(permutation).join(" / ")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
export const ProductAttributesForm = ({ form }: ProductAttributesProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, onOpenChange] = useState(false)
|
const [open, onOpenChange] = useState(false)
|
||||||
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
|
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
|
||||||
@@ -466,7 +467,6 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
|||||||
generateNameFromPermutation(options),
|
generateNameFromPermutation(options),
|
||||||
variant_rank: i,
|
variant_rank: i,
|
||||||
options,
|
options,
|
||||||
prices: [],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import { RouteFocusModal } from "../../../components/route-modal"
|
import { RouteFocusModal } from "../../../components/route-modal"
|
||||||
import { CreateProductForm } from "./components/create-product-form"
|
import { CreateProductPage } from "./components/create-product"
|
||||||
|
|
||||||
export const ProductCreate = () => {
|
export const ProductCreate = () => {
|
||||||
return (
|
return (
|
||||||
<RouteFocusModal>
|
<RouteFocusModal>
|
||||||
<CreateProductForm />
|
<CreateProductPage />
|
||||||
</RouteFocusModal>
|
</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>
|
||||||
+6
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Plus } from "@medusajs/icons"
|
import { PencilSquare, Plus } from "@medusajs/icons"
|
||||||
import { Product } from "@medusajs/medusa"
|
import { Product } from "@medusajs/medusa"
|
||||||
import { Container, Heading } from "@medusajs/ui"
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -67,6 +67,11 @@ export const ProductVariantSection = ({
|
|||||||
to: `variants/create`,
|
to: `variants/create`,
|
||||||
icon: <Plus />,
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+21
@@ -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