feat(dashboard): Initial pricing domain (#6996)

**What**
- Sets up the initial work for Pricing domain
- Fixes Store domain

**Todo in follow up PR**
- Translations
- Add status when creating the Price List and allow updating it
- Improve DataGrid component
- Add missing functionality once backend support is added (customer_groups, region prices and update prices)

CLOSES CORE-1931
This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2024-04-08 18:22:53 +00:00
1 parent 6cc9a5e469
commit 0f05790ae4
81 files changed
+2628 -745

No files matched your search

+2 -2
View File
@@ -38,12 +38,12 @@
"qs": "^6.12.0", "qs": "^6.12.0",
"react": "18.2.0", "react": "18.2.0",
"react-country-flag": "^3.1.0", "react-country-flag": "^3.1.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-focus-lock": "^2.11.1",
"react-hook-form": "7.49.1", "react-hook-form": "7.49.1",
"react-i18next": "13.5.0", "react-i18next": "13.5.0",
"react-jwt": "^1.2.0", "react-jwt": "^1.2.0",
"react-resizable-panels": "^2.0.9", "react-resizable-panels": "^2.0.16",
"react-router-dom": "6.20.1", "react-router-dom": "6.20.1",
"zod": "3.22.4" "zod": "3.22.4"
}, },
@@ -0,0 +1,11 @@
export enum GridCellType {
VOID = "void",
READONLY = "readonly",
EDITABLE = "editable",
OVERLAY = "overlay",
}
export const NON_INTERACTIVE_CELL_TYPES = [
GridCellType.VOID,
GridCellType.READONLY,
]
@@ -1,203 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product, ProductVariant } from "@medusajs/medusa"
import { Button, Container } from "@medusajs/ui"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useAdminProducts } from "medusa-react"
import { useEffect, useMemo } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Thumbnail } from "../common/thumbnail"
import { DataGrid } from "./data-grid"
import { TextField } from "./grid-fields/common/text-field"
import { DisplayField } from "./grid-fields/non-interactive/display-field"
import { DataGridMeta } from "./types"
const ProductEditorSchema = zod.object({
products: zod.record(
zod.object({
variants: zod.record(
zod.object({
title: zod.string(),
sku: zod.string(),
ean: zod.string().optional(),
upc: zod.string().optional(),
})
),
})
),
})
type ProductEditorSchemaType = zod.infer<typeof ProductEditorSchema>
type VariantObject = ProductEditorSchemaType["products"]["id"]["variants"]
const getVariantRows = (row: Product | ProductVariant) => {
if ("variants" in row) {
return row.variants
}
return undefined
}
/**
* Demo component to test the data grid.
*
* To be deleted when the feature is implemented.
*/
export const DataGridDemo = () => {
const form = useForm<ProductEditorSchemaType>({
resolver: zodResolver(ProductEditorSchema),
})
const { setValue } = form
const { products, isLoading } = useAdminProducts(
{
expand: "variants,variants.prices",
},
{
keepPreviousData: true,
}
)
useEffect(() => {
if (!isLoading && products) {
products.forEach((product) => {
setValue(`products.${product.id}.variants`, {
...product.variants.reduce((variants, variant) => {
variants[variant.id!] = {
title: variant.title || "",
sku: variant.sku || "",
ean: variant.ean || "",
upc: variant.upc || "",
}
return variants
}, {} as VariantObject),
})
})
}
}, [products, isLoading, setValue])
const columns = useColumns()
const initializing = isLoading || !products
const handleSubmit = form.handleSubmit((data) => {
console.log("submitting", data)
})
return (
<Container className="overflow-hidden p-0">
<DataGrid
isLoading={initializing}
data={products as Product[]}
columns={columns}
state={form}
getSubRows={getVariantRows}
/>
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<Button size="small" onClick={handleSubmit}>
Submit
</Button>
</div>
</Container>
)
}
/**
* Helper function to determine if a row is a product or a variant.
*/
const isProduct = (row: Product | ProductVariant): row is Product => {
return "variants" in row
}
const columnHelper = createColumnHelper<Product | ProductVariant>()
const useColumns = () => {
const { t } = useTranslation()
const colDefs: ColumnDef<Product | ProductVariant>[] = useMemo(() => {
return [
columnHelper.display({
id: t("fields.title"),
header: "Title",
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return (
<DisplayField>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Thumbnail src={entity.thumbnail} />
<span className="truncate">{entity.title}</span>
</div>
</DisplayField>
)
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.title`}
/>
)
},
size: 350,
}),
columnHelper.accessor("sku", {
header: t("fields.sku"),
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.sku`}
/>
)
},
}),
columnHelper.accessor("ean", {
header: "EAN",
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.ean`}
/>
)
},
}),
columnHelper.accessor("upc", {
header: "UPC",
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.upc`}
/>
)
},
}),
]
}, [t])
return colDefs
}
@@ -20,6 +20,7 @@ import {
Command, Command,
useCommandHistory, useCommandHistory,
} from "../../../../hooks/use-command-history" } from "../../../../hooks/use-command-history"
import { GridCellType, NON_INTERACTIVE_CELL_TYPES } from "../../constants"
type FieldCoordinates = { type FieldCoordinates = {
column: number column: number
@@ -28,9 +29,9 @@ 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[] | undefined
@@ -40,9 +41,9 @@ const ROW_HEIGHT = 40
export const DataGridRoot = < export const DataGridRoot = <
TData, TData,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues
>({ >({
data, data = [],
columns, columns,
state, state,
getSubRows, getSubRows,
@@ -99,17 +100,35 @@ export const DataGridRoot = <
} }
} }
const handleBlurAnchor = () => {
const activeElement = document.activeElement
if (anchor && activeElement instanceof HTMLElement) {
activeElement.blur()
}
}
const isNonInteractive = (element: HTMLElement) => {
const type = element.getAttribute("data-cell-type")
if (!type) {
return true
}
return NON_INTERACTIVE_CELL_TYPES.includes(type as GridCellType)
}
const handleMouseDown = (e: ReactMouseEvent<HTMLTableCellElement>) => { const handleMouseDown = (e: ReactMouseEvent<HTMLTableCellElement>) => {
e.stopPropagation()
e.preventDefault()
const target = e.target const target = e.target
/** /**
* Check if the click was on a presentation element. * Check if the click was on a presentation element.
* If so, we don't want to set the anchor. * If so, we don't want to set the anchor.
*/ */
if ( if (target instanceof HTMLElement && isNonInteractive(target)) {
target instanceof HTMLElement &&
target.querySelector("[data-role=presentation]")
) {
return return
} }
@@ -121,6 +140,9 @@ export const DataGridRoot = <
if (e.detail === 2 || isAnchor) { if (e.detail === 2 || isAnchor) {
handleFocusInner(e.currentTarget) handleFocusInner(e.currentTarget)
return return
} else {
// reset focus so the previous cell doesn't keep the focus
handleBlurAnchor()
} }
const coordinates: FieldCoordinates = { const coordinates: FieldCoordinates = {
@@ -157,10 +179,7 @@ export const DataGridRoot = <
* Check if the click was on a presentation element. * Check if the click was on a presentation element.
* If so, we don't want to add it to the selection. * If so, we don't want to add it to the selection.
*/ */
if ( if (target instanceof HTMLElement && isNonInteractive(target)) {
target instanceof HTMLElement &&
target.querySelector("[data-role=presentation]")
) {
return return
} }
@@ -487,7 +506,7 @@ export const DataGridRoot = <
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory]) }, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
return ( return (
<div className="size-full overflow-hidden"> <div className="bg-ui-bg-subtle size-full overflow-hidden">
<div <div
ref={tableContainerRef} ref={tableContainerRef}
style={{ style={{
@@ -559,10 +578,11 @@ export const DataGridRoot = <
data-column-index={index} data-column-index={index}
className={clx( className={clx(
"bg-ui-bg-base has-[[data-role='presentation']]:bg-ui-bg-subtle relative flex items-center border-b border-r p-0 outline-none", "bg-ui-bg-base has-[[data-role='presentation']]:bg-ui-bg-subtle relative flex items-center border-b border-r p-0 outline-none",
"after:transition-fg after:border-ui-fg-interactive after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']", "after:transition-fg after:border-ui-fg-interactive after:pointer-events-none after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']",
{ {
"after:visible": isAnchor, "after:visible": isAnchor,
"bg-ui-bg-highlight": isSelected, "bg-ui-bg-highlight focus-within:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-base-hover": isDragTarget, "bg-ui-bg-base-hover": isDragTarget,
} }
)} )}
@@ -1,14 +1,14 @@
import { Select } from "@medusajs/ui" import { Select } from "@medusajs/ui"
import { Controller, FieldValues } from "react-hook-form" import { Controller, FieldValues } from "react-hook-form"
import { FieldProps } from "../../../types" import { CellProps } from "../../../types"
interface BooleanFieldProps<TFieldValues extends FieldValues = any> interface BooleanCellProps<TFieldValues extends FieldValues = any>
extends FieldProps<TFieldValues> {} extends CellProps<TFieldValues> {}
export const BooleanField = <TFieldValues extends FieldValues = any>({ export const BooleanCell = <TFieldValues extends FieldValues = any>({
field, field,
meta, meta,
}: BooleanFieldProps<TFieldValues>) => { }: BooleanCellProps<TFieldValues>) => {
const { control } = meta const { control } = meta
return ( return (
@@ -0,0 +1 @@
export * from "./boolean-cell"
@@ -0,0 +1,57 @@
import { CurrencyDTO } from "@medusajs/types"
import { useRef } from "react"
import Primitive from "react-currency-input-field"
import { Controller, FieldValues } from "react-hook-form"
import { GridCellType } from "../../../constants"
import { CellProps } from "../../../types"
interface CurrencyCellProps<TFieldValues extends FieldValues = any>
extends CellProps<TFieldValues> {
currency: CurrencyDTO
}
export const CurrencyCell = ({ currency, field, meta }: CurrencyCellProps) => {
const symbolRef = useRef<HTMLSpanElement>(null)
// @ts-ignore - Type is wrong
const decimalScale = currency.decimal_digits
const { control } = meta
return (
<Controller
control={control}
name={field}
render={({ field: { onChange, ...rest } }) => {
return (
<div className="relative size-full">
<span
ref={symbolRef}
role="presentation"
className="text-ui-fg-muted txt-compact-small pointer-events-none absolute left-0 top-0 select-none py-2.5 pl-4"
>
{currency.symbol_native}
</span>
<Primitive
data-input-field="true"
data-field-id={field}
data-cell-type={GridCellType.EDITABLE}
className="size-full bg-transparent py-2.5 pr-4 text-right outline-none"
style={{
paddingLeft: symbolRef.current?.offsetWidth
? `${symbolRef.current.offsetWidth + 8}px`
: "16px",
}}
decimalScale={decimalScale}
allowDecimals={decimalScale > 0}
onValueChange={(_value, _name, values) => {
onChange(values?.value)
}}
{...rest}
/>
</div>
)
}}
/>
)
}
@@ -0,0 +1 @@
export * from "./currency-cell"
@@ -0,0 +1 @@
export * from "./readonly-cell"
@@ -0,0 +1,14 @@
import { PropsWithChildren } from "react"
import { GridCellType } from "../../../constants"
export const ReadonlyCell = ({ children }: PropsWithChildren) => {
return (
<div
role="cell"
data-cell-type={GridCellType.READONLY}
className="bg-ui-bg-base size-full cursor-not-allowed px-4 py-2.5"
>
{children}
</div>
)
}
@@ -0,0 +1 @@
export * from "./text-cell"
@@ -1,13 +1,13 @@
import { FieldValues } from "react-hook-form" import { FieldValues } from "react-hook-form"
import { FieldProps } from "../../../types" import { CellProps } from "../../../types"
interface TextFieldProps<TFieldValues extends FieldValues = any> interface TextCellProps<TFieldValues extends FieldValues = any>
extends FieldProps<TFieldValues> {} extends CellProps<TFieldValues> {}
export const TextField = <TFieldValues extends FieldValues = any>({ export const TextCell = <TFieldValues extends FieldValues = any>({
field, field,
meta, meta,
}: TextFieldProps<TFieldValues>) => { }: TextCellProps<TFieldValues>) => {
const { register } = meta const { register } = meta
return ( return (
@@ -0,0 +1 @@
export * from "./void-cell"
@@ -0,0 +1,14 @@
import { PropsWithChildren } from "react"
import { GridCellType } from "../../../constants"
export const VoidCell = ({ children }: PropsWithChildren) => {
return (
<div
role="cell"
data-cell-type={GridCellType.VOID}
className="bg-ui-bg-subtle size-full cursor-not-allowed px-4 py-2.5"
>
{children}
</div>
)
}
@@ -1 +0,0 @@
export * from "./boolean-field"
@@ -1 +0,0 @@
export * from "./text-field"
@@ -1,15 +0,0 @@
import { PropsWithChildren } from "react"
/**
* Field for displaying non-editable data in a grid.
*/
export const DisplayField = ({ children }: PropsWithChildren) => {
return (
<div
className="flex size-full cursor-not-allowed items-center justify-center px-4 py-2.5"
data-role="presentation"
>
{children}
</div>
)
}
@@ -1 +0,0 @@
export * from "./display-field"
@@ -5,7 +5,7 @@ export type DataGridMeta<TFieldValues extends FieldValues = FieldValues> = {
control: Control<TFieldValues> control: Control<TFieldValues>
} }
export interface FieldProps<TFieldValues extends FieldValues = FieldValues> { export interface CellProps<TFieldValues extends FieldValues = FieldValues> {
field: Path<TFieldValues> field: Path<TFieldValues>
meta: DataGridMeta<TFieldValues> meta: DataGridMeta<TFieldValues>
} }
@@ -1,12 +1,17 @@
import { Tooltip } from "@medusajs/ui" import { Tooltip } from "@medusajs/ui"
import format from "date-fns/format" import format from "date-fns/format"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../placeholder-cell"
type DateCellProps = { type DateCellProps = {
date: Date date: Date | string | undefined
} }
export const CreatedAtCell = ({ date }: DateCellProps) => { export const CreatedAtCell = ({ date }: DateCellProps) => {
if (!date) {
return <PlaceholderCell />
}
const value = new Date(date) const value = new Date(date)
value.setMinutes(value.getMinutes() - value.getTimezoneOffset()) value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
@@ -7,10 +7,10 @@ type StatusCellProps = PropsWithChildren<{
export const StatusCell = ({ color, children }: StatusCellProps) => { export const StatusCell = ({ color, children }: StatusCellProps) => {
return ( return (
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-0.5 overflow-hidden"> <div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-2 overflow-hidden">
<div <div
role="presentation" role="presentation"
className="flex size-5 items-center justify-center" className="flex h-5 w-2 items-center justify-center"
> >
<div <div
className={clx( className={clx(
@@ -1,10 +1,10 @@
import {
AdminCustomerGroupListResponse,
AdminCustomerGroupResponse,
} from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query" import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { client } from "../../lib/client" import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import {
AdminCustomerGroupResponse,
AdminCustomerGroupListResponse,
} from "@medusajs/types"
const CUSTOMER_GROUPS_QUERY_KEY = "customer_groups" as const const CUSTOMER_GROUPS_QUERY_KEY = "customer_groups" as const
const customerGroupsQueryKeys = queryKeysFactory(CUSTOMER_GROUPS_QUERY_KEY) const customerGroupsQueryKeys = queryKeysFactory(CUSTOMER_GROUPS_QUERY_KEY)
@@ -0,0 +1,140 @@
import {
QueryKey,
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query"
import { client } from "../../lib/client"
import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
import {
AddPriceListPricesReq,
CreatePriceListReq,
DeletePriceListPricesReq,
UpdatePriceListReq,
} from "../../types/api-payloads"
import {
PriceListDeleteRes,
PriceListListRes,
PriceListRes,
} from "../../types/api-responses"
const PRICE_LISTS_QUERY_KEY = "price-lists" as const
export const priceListsQueryKeys = queryKeysFactory(PRICE_LISTS_QUERY_KEY)
export const usePriceList = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<PriceListRes, Error, PriceListRes, QueryKey>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.priceLists.retrieve(id, query),
queryKey: priceListsQueryKeys.detail(id),
...options,
})
return { ...data, ...rest }
}
export const usePriceLists = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<PriceListListRes, Error, PriceListListRes, QueryKey>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.priceLists.list(query),
queryKey: priceListsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useCreatePriceList = (
options?: UseMutationOptions<PriceListRes, Error, CreatePriceListReq>
) => {
return useMutation({
mutationFn: (payload) => client.priceLists.create(payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.list() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdatePriceList = (
id: string,
options?: UseMutationOptions<PriceListRes, Error, UpdatePriceListReq>
) => {
return useMutation({
mutationFn: (payload) => client.priceLists.update(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.list() })
queryClient.invalidateQueries({
queryKey: priceListsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeletePriceList = (
id: string,
options?: UseMutationOptions<PriceListDeleteRes, Error, void>
) => {
return useMutation({
mutationFn: () => client.priceLists.delete(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.list() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const usePriceListAddPrices = (
id: string,
options?: UseMutationOptions<PriceListRes, Error, AddPriceListPricesReq>
) => {
return useMutation({
mutationFn: (payload) => client.priceLists.addPrices(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: priceListsQueryKeys.detail(id),
})
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.lists() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const usePriceListRemovePrices = (
id: string,
options?: UseMutationOptions<PriceListRes, Error, DeletePriceListPricesReq>
) => {
return useMutation({
mutationFn: (payload) => client.priceLists.removePrices(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: priceListsQueryKeys.detail(id),
})
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.lists() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -1,15 +1,15 @@
import { CustomerGroup } from "@medusajs/medusa" import { Text } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { Text } from "@medusajs/ui"
import { NameHeader } from "../../../components/table/table-cells/common/name-cell" import { CustomerGroupDTO } from "@medusajs/types"
import { import {
CreatedAtHeader,
CreatedAtCell, CreatedAtCell,
CreatedAtHeader,
} from "../../../components/table/table-cells/common/created-at-cell" } from "../../../components/table/table-cells/common/created-at-cell"
import { NameHeader } from "../../../components/table/table-cells/common/name-cell"
const columnHelper = createColumnHelper<CustomerGroup>() const columnHelper = createColumnHelper<CustomerGroupDTO>()
export const useCustomerGroupTableColumns = () => { export const useCustomerGroupTableColumns = () => {
return useMemo( return useMemo(
@@ -8,6 +8,7 @@ import { customerGroups } from "./customer-groups"
import { customers } from "./customers" import { customers } from "./customers"
import { invites } from "./invites" import { invites } from "./invites"
import { payments } from "./payments" import { payments } from "./payments"
import { priceLists } from "./price-lists"
import { productTypes } from "./product-types" import { productTypes } from "./product-types"
import { products } from "./products" import { products } from "./products"
import { promotions } from "./promotions" import { promotions } from "./promotions"
@@ -40,6 +41,7 @@ export const client = {
invites: invites, invites: invites,
products: products, products: products,
productTypes: productTypes, productTypes: productTypes,
priceLists: priceLists,
stockLocations: stockLocations, stockLocations: stockLocations,
workflowExecutions: workflowExecutions, workflowExecutions: workflowExecutions,
} }
@@ -0,0 +1,59 @@
import {
AddPriceListPricesReq,
CreatePriceListReq,
DeletePriceListPricesReq,
UpdatePriceListReq,
} from "../../types/api-payloads"
import {
PriceListDeleteRes,
PriceListListRes,
PriceListRes,
} from "../../types/api-responses"
import { getRequest, postRequest } from "./common"
async function retrievePriceLists(id: string, query?: Record<string, any>) {
return getRequest<PriceListRes>(`/admin/price-lists/${id}`, query)
}
async function listPriceLists(query?: Record<string, any>) {
return getRequest<PriceListListRes>(`/admin/price-lists`, query)
}
async function createPriceList(payload: CreatePriceListReq) {
return postRequest<PriceListRes>(`/admin/price-lists`, payload)
}
async function updatePriceList(id: string, payload: UpdatePriceListReq) {
return postRequest<PriceListRes>(`/admin/price-lists/${id}`, payload)
}
async function deletePriceList(id: string) {
return postRequest<PriceListDeleteRes>(`/admin/price-lists/${id}/delete`)
}
async function addPriceListPrices(id: string, payload: AddPriceListPricesReq) {
return postRequest<PriceListRes>(
`/admin/price-lists/${id}/prices/batch/add`,
payload
)
}
async function removePriceListPrices(
id: string,
payload: DeletePriceListPricesReq
) {
return postRequest<PriceListRes>(
`/admin/price-lists/${id}/prices/batch/remove`,
payload
)
}
export const priceLists = {
retrieve: retrievePriceLists,
list: listPriceLists,
create: createPriceList,
update: updatePriceList,
delete: deletePriceList,
addPrices: addPriceListPrices,
removePrices: removePriceListPrices,
}
@@ -1,7 +1,7 @@
import { currencies } from "./currencies" import { currencies } from "./currencies"
const getDecimalDigits = (currency: string) => { export const getDecimalDigits = (currency: string) => {
return currencies[currency.toUpperCase()]?.decimal_digits return currencies[currency.toUpperCase()]?.decimal_digits ?? 0
} }
/** /**
@@ -18,7 +18,7 @@ const getDecimalDigits = (currency: string) => {
export const getPresentationalAmount = (amount: number, currency: string) => { export const getPresentationalAmount = (amount: number, currency: string) => {
const decimalDigits = getDecimalDigits(currency) const decimalDigits = getDecimalDigits(currency)
if (!decimalDigits) { if (decimalDigits === undefined) {
throw new Error("Currency has no decimal digits") throw new Error("Currency has no decimal digits")
} }
@@ -36,9 +36,9 @@ export const getPresentationalAmount = (amount: number, currency: string) => {
* getDbAmount(10, "jpy") // 10 * getDbAmount(10, "jpy") // 10
*/ */
export const getDbAmount = (amount: number, currency: string) => { export const getDbAmount = (amount: number, currency: string) => {
const decimalDigits = currencies[currency.toUpperCase()].decimal_digits const decimalDigits = getDecimalDigits(currency)
if (!decimalDigits) { if (decimalDigits === undefined) {
throw new Error("Currency has no decimal digits") throw new Error("Currency has no decimal digits")
} }
@@ -310,44 +310,6 @@ export const v1Routes: RouteObject[] = [
}, },
], ],
}, },
{
path: "/pricing",
handle: {
crumb: () => "Pricing",
},
children: [
{
path: "",
lazy: () => import("../../routes/pricing/pricing-list"),
children: [
// {
// path: "create",
// lazy: () => import("../../routes/pricing/pricing-create"),
// },
],
},
{
path: ":id",
lazy: () => import("../../routes/pricing/pricing-detail"),
children: [
{
path: "edit",
lazy: () => import("../../routes/pricing/pricing-edit"),
},
{
path: "products/add",
lazy: () =>
import("../../routes/pricing/pricing-products-add"),
},
{
path: "products/edit",
lazy: () =>
import("../../routes/pricing/pricing-products-edit"),
},
],
},
],
},
], ],
}, },
...routeExtensions, ...routeExtensions,
@@ -17,6 +17,7 @@ import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout-v2/main-layout" import { MainLayout } from "../../components/layout-v2/main-layout"
import { SettingsLayout } from "../../components/layout/settings-layout" import { SettingsLayout } from "../../components/layout/settings-layout"
import { useMe } from "../../hooks/api/users" import { useMe } from "../../hooks/api/users"
import { PriceListRes } from "../../types/api-responses"
import { SearchProvider } from "../search-provider" import { SearchProvider } from "../search-provider"
import { SidebarProvider } from "../sidebar-provider" import { SidebarProvider } from "../sidebar-provider"
@@ -262,6 +263,53 @@ export const v2Routes: RouteObject[] = [
}, },
], ],
}, },
{
path: "/pricing",
handle: {
crumb: () => "Pricing",
},
children: [
{
path: "",
lazy: () => import("../../v2-routes/pricing/pricing-list"),
children: [
{
path: "create",
lazy: () =>
import("../../v2-routes/pricing/pricing-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../v2-routes/pricing/pricing-detail"),
handle: {
crumb: (data: PriceListRes) => data.price_list.title,
},
children: [
{
path: "edit",
lazy: () => import("../../v2-routes/pricing/pricing-edit"),
},
{
path: "configuration",
lazy: () =>
import("../../v2-routes/pricing/pricing-configuration"),
},
{
path: "products/add",
lazy: () =>
import("../../v2-routes/pricing/pricing-products-add"),
},
{
path: "products/edit",
lazy: () =>
import("../../v2-routes/pricing/pricing-products-prices"),
},
],
},
],
},
{ {
path: "/customers", path: "/customers",
handle: { handle: {
@@ -1,202 +0,0 @@
import { Currency, Product, ProductVariant, Region } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminPriceListProducts } from "medusa-react"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { useParams } from "react-router-dom"
import { z } from "zod"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { DataGrid } from "../../../../../components/grid/data-grid"
import { TextField } from "../../../../../components/grid/grid-fields/common/text-field"
import { DisplayField } from "../../../../../components/grid/grid-fields/non-interactive/display-field"
import { DataGridMeta } from "../../../../../components/grid/types"
import { RouteFocusModal } from "../../../../../components/route-modal"
type EditProductPricesFormProps = {
regions: Region[]
currencies: Currency[]
ids: string | null
}
const ProductEditorSchema = z.object({
products: z.record(
z.object({
variants: z.record(
z.object({
prices: z.object({
regions: z.record(
z.object({
amount: z.number(),
})
),
currencies: z.record(
z.object({
amount: z.number(),
})
),
}),
})
),
})
),
})
type ProductEditorSchemaType = z.infer<typeof ProductEditorSchema>
export const EditProductPricesForm = ({
ids,
regions,
currencies,
}: EditProductPricesFormProps) => {
const { id } = useParams()
const form = useForm()
const { products, count, isLoading, isError, error } =
useAdminPriceListProducts(
id!,
{
id: ids?.split(",") || undefined,
},
{
keepPreviousData: true,
}
)
const columns = useColumns({ regions, currencies })
if (isError) {
throw error
}
return (
<RouteFocusModal.Form form={form}>
<form className="flex size-full flex-col">
<RouteFocusModal.Header></RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
<DataGrid
isLoading={isLoading}
data={products as Product[]}
columns={columns}
state={form}
getSubRows={getVariantRows}
/>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
/**
* Helper function to determine if a row is a product or a variant.
*/
const isProduct = (row: Product | ProductVariant): row is Product => {
return "variants" in row
}
const getVariantRows = (row: Product | ProductVariant) => {
if ("variants" in row) {
return row.variants
}
return undefined
}
const columnHelper = createColumnHelper<Product | ProductVariant>()
const createRegionColum = (region: Region) => {
return columnHelper.display({
id: region.id,
header: region.name,
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
field={`products.${entity.id}.variants.${entity.id}.prices.regions.${region.id}.amount`}
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
/>
)
},
})
}
const createCurrencyColumn = (currency: Currency) => {
return columnHelper.display({
id: currency.code,
header: currency.code.toUpperCase(),
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
field={`products.${entity.id}.variants.${entity.id}.prices.currencies.${currency.code}.amount`}
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
/>
)
},
})
}
const useColumns = ({
regions,
currencies,
}: {
regions: Region[]
currencies: Currency[]
}) => {
const { t } = useTranslation()
const regionColumns = useMemo(() => {
return regions.map(createRegionColum)
}, [regions])
const currencyColumns = useMemo(() => {
return currencies.map(createCurrencyColumn)
}, [currencies])
return useMemo(
() => [
columnHelper.display({
id: "product-display",
header: t("fields.product"),
cell: ({ row }) => {
const entity = row.original
if (isProduct(entity)) {
return (
<DisplayField>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Thumbnail src={entity.thumbnail} />
<span className="truncate">{entity.title}</span>
</div>
</DisplayField>
)
}
return (
<DisplayField>
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{entity.title}</span>
</div>
</DisplayField>
)
},
size: 350,
}),
...regionColumns,
...currencyColumns,
],
[t, regionColumns, currencyColumns]
)
}
@@ -1 +0,0 @@
export * from "./edit-product-prices-form"
@@ -1 +0,0 @@
export { PricingProductsEdit as Component } from "./pricing-products-edit"
@@ -1,45 +0,0 @@
import { useAdminRegions, useAdminStore } from "medusa-react"
import { useSearchParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { EditProductPricesForm } from "./components/edit-product-prices-form"
export const PricingProductsEdit = () => {
const [searchParams] = useSearchParams()
const { regions, isLoading, isError, error } = useAdminRegions({
limit: 1000,
fields: "id,name,includes_tax,currency.code,currency.symbol_native",
expand: "currency",
})
const {
store,
isLoading: isLoadingStore,
isError: isStoreError,
error: storeError,
} = useAdminStore()
const ids = searchParams.get("ids[]")
const currencies = store?.currencies || []
const ready = !isLoading && regions && !isLoadingStore && store
if (isError) {
throw error
}
if (isStoreError) {
throw storeError
}
return (
<RouteFocusModal>
{ready && (
<EditProductPricesForm
ids={ids}
regions={regions}
currencies={currencies}
/>
)}
</RouteFocusModal>
)
}
@@ -7,6 +7,7 @@ import {
CreateCampaignDTO, CreateCampaignDTO,
CreateCustomerDTO, CreateCustomerDTO,
CreateInviteDTO, CreateInviteDTO,
CreatePriceListDTO,
CreateProductCollectionDTO, CreateProductCollectionDTO,
CreatePromotionDTO, CreatePromotionDTO,
CreatePromotionRuleDTO, CreatePromotionRuleDTO,
@@ -16,6 +17,7 @@ import {
UpdateApiKeyDTO, UpdateApiKeyDTO,
UpdateCampaignDTO, UpdateCampaignDTO,
UpdateCustomerDTO, UpdateCustomerDTO,
UpdatePriceListDTO,
UpdateProductCollectionDTO, UpdateProductCollectionDTO,
UpdatePromotionDTO, UpdatePromotionDTO,
UpdatePromotionRuleDTO, UpdatePromotionRuleDTO,
@@ -64,6 +66,18 @@ export type UpdateStockLocationReq = UpdateStockLocationInput
export type CreateProductCollectionReq = CreateProductCollectionDTO export type CreateProductCollectionReq = CreateProductCollectionDTO
export type UpdateProductCollectionReq = UpdateProductCollectionDTO export type UpdateProductCollectionReq = UpdateProductCollectionDTO
// Price Lists
export type CreatePriceListReq = CreatePriceListDTO
export type UpdatePriceListReq = UpdatePriceListDTO
export type AddPriceListPricesReq = {
prices: {
currency_code: string
amount: number
variant_id: string
}[]
}
export type DeletePriceListPricesReq = { ids: string[] }
// Promotion // Promotion
export type CreatePromotionReq = CreatePromotionDTO export type CreatePromotionReq = CreatePromotionDTO
export type UpdatePromotionReq = UpdatePromotionDTO export type UpdatePromotionReq = UpdatePromotionDTO
@@ -5,8 +5,10 @@
import { import {
CampaignDTO, CampaignDTO,
CurrencyDTO, CurrencyDTO,
CustomerGroupDTO,
InviteDTO, InviteDTO,
PaymentProviderDTO, PaymentProviderDTO,
PriceListDTO,
ProductCategoryDTO, ProductCategoryDTO,
ProductCollectionDTO, ProductCollectionDTO,
ProductDTO, ProductDTO,
@@ -137,3 +139,15 @@ export type ProductCollectionListRes = {
collections: ProductCollectionDTO[] collections: ProductCollectionDTO[]
} & ListRes } & ListRes
export type ProductCollectionDeleteRes = DeleteRes export type ProductCollectionDeleteRes = DeleteRes
// Price Lists
export type PriceListRes = { price_list: PriceListDTO }
export type PriceListListRes = { price_lists: PriceListDTO[] } & ListRes
export type PriceListDeleteRes = DeleteRes
// Customer Groups
export type CustomerGroupRes = { customer_group: CustomerGroupDTO }
export type CustomerGroupListRes = {
customer_groups: CustomerGroupDTO[]
} & ListRes
export type CustomerGroupDeleteRes = DeleteRes
@@ -0,0 +1,77 @@
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../components/common/thumbnail"
import { CurrencyCell } from "../../../../components/grid/grid-cells/common/currency-cell"
import { ReadonlyCell } from "../../../../components/grid/grid-cells/common/readonly-cell"
import { VoidCell } from "../../../../components/grid/grid-cells/common/void-cell"
import { DataGridMeta } from "../../../../components/grid/types"
import { ExtendedProductDTO } from "../../../../types/api-responses"
import { isProductRow } from "../utils"
const columnHelper = createColumnHelper<
ExtendedProductDTO | ProductVariantDTO
>()
export const usePriceListGridColumns = ({
currencies = [],
}: {
currencies?: CurrencyDTO[]
}) => {
const { t } = useTranslation()
const colDefs: ColumnDef<ExtendedProductDTO | ProductVariantDTO>[] =
useMemo(() => {
return [
columnHelper.display({
id: t("fields.title"),
header: t("fields.title"),
cell: ({ row }) => {
const entity = row.original
if (isProductRow(entity)) {
return (
<VoidCell>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Thumbnail src={entity.thumbnail} />
<span className="truncate">{entity.title}</span>
</div>
</VoidCell>
)
}
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 }) => {
const entity = row.original
if (isProductRow(entity)) {
return <VoidCell />
}
return (
<CurrencyCell
currency={currency}
meta={table.options.meta as DataGridMeta}
field={`products.${entity.product_id}.variants.${entity.id}.currency_prices.${currency.code}.amount`}
/>
)
},
})
}),
]
}, [t, currencies])
return colDefs
}
@@ -0,0 +1,31 @@
import { z } from "zod"
const PricingVariantPricesRecordSchema = z.record(
z
.object({
amount: z.string().optional(),
id: z.string().nullable().optional(),
})
.optional()
)
const PricingVariantsRecordSchema = z.record(
z.object({
currency_prices: PricingVariantPricesRecordSchema,
region_prices: PricingVariantPricesRecordSchema,
})
)
export type PricingVariantsRecordType = z.infer<
typeof PricingVariantsRecordSchema
>
export const PricingProductsRecordSchema = z.record(
z.object({
variants: PricingVariantsRecordSchema,
})
)
export type PricingProductsRecordType = z.infer<
typeof PricingProductsRecordSchema
>
@@ -1,8 +1,9 @@
import { PriceList } from "@medusajs/medusa" import { PriceListDTO, ProductVariantDTO } from "@medusajs/types"
import { TFunction } from "i18next" import { TFunction } from "i18next"
import { ExtendedProductDTO } from "../../../types/api-responses"
import { PriceListStatus } from "./constants" import { PriceListStatus } from "./constants"
const getValues = (priceList: PriceList) => { const getValues = (priceList: PriceListDTO) => {
const startsAt = priceList.starts_at const startsAt = priceList.starts_at
const endsAt = priceList.ends_at const endsAt = priceList.ends_at
@@ -19,7 +20,7 @@ const getValues = (priceList: PriceList) => {
export const getPriceListStatus = ( export const getPriceListStatus = (
t: TFunction<"translation">, t: TFunction<"translation">,
priceList: PriceList priceList: PriceListDTO
) => { ) => {
const { isExpired, isScheduled, isDraft } = getValues(priceList) const { isExpired, isScheduled, isDraft } = getValues(priceList)
@@ -46,3 +47,9 @@ export const getPriceListStatus = (
text, text,
} }
} }
export const isProductRow = (
row: ExtendedProductDTO | ProductVariantDTO
): row is ExtendedProductDTO => {
return "variants" in row
}
@@ -0,0 +1 @@
export * from "./price-list-configuration-form"
@@ -0,0 +1,197 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PriceListDTO } from "@medusajs/types"
import { Button, DatePicker, Switch, Text } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdatePriceList } from "../../../../../hooks/api/price-lists"
type PriceListConfigurationFormProps = {
priceList: PriceListDTO
}
const PriceListConfigurationSchema = z.object({
ends_at: z.date().nullable(),
starts_at: z.date().nullable(),
})
/**
* TODO: Add CustomerGroups and possibly change to a RouteFocusModal
*
* Customer group rules aren't supported out of the box atm, so we can't
* set them in the UI without throwing an error.
*/
export const PriceListConfigurationForm = ({
priceList,
}: PriceListConfigurationFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof PriceListConfigurationSchema>>({
defaultValues: {
ends_at: priceList.ends_at ? new Date(priceList.ends_at) : null,
starts_at: priceList.starts_at ? new Date(priceList.starts_at) : null,
},
resolver: zodResolver(PriceListConfigurationSchema),
})
const { mutateAsync } = useUpdatePriceList(priceList.id)
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
// @ts-ignore - type is wrong and expects an ID.
{
starts_at: values.starts_at?.toISOString() || null,
ends_at: values.ends_at?.toISOString() || null,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<form
className="flex flex-1 flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
<Form.Field
control={form.control}
name="starts_at"
render={({ field: { value, onChange, ...rest } }) => {
const handleSwitchChange = (checked: boolean) => {
if (!checked) {
onChange(null)
return
}
const now = new Date()
onChange(now)
}
return (
<Form.Item>
<Collapsible.Root
open={!!value}
onOpenChange={handleSwitchChange}
>
<div className="grid grid-cols-[1fr_32px] gap-4">
<div>
<Text size="small" leading="compact" weight="plus">
Price list has a start date?
</Text>
<Text size="small" className="text-ui-fg-subtle">
Schedule the price list to activate in the future.
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch name={rest.name} checked={!!value} />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-2 pt-4">
<Form.Label className="!txt-small text-ui-fg-subtle">
{t("fields.startDate")}
</Form.Label>
<Form.Control>
<DatePicker
value={value || undefined}
onChange={onChange}
{...rest}
/>
</Form.Control>
</div>
</Collapsible.Content>
<Form.ErrorMessage />
</Collapsible.Root>
</Form.Item>
)
}}
/>
<Divider />
<Form.Field
control={form.control}
name="ends_at"
render={({ field: { value, onChange, ...rest } }) => {
const handleSwitchChange = (checked: boolean) => {
if (!checked) {
onChange(null)
return
}
const inAWeek = new Date(
new Date().setDate(new Date().getDate() + 7)
)
onChange(inAWeek)
}
return (
<Form.Item>
<Collapsible.Root
open={!!value}
onOpenChange={handleSwitchChange}
>
<div className="grid grid-cols-[1fr_32px] gap-4">
<div>
<Text size="small" leading="compact" weight="plus">
Price list has an end date?
</Text>
<Text size="small" className="text-ui-fg-subtle">
Schedule the price list to deactivate in the future.
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch name={rest.name} checked={!!value} />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-2 pt-4">
<Form.Label className="!txt-small text-ui-fg-subtle">
{t("fields.endDate")}
</Form.Label>
<Form.Control>
<DatePicker
value={value || undefined}
onChange={onChange}
{...rest}
/>
</Form.Control>
</div>
</Collapsible.Content>
<Form.ErrorMessage />
</Collapsible.Root>
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer className="shrink-0">
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit">
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export { PricingConfiguration as Component } from "./pricing-configuration"
@@ -0,0 +1,28 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { usePriceList } from "../../../hooks/api/price-lists"
import { PriceListConfigurationForm } from "./components/price-list-configuration-form"
export const PricingConfiguration = () => {
const { t } = useTranslation()
const { id } = useParams()
const { price_list, isLoading, isError, error } = usePriceList(id!)
const ready = !isLoading && price_list
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("pricing.settings.editPriceListTitle")}</Heading>
</RouteDrawer.Header>
{ready && <PriceListConfigurationForm priceList={price_list} />}
</RouteDrawer>
)
}
@@ -0,0 +1 @@
export * from "./pricing-create-form"
@@ -0,0 +1,359 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
import { FieldPath, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { CreatePriceListDTO, CreatePriceListPriceDTO } from "@medusajs/types"
import { useState } from "react"
import { z } from "zod"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreatePriceList } from "../../../../../hooks/api/price-lists"
import { castNumber } from "../../../../../lib/cast-number"
import { getDbAmount } from "../../../../../lib/money-amount-helpers"
import { PricingDetailsForm } from "./pricing-details-form"
import { PricingPricesForm } from "./pricing-prices-form"
import { PricingProductsForm } from "./pricing-products-form"
import {
PricingCreateSchema,
PricingCreateSchemaType,
PricingDetailsFields,
PricingDetailsSchema,
PricingPricesFields,
PricingProductsFields,
PricingProductsSchema,
} from "./schema"
enum Tab {
DETAIL = "detail",
PRODUCT = "product",
PRICE = "price",
}
const tabOrder = [Tab.DETAIL, Tab.PRODUCT, Tab.PRICE] as const
type TabState = Record<Tab, ProgressStatus>
const initialTabState: TabState = {
[Tab.DETAIL]: "in-progress",
[Tab.PRODUCT]: "not-started",
[Tab.PRICE]: "not-started",
}
export const PricingCreateForm = () => {
const [tab, setTab] = useState<Tab>(Tab.DETAIL)
const [tabState, setTabState] = useState<TabState>(initialTabState)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<PricingCreateSchemaType>({
defaultValues: {
type: "sale",
title: "",
description: "",
starts_at: null,
ends_at: null,
customer_group_ids: [],
product_ids: [],
products: {},
},
resolver: zodResolver(PricingCreateSchema),
})
const { mutateAsync, isPending } = useCreatePriceList()
const handleSubmit = form.handleSubmit(
async (data) => {
const { customer_group_ids, products } = data
const rules = customer_group_ids?.length
? { customer_group_id: customer_group_ids.map((cg) => cg.id) }
: undefined
const prices: CreatePriceListPriceDTO[] = []
for (const [_, product] of Object.entries(products)) {
const { variants } = product
for (const [variantId, variant] of Object.entries(variants)) {
const { currency_prices } = variant
for (const [currencyCode, currencyPrice] of Object.entries(
currency_prices
)) {
if (!currencyPrice) {
continue
}
prices.push({
amount: getDbAmount(
castNumber(currencyPrice.amount),
currencyCode
),
currency_code: currencyCode,
// @ts-expect-error type is wrong
variant_id: variantId,
})
}
}
}
await mutateAsync(
{
title: data.title,
type: data.type as CreatePriceListDTO["type"],
description: data.description,
starts_at: data.starts_at ? data.starts_at.toISOString() : null,
ends_at: data.ends_at ? data.ends_at.toISOString() : null,
rules,
prices: prices,
},
{
onSuccess: ({ price_list }) => {
handleSuccess(`../${price_list.id}`)
},
}
)
},
(error) => console.error(error)
)
const partialFormValidation = (
fields: FieldPath<PricingCreateSchemaType>[],
schema: z.ZodSchema<any>
) => {
form.clearErrors(fields)
const values = fields.reduce((acc, key) => {
acc[key] = form.getValues(key)
return acc
}, {} as Record<string, unknown>)
const validationResult = schema.safeParse(values)
if (!validationResult.success) {
validationResult.error.errors.forEach(({ path, message }) => {
form.setError(path.join(".") as keyof PricingCreateSchemaType, {
type: "manual",
message,
})
})
return false
}
return true
}
const isTabDirty = (tab: Tab) => {
switch (tab) {
case Tab.DETAIL: {
const fields = PricingDetailsFields
return fields.some((field) => {
return form.getFieldState(field).isDirty
})
}
case Tab.PRODUCT: {
const fields = PricingProductsFields
return fields.some((field) => {
return form.getFieldState(field).isDirty
})
}
case Tab.PRICE: {
const fields = PricingPricesFields
return fields.some((field) => {
return form.getFieldState(field).isDirty
})
}
}
}
const handleChangeTab = (update: Tab) => {
if (tab === update) {
return
}
if (tabOrder.indexOf(update) < tabOrder.indexOf(tab)) {
const isCurrentTabDirty = isTabDirty(tab)
setTabState((prev) => ({
...prev,
[tab]: isCurrentTabDirty ? prev[tab] : "not-started",
[update]: "in-progress",
}))
setTab(update)
return
}
// get the tabs from the current tab to the update tab including the current tab
const tabs = tabOrder.slice(0, tabOrder.indexOf(update))
// validate all the tabs from the current tab to the update tab if it fails on any of tabs then set that tab as current tab
for (const tab of tabs) {
if (tab === Tab.DETAIL) {
if (
!partialFormValidation(PricingDetailsFields, PricingDetailsSchema)
) {
setTabState((prev) => ({
...prev,
[tab]: "in-progress",
}))
setTab(tab)
return
}
setTabState((prev) => ({
...prev,
[tab]: "completed",
}))
} else if (tab === Tab.PRODUCT) {
if (
!partialFormValidation(PricingProductsFields, PricingProductsSchema)
) {
setTabState((prev) => ({
...prev,
[tab]: "in-progress",
}))
setTab(tab)
return
}
setTabState((prev) => ({
...prev,
[tab]: "completed",
}))
}
}
setTabState((prev) => ({
...prev,
[tab]: "completed",
[update]: "in-progress",
}))
setTab(update)
}
const handleNextTab = (tab: Tab) => {
if (tabOrder.indexOf(tab) + 1 >= tabOrder.length) {
return
}
const nextTab = tabOrder[tabOrder.indexOf(tab) + 1]
handleChangeTab(nextTab)
}
return (
<RouteFocusModal.Form form={form}>
<ProgressTabs
value={tab}
onValueChange={(tab) => handleChangeTab(tab as Tab)}
className="flex h-full flex-col overflow-hidden"
>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<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.detail}
value={Tab.DETAIL}
>
Details
</ProgressTabs.Trigger>
<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>
<PrimaryButton
tab={tab}
next={handleNextTab}
isLoading={isPending}
/>
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="size-full overflow-hidden">
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.DETAIL}
>
<PricingDetailsForm form={form} />
</ProgressTabs.Content>
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.PRODUCT}
>
<PricingProductsForm form={form} />
</ProgressTabs.Content>
<ProgressTabs.Content
className="size-full overflow-hidden"
value={Tab.PRICE}
>
<PricingPricesForm form={form} />
</ProgressTabs.Content>
</RouteFocusModal.Body>
</form>
</ProgressTabs>
</RouteFocusModal.Form>
)
}
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
key="submit-button"
type="submit"
variant="primary"
size="small"
isLoading={isLoading}
>
{t("actions.save")}
</Button>
)
}
return (
<Button
key="next-button"
type="button"
variant="primary"
size="small"
onClick={() => next(tab)}
>
{t("actions.continue")}
</Button>
)
}
@@ -0,0 +1,536 @@
import {
Button,
Checkbox,
DatePicker,
Heading,
Input,
RadioGroup,
Switch,
Text,
Textarea,
} from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useFieldArray, type UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { XMarkMini } from "@medusajs/icons"
import { CustomerGroupDTO } from "@medusajs/types"
import { keepPreviousData } from "@tanstack/react-query"
import {
OnChangeFn,
RowSelectionState,
createColumnHelper,
} from "@tanstack/react-table"
import { t } from "i18next"
import { useEffect, useMemo, useState } from "react"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import { SplitView } from "../../../../../components/layout/split-view"
import { DataTable } from "../../../../../components/table/data-table"
import { useCustomerGroups } from "../../../../../hooks/api/customer-groups"
import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns/use-customer-group-table-columns"
import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import type {
PricingCreateSchemaType,
PricingCustomerGroupsArrayType,
} from "./schema"
type PricingDetailsFormProps = {
form: UseFormReturn<PricingCreateSchemaType>
}
export const PricingDetailsForm = ({ form }: PricingDetailsFormProps) => {
const [open, setOpen] = useState(false)
const [showCustomerGroups, setShowCustomerGroups] = useState(
!!form.getValues("customer_group_ids")?.length
)
const { t } = useTranslation()
const { fields, remove, append } = useFieldArray({
control: form.control,
name: "customer_group_ids",
keyName: "cg_id",
})
const handleAddCustomerGroup = (groups: PricingCustomerGroupsArrayType) => {
const newIds = groups.map((group) => group.id)
const fieldsToAdd = groups.filter(
(group) => !fields.some((field) => field.id === group.id)
)
for (const field of fields) {
if (!newIds.includes(field.id)) {
remove(fields.indexOf(field))
}
}
append(fieldsToAdd)
setOpen(false)
}
const handleOpenDrawer = () => {
setOpen(true)
}
const handleShowCustomerGroups = (open: boolean) => {
if (!open) {
form.setValue("customer_group_ids", [])
}
setShowCustomerGroups(open)
}
return (
<SplitView open={open} onOpenChange={setOpen}>
<SplitView.Content>
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
<div>
<Heading>Create Price List</Heading>
<Text size="small" className="text-ui-fg-subtle">
Create a new price list to manage the prices of your products.
</Text>
</div>
<Form.Field
control={form.control}
name="type"
render={({ field: { onChange, ...rest } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-y-4">
<div>
<Form.Label>{t("fields.type")}</Form.Label>
<Form.Hint>
Choose the type of price list you want to create.
</Form.Hint>
</div>
<Form.Control>
<RadioGroup
onValueChange={onChange}
{...rest}
className="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<RadioGroup.ChoiceBox
value={"sale"}
label="Sale"
description="Choose this if you are creating a sale"
/>
<RadioGroup.ChoiceBox
value={"override"}
label="Override"
description="Choose this if you are creating an override"
/>
</RadioGroup>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.description")}</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Divider />
<Form.Field
control={form.control}
name="starts_at"
render={({ field: { value, onChange, ...rest } }) => {
const handleSwitchChange = (checked: boolean) => {
if (!checked) {
onChange(null)
return
}
const now = new Date()
onChange(now)
}
return (
<Form.Item>
<Collapsible.Root
open={!!value}
onOpenChange={handleSwitchChange}
>
<div className="grid grid-cols-[1fr_32px] gap-4">
<div>
<Text size="small" leading="compact" weight="plus">
Price list has a start date?
</Text>
<Text size="small" className="text-ui-fg-subtle">
Schedule the price list to activate in the future.
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch name={rest.name} checked={!!value} />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-2 pt-4">
<Form.Label className="!txt-small text-ui-fg-subtle">
{t("fields.startDate")}
</Form.Label>
<Form.Control>
<DatePicker
value={value || undefined}
onChange={onChange}
{...rest}
/>
</Form.Control>
</div>
</Collapsible.Content>
<Form.ErrorMessage />
</Collapsible.Root>
</Form.Item>
)
}}
/>
<Divider />
<Form.Field
control={form.control}
name="ends_at"
render={({ field: { value, onChange, ...rest } }) => {
const handleSwitchChange = (checked: boolean) => {
if (!checked) {
onChange(null)
return
}
const inAWeek = new Date(
new Date().setDate(new Date().getDate() + 7)
)
onChange(inAWeek)
}
return (
<Form.Item>
<Collapsible.Root
open={!!value}
onOpenChange={handleSwitchChange}
>
<div className="grid grid-cols-[1fr_32px] gap-4">
<div>
<Text size="small" leading="compact" weight="plus">
Price list has an end date?
</Text>
<Text size="small" className="text-ui-fg-subtle">
Schedule the price list to deactivate in the future.
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch name={rest.name} checked={!!value} />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-2 pt-4">
<Form.Label className="!txt-small text-ui-fg-subtle">
{t("fields.endDate")}
</Form.Label>
<Form.Control>
<DatePicker
value={value || undefined}
onChange={onChange}
{...rest}
/>
</Form.Control>
</div>
</Collapsible.Content>
<Form.ErrorMessage />
</Collapsible.Root>
</Form.Item>
)
}}
/>
<Divider />
<div>
<Collapsible.Root
open={showCustomerGroups}
onOpenChange={handleShowCustomerGroups}
>
<Form.Field
control={form.control}
name="customer_group_ids"
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-[1fr_32px] items-start gap-4">
<div>
<Form.Label optional>
Customer availability
</Form.Label>
<Form.Hint>
Specify which customer groups the price overrides
should apply for.
</Form.Hint>
</div>
<Form.Control>
<Collapsible.Trigger asChild>
<Switch
name={field.name}
checked={showCustomerGroups}
/>
</Collapsible.Trigger>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Collapsible.Content>
<div className="flex flex-col pt-4">
{fields.length > 0 ? (
fields.map((field, index) => {
return (
<div
key={field.cg_id}
className="bg-ui-bg-field shadow-borders-base transition-fg hover:bg-ui-bg-field-hover flex h-7 w-fit items-center overflow-hidden rounded-md"
>
<div className="txt-compact-small-plus flex h-full select-none items-center justify-center px-2 py-0.5">
{field.name}
</div>
<button
type="button"
onClick={() => remove(index)}
className="focus-visible:bg-ui-bg-field-hover transition-fg hover:bg-ui-bg-field-hover flex h-full w-7 items-center justify-center border-l outline-none"
>
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
)
})
) : (
<div className="flex items-center justify-center px-2 py-3">
<Text
size="small"
leading="compact"
className="text-ui-fg-muted"
>
No customer groups selected.
</Text>
</div>
)}
<div className="flex items-center justify-end">
<Button
size="small"
variant="secondary"
type="button"
onClick={handleOpenDrawer}
>
Add customer groups
</Button>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
</div>
</SplitView.Content>
<CustomerGroupDrawer
selectedCustomerGroups={fields}
saveCustomerGroups={handleAddCustomerGroup}
/>
</SplitView>
)
}
const PAGE_SIZE = 50
const PREFIX = "cg"
const initRowSelection = (
selectedCustomerGroups: PricingCustomerGroupsArrayType
) => {
return selectedCustomerGroups.reduce((acc, group) => {
acc[group.id] = true
return acc
}, {} as RowSelectionState)
}
const CustomerGroupDrawer = ({
selectedCustomerGroups,
saveCustomerGroups,
}: {
selectedCustomerGroups: PricingCustomerGroupsArrayType
saveCustomerGroups: (arr: PricingCustomerGroupsArrayType) => void
}) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowSelection(selectedCustomerGroups)
)
const [intermediate, setIntermediate] =
useState<PricingCustomerGroupsArrayType>(selectedCustomerGroups)
useEffect(() => {
// If the selected customer groups change outside of the drawer,
// update the row selection state and intermediate state
setRowSelection(initRowSelection(selectedCustomerGroups))
setIntermediate(selectedCustomerGroups)
}, [selectedCustomerGroups])
const { searchParams, raw } = useCustomerGroupTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { customer_groups, count, isLoading, isError, error } =
useCustomerGroups(searchParams, {
placeholderData: keepPreviousData,
})
const updater: OnChangeFn<RowSelectionState> = (value) => {
const state = typeof value === "function" ? value(rowSelection) : value
const currentIds = Object.keys(rowSelection)
const ids = Object.keys(state)
const newIds = ids.filter((id) => !currentIds.includes(id))
const removedIds = currentIds.filter((id) => !ids.includes(id))
const newCustomerGroups =
customer_groups
?.filter((cg) => newIds.includes(cg.id))
.map((cg) => ({ id: cg.id, name: cg.name })) || []
const filteredIntermediate = intermediate.filter(
(cg) => !removedIds.includes(cg.id)
)
setIntermediate([...filteredIntermediate, ...newCustomerGroups])
setRowSelection(state)
}
const handleSave = () => {
saveCustomerGroups(intermediate)
}
const columns = useColumns()
const { table } = useDataTable({
data: customer_groups || [],
columns,
count,
enablePagination: true,
enableRowSelection: true,
getRowId: (row) => row.id,
rowSelection: {
state: rowSelection,
updater,
},
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
if (isError) {
throw error
}
return (
<SplitView.Drawer>
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
isLoading={isLoading}
layout="fill"
pagination
search
prefix={PREFIX}
queryObject={raw}
/>
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<SplitView.Close type="button" asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</SplitView.Close>
<Button
type="button"
variant="primary"
size="small"
onClick={handleSave}
>
{t("actions.add")}
</Button>
</div>
</div>
</SplitView.Drawer>
)
}
const columnHelper = createColumnHelper<CustomerGroupDTO>()
const useColumns = () => {
const base = useCustomerGroupTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}
@@ -0,0 +1,119 @@
import { useEffect } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { DataGrid } from "../../../../../components/grid/data-grid"
import { useCurrencies } from "../../../../../hooks/api/currencies"
import { useProducts } from "../../../../../hooks/api/products"
import { useStore } from "../../../../../hooks/api/store"
import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns"
import { PricingVariantsRecordType } from "../../../common/schemas"
import { isProductRow } from "../../../common/utils"
import { PricingCreateSchemaType } from "./schema"
type PricingPricesFormProps = {
form: UseFormReturn<PricingCreateSchemaType>
}
export const PricingPricesForm = ({ form }: PricingPricesFormProps) => {
const {
store,
isLoading: isStoreLoading,
isError: isStoreError,
error: storeError,
} = useStore()
const {
currencies,
isLoading: isCurrenciesLoading,
isError: isCurrencyError,
error: currencyError,
} = useCurrencies(
{
code: store?.supported_currency_codes,
limit: store?.supported_currency_codes?.length,
},
{
enabled: !!store,
}
)
const ids = useWatch({
control: form.control,
name: "product_ids",
})
const existingProducts = useWatch({
control: form.control,
name: "products",
})
const { products, isLoading, isError, error } = useProducts({
id: ids.map((id) => id.id),
limit: ids.length,
fields: "title,thumbnail,*variants",
})
const { setValue } = form
useEffect(() => {
if (!isLoading && products) {
products.forEach((product) => {
/**
* If the product already exists in the form, we don't want to overwrite it.
*/
if (existingProducts[product.id] || !product.variants) {
return
}
setValue(`products.${product.id}.variants`, {
...product.variants.reduce((variants, variant) => {
variants[variant.id] = {
currency_prices: {},
region_prices: {},
}
return variants
}, {} as PricingVariantsRecordType),
})
})
}
}, [products, existingProducts, isLoading, setValue])
const columns = usePriceListGridColumns({
currencies,
})
const initializing =
isLoading ||
isStoreLoading ||
isCurrenciesLoading ||
!products ||
!store ||
!currencies
if (isError) {
throw error
}
if (isStoreError) {
throw storeError
}
if (isCurrencyError) {
throw currencyError
}
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
<DataGrid
columns={columns}
data={products}
getSubRows={(row) => {
if (isProductRow(row)) {
return row.variants
}
}}
isLoading={initializing}
state={form}
/>
</div>
)
}
@@ -0,0 +1,174 @@
import { Checkbox } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import {
OnChangeFn,
RowSelectionState,
createColumnHelper,
} from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { DataTable } from "../../../../../components/table/data-table"
import { useProducts } from "../../../../../hooks/api/products"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { ExtendedProductDTO } from "../../../../../types/api-responses"
import { PricingCreateSchemaType, PricingProductsRecordType } from "./schema"
type PricingProductsFormProps = {
form: UseFormReturn<PricingCreateSchemaType>
}
const PAGE_SIZE = 50
const PREFIX = "p"
function getInitialSelection(products: { id: string }[]) {
return products.reduce((acc, curr) => {
acc[curr.id] = true
return acc
}, {} as RowSelectionState)
}
export const PricingProductsForm = ({ form }: PricingProductsFormProps) => {
const { control, setValue } = form
const selectedIds = useWatch({
control,
name: "product_ids",
})
const productRecords = useWatch({
control,
name: "products",
})
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
getInitialSelection(selectedIds)
)
const { searchParams, raw } = useProductTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { products, count, isLoading, isError, error } = useProducts(
searchParams,
{
placeholderData: keepPreviousData,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const state = typeof fn === "function" ? fn(rowSelection) : fn
const ids = Object.keys(state)
const productRecordKeys = Object.keys(productRecords)
const updatedRecords = productRecordKeys.reduce((acc, key) => {
if (ids.includes(key)) {
acc[key] = productRecords[key]
}
return acc
}, {} as PricingProductsRecordType)
const update = ids.map((id) => ({ id }))
setValue("product_ids", update, { shouldDirty: true, shouldTouch: true })
/**
* Update the product records to ensure that all unselected products
* are removed from the form state.
*/
setValue("products", updatedRecords, {
shouldDirty: true,
shouldTouch: true,
})
setRowSelection(state)
}
const columns = useColumns()
const filters = useProductTableFilters()
const { table } = useDataTable({
data: products || [],
columns,
count,
enablePagination: true,
enableRowSelection: (row) => {
return row.original.variants.length > 0
},
getRowId: (row) => row.id,
rowSelection: {
state: rowSelection,
updater,
},
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col">
<DataTable
table={table}
columns={columns}
filters={filters}
pageSize={PAGE_SIZE}
prefix={PREFIX}
count={count}
isLoading={isLoading}
layout="fill"
orderBy={["title", "status", "created_at", "updated_at"]}
pagination
search
queryObject={raw}
/>
</div>
)
}
const columnHelper = createColumnHelper<ExtendedProductDTO>()
const useColumns = () => {
const base = useProductTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}
@@ -0,0 +1,54 @@
import { z } from "zod"
import { PricingProductsRecordSchema } from "../../../common/schemas"
const PricingCustomerGroupsArray = z.array(
z.object({
id: z.string(),
name: z.string(),
})
)
export type PricingCustomerGroupsArrayType = z.infer<
typeof PricingCustomerGroupsArray
>
export const PricingCreateSchema = z.object({
type: z.enum(["sale", "override"]),
title: z.string().min(1),
description: z.string().min(1),
starts_at: z.date().nullable(),
ends_at: z.date().nullable(),
customer_group_ids: PricingCustomerGroupsArray.optional(),
product_ids: z.array(z.object({ id: z.string() })).min(1),
products: PricingProductsRecordSchema,
})
export type PricingCreateSchemaType = z.infer<typeof PricingCreateSchema>
export const PricingDetailsSchema = PricingCreateSchema.pick({
type: true,
title: true,
description: true,
starts_at: true,
ends_at: true,
})
export const PricingDetailsFields = Object.keys(
PricingDetailsSchema.shape
) as (keyof typeof PricingDetailsSchema.shape)[]
export const PricingProductsSchema = PricingCreateSchema.pick({
product_ids: true,
})
export const PricingProductsFields = Object.keys(
PricingProductsSchema.shape
) as (keyof typeof PricingProductsSchema.shape)[]
export const PricingPricesSchema = PricingCreateSchema.pick({
products: true,
})
export const PricingPricesFields = Object.keys(
PricingPricesSchema.shape
) as (keyof typeof PricingPricesSchema.shape)[]
@@ -0,0 +1 @@
export { PricingCreate as Component } from "./pricing-create"
@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { PricingCreateForm } from "./components/pricing-create-form"
export const PricingCreate = () => {
return (
<RouteFocusModal>
<PricingCreateForm />
</RouteFocusModal>
)
}
@@ -1,21 +1,23 @@
import { PencilSquare } from "@medusajs/icons" import { PencilSquare } from "@medusajs/icons"
import { PriceList } from "@medusajs/medusa" import { PriceListDTO } from "@medusajs/types"
import { Container, Heading, Text, Tooltip } from "@medusajs/ui" import { Container, Heading, Text } from "@medusajs/ui"
import { format } from "date-fns"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDate } from "../../../../../hooks/use-date"
type PricingConfigurationSectionProps = { type PricingConfigurationSectionProps = {
priceList: PriceList priceList: PriceListDTO
} }
export const PricingConfigurationSection = ({ export const PricingConfigurationSection = ({
priceList, priceList,
}: PricingConfigurationSectionProps) => { }: PricingConfigurationSectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { getFullDate } = useDate()
const firstCustomerGroups = priceList.customer_groups?.slice(0, 3) // TODO: Customer groups are not available in the price list schema out-of-the-box atm.
const remainingCustomerGroups = priceList.customer_groups?.slice(3) // const firstCustomerGroups = priceList.customer_groups?.slice(0, 3) || []
// const remainingCustomerGroups = priceList.customer_groups?.slice(3) || []
return ( return (
<Container className="divide-y p-0"> <Container className="divide-y p-0">
@@ -27,7 +29,7 @@ export const PricingConfigurationSection = ({
actions: [ actions: [
{ {
label: t("actions.edit"), label: t("actions.edit"),
to: "configurations/edit", to: "configuration",
icon: <PencilSquare />, icon: <PencilSquare />,
}, },
], ],
@@ -41,7 +43,7 @@ export const PricingConfigurationSection = ({
</Text> </Text>
<Text size="small" className="text-pretty"> <Text size="small" className="text-pretty">
{priceList.starts_at {priceList.starts_at
? format(new Date(priceList.starts_at), "dd MMM yyyy") ? getFullDate({ date: priceList.starts_at })
: "-"} : "-"}
</Text> </Text>
</div> </div>
@@ -50,12 +52,10 @@ export const PricingConfigurationSection = ({
{t("fields.endDate")} {t("fields.endDate")}
</Text> </Text>
<Text size="small" className="text-pretty"> <Text size="small" className="text-pretty">
{priceList.ends_at {priceList.ends_at ? getFullDate({ date: priceList.ends_at }) : "-"}
? format(new Date(priceList.ends_at), "dd MMM yyyy")
: "-"}
</Text> </Text>
</div> </div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4"> {/* <div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text leading="compact" size="small" weight="plus"> <Text leading="compact" size="small" weight="plus">
{t("pricing.settings.customerGroupsLabel")} {t("pricing.settings.customerGroupsLabel")}
</Text> </Text>
@@ -84,7 +84,7 @@ export const PricingConfigurationSection = ({
</Tooltip> </Tooltip>
)} )}
</Text> </Text>
</div> </div> */}
</Container> </Container>
) )
} }
@@ -1,14 +1,14 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { PencilSquare, Trash } from "@medusajs/icons"
import { PriceList } from "@medusajs/medusa" import { PriceListDTO } from "@medusajs/types"
import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui" import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui"
import { useAdminDeletePriceList } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeletePriceList } from "../../../../../hooks/api/price-lists"
import { getPriceListStatus } from "../../../common/utils" import { getPriceListStatus } from "../../../common/utils"
type PricingGeneralSectionProps = { type PricingGeneralSectionProps = {
priceList: PriceList priceList: PriceListDTO
} }
export const PricingGeneralSection = ({ export const PricingGeneralSection = ({
@@ -18,7 +18,7 @@ export const PricingGeneralSection = ({
const navigate = useNavigate() const navigate = useNavigate()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync } = useAdminDeletePriceList(priceList.id) const { mutateAsync } = useDeletePriceList(priceList.id)
const overrideCount = priceList.prices?.length || 0 const overrideCount = priceList.prices?.length || 0
@@ -28,7 +28,7 @@ export const PricingGeneralSection = ({
const res = await prompt({ const res = await prompt({
title: t("general.areYouSure"), title: t("general.areYouSure"),
description: t("pricing.deletePriceListWarning", { description: t("pricing.deletePriceListWarning", {
name: priceList.name, name: priceList.title,
}), }),
confirmText: t("actions.delete"), confirmText: t("actions.delete"),
cancelText: t("actions.cancel"), cancelText: t("actions.cancel"),
@@ -53,7 +53,7 @@ export const PricingGeneralSection = ({
return ( return (
<Container className="divide-y p-0"> <Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
<Heading>{priceList.name}</Heading> <Heading>{priceList.title}</Heading>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<StatusBadge color={color}>{text}</StatusBadge> <StatusBadge color={color}>{text}</StatusBadge>
<ActionMenu <ActionMenu
@@ -1,23 +1,22 @@
import { PencilSquare, Plus } from "@medusajs/icons" import { PencilSquare, Plus, Trash } from "@medusajs/icons"
import { PriceList, Product } from "@medusajs/medusa" import { PriceListDTO } from "@medusajs/types"
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui" import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import {
useAdminDeletePriceListProductsPrices,
useAdminPriceListProducts,
} from "medusa-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useProducts } from "../../../../../hooks/api/products"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns" import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters" import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query" import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { ExtendedProductDTO } from "../../../../../types/api-responses"
type PricingProductSectionProps = { type PricingProductSectionProps = {
priceList: PriceList priceList: PriceListDTO
} }
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@@ -36,23 +35,21 @@ export const PricingProductSection = ({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
prefix: PREFIX, prefix: PREFIX,
}) })
const { products, count, isLoading, isError, error } = const { products, count, isLoading, isError, error } = useProducts(
useAdminPriceListProducts( {
priceList.id, ...searchParams,
{ price_list_id: [priceList.id],
...searchParams, },
expand: "variants,sales_channels,collection", {
}, placeholderData: keepPreviousData,
{ }
keepPreviousData: true, )
}
)
const filters = useProductTableFilters() const filters = useProductTableFilters()
const columns = useColumns() const columns = useColumns()
const { table } = useDataTable({ const { table } = useDataTable({
data: (products || []) as Product[], data: products || [],
count, count,
columns, columns,
enablePagination: true, enablePagination: true,
@@ -66,7 +63,6 @@ export const PricingProductSection = ({
prefix: PREFIX, prefix: PREFIX,
}) })
const { mutateAsync } = useAdminDeletePriceListProductsPrices(priceList.id)
const handleDelete = async () => { const handleDelete = async () => {
const res = await prompt({ const res = await prompt({
title: t("general.areYouSure"), title: t("general.areYouSure"),
@@ -81,9 +77,9 @@ export const PricingProductSection = ({
return return
} }
await mutateAsync({ // The endpoint to batch remove prices by product ids is not implemented in
product_ids: Object.keys(rowSelection), // v2. We either need to implement it or remove the feature.
}) console.log("Not implemented yet.")
} }
const handleEdit = async () => { const handleEdit = async () => {
@@ -149,7 +145,31 @@ export const PricingProductSection = ({
) )
} }
const columnHelper = createColumnHelper<Product>() const ProductRowAction = ({ product }: { product: ExtendedProductDTO }) => {
const { t } = useTranslation()
// TODO: The endpoint to remove prices by product id is not implemented in v2.
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <Trash />,
label: t("actions.remove"),
onClick: () => {
console.log("Not implemented yet.")
},
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<ExtendedProductDTO>()
const useColumns = () => { const useColumns = () => {
const base = useProductTableColumns() const base = useProductTableColumns()
@@ -185,6 +205,10 @@ const useColumns = () => {
}, },
}), }),
...base, ...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => <ProductRowAction product={row.original} />,
}),
], ],
[base] [base]
) )
@@ -1 +1,2 @@
export { pricingLoader as loader } from "./loader"
export { PricingDetail as Component } from "./pricing-detail" export { PricingDetail as Component } from "./pricing-detail"
@@ -0,0 +1,20 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { priceListsQueryKeys } from "../../../hooks/api/price-lists"
import { client } from "../../../lib/client"
import { queryClient } from "../../../lib/medusa"
import { PriceListRes } from "../../../types/api-responses"
const pricingDetailQuery = (id: string) => ({
queryKey: priceListsQueryKeys.detail(id),
queryFn: async () => client.priceLists.retrieve(id),
})
export const pricingLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = pricingDetailQuery(id!)
return (
queryClient.getQueryData<PriceListRes>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}
@@ -1,6 +1,6 @@
import { useAdminPriceList } from "medusa-react"
import { Outlet, useParams } from "react-router-dom" import { Outlet, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section" import { JsonViewSection } from "../../../components/common/json-view-section"
import { usePriceList } from "../../../hooks/api/price-lists"
import { PricingConfigurationSection } from "./components/pricing-configuration-section" import { PricingConfigurationSection } from "./components/pricing-configuration-section"
import { PricingGeneralSection } from "./components/pricing-general-section" import { PricingGeneralSection } from "./components/pricing-general-section"
import { PricingProductSection } from "./components/pricing-product-section" import { PricingProductSection } from "./components/pricing-product-section"
@@ -8,7 +8,7 @@ import { PricingProductSection } from "./components/pricing-product-section"
export const PricingDetail = () => { export const PricingDetail = () => {
const { id } = useParams() const { id } = useParams()
const { price_list, isLoading, isError, error } = useAdminPriceList(id!) const { price_list, isLoading, isError, error } = usePriceList(id!)
if (isLoading || !price_list) { if (isLoading || !price_list) {
return <div>Loading...</div> return <div>Loading...</div>
@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { PriceList } from "@medusajs/medusa" import { PriceListDTO } from "@medusajs/types"
import { Button, Input, RadioGroup, Switch, Textarea } from "@medusajs/ui" import { Button, Input, RadioGroup, Textarea } from "@medusajs/ui"
import { useAdminUpdatePriceList } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { z } from "zod" import { z } from "zod"
@@ -10,17 +9,17 @@ import {
RouteDrawer, RouteDrawer,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useUpdatePriceList } from "../../../../../hooks/api/price-lists"
import { PriceListType } from "../../../common/constants" import { PriceListType } from "../../../common/constants"
type EditPriceListFormProps = { type EditPriceListFormProps = {
priceList: PriceList priceList: PriceListDTO
} }
const EditPriceListFormSchema = z.object({ const EditPriceListFormSchema = z.object({
type: z.nativeEnum(PriceListType), type: z.nativeEnum(PriceListType),
name: z.string().min(1), title: z.string().min(1),
description: z.string().min(1), description: z.string().min(1),
includes_tax: z.boolean(),
}) })
export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => { export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
@@ -30,14 +29,13 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
const form = useForm<z.infer<typeof EditPriceListFormSchema>>({ const form = useForm<z.infer<typeof EditPriceListFormSchema>>({
defaultValues: { defaultValues: {
type: priceList.type, type: priceList.type,
name: priceList.name, title: priceList.title,
description: priceList.description, description: priceList.description,
includes_tax: priceList.includes_tax,
}, },
resolver: zodResolver(EditPriceListFormSchema), resolver: zodResolver(EditPriceListFormSchema),
}) })
const { mutateAsync } = useAdminUpdatePriceList(priceList.id) const { mutateAsync } = useUpdatePriceList(priceList.id)
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(values, { await mutateAsync(values, {
@@ -85,11 +83,11 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="name" name="title"
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label>{t("fields.name")}</Form.Label> <Form.Label>{t("fields.title")}</Form.Label>
<Form.Control> <Form.Control>
<Input {...field} /> <Input {...field} />
</Form.Control> </Form.Control>
@@ -114,32 +112,6 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
}} }}
/> />
</div> </div>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>{t("fields.taxInclusivePricing")}</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>
{t("pricing.settings.taxInclusivePricingHint")}
</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
</RouteDrawer.Body> </RouteDrawer.Body>
<RouteDrawer.Footer className="shrink-0"> <RouteDrawer.Footer className="shrink-0">
<div className="flex items-center justify-end gap-x-2"> <div className="flex items-center justify-end gap-x-2">
@@ -1,15 +1,15 @@
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useAdminPriceList } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { usePriceList } from "../../../hooks/api/price-lists"
import { EditPriceListForm } from "./components/edit-price-list-form" import { EditPriceListForm } from "./components/edit-price-list-form"
export const PricingEdit = () => { export const PricingEdit = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { id } = useParams() const { id } = useParams()
const { price_list, isLoading, isError, error } = useAdminPriceList(id!) const { price_list, isLoading, isError, error } = usePriceList(id!)
const ready = !isLoading && price_list const ready = !isLoading && price_list
@@ -1,8 +1,9 @@
import { Button, Container, Heading } from "@medusajs/ui" import { Button, Container, Heading } from "@medusajs/ui"
import { useAdminPriceLists } from "medusa-react" import { keepPreviousData } from "@tanstack/react-query"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { usePriceLists } from "../../../../../hooks/api/price-lists"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { usePricingTableColumns } from "./use-pricing-table-columns" import { usePricingTableColumns } from "./use-pricing-table-columns"
import { usePricingTableQuery } from "./use-pricing-table-query" import { usePricingTableQuery } from "./use-pricing-table-query"
@@ -15,12 +16,13 @@ export const PricingListTable = () => {
const { searchParams, raw } = usePricingTableQuery({ const { searchParams, raw } = usePricingTableQuery({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}) })
const { price_lists, count, isLoading, isError, error } = useAdminPriceLists( const { price_lists, count, isLoading, isError, error } = usePriceLists(
// {
// ...searchParams, // The query params are not implemented, and any search params other than expand and fields will throw an error
// },
undefined,
{ {
...searchParams, placeholderData: keepPreviousData,
},
{
keepPreviousData: true,
} }
) )
@@ -1,12 +1,12 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { PencilSquare, Trash } from "@medusajs/icons"
import { PriceList } from "@medusajs/medusa" import { PriceListDTO } from "@medusajs/types"
import { usePrompt } from "@medusajs/ui" import { usePrompt } from "@medusajs/ui"
import { useAdminDeletePriceList } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeletePriceList } from "../../../../../hooks/api/price-lists"
type PricingTableActionsProps = { type PricingTableActionsProps = {
priceList: PriceList priceList: PriceListDTO
} }
export const PricingTableActions = ({ export const PricingTableActions = ({
@@ -15,13 +15,13 @@ export const PricingTableActions = ({
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync } = useAdminDeletePriceList(priceList.id) const { mutateAsync } = useDeletePriceList(priceList.id)
const handleDelete = async () => { const handleDelete = async () => {
const res = await prompt({ const res = await prompt({
title: t("general.areYouSure"), title: t("general.areYouSure"),
description: t("pricing.deletePriceListWarning", { description: t("pricing.deletePriceListWarning", {
name: priceList.name, name: priceList.title,
}), }),
confirmText: t("actions.delete"), confirmText: t("actions.delete"),
cancelText: t("actions.cancel"), cancelText: t("actions.cancel"),
@@ -1,4 +1,4 @@
import { PriceList } from "@medusajs/medusa" import { PriceListDTO } from "@medusajs/types"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -6,32 +6,17 @@ import { StatusCell } from "../../../../../components/table/table-cells/common/s
import { getPriceListStatus } from "../../../common/utils" import { getPriceListStatus } from "../../../common/utils"
import { PricingTableActions } from "./pricing-table-actions" import { PricingTableActions } from "./pricing-table-actions"
const columnHelper = createColumnHelper<PriceList>() const columnHelper = createColumnHelper<PriceListDTO>()
export const usePricingTableColumns = () => { export const usePricingTableColumns = () => {
const { t } = useTranslation() const { t } = useTranslation()
return useMemo( return useMemo(
() => [ () => [
columnHelper.accessor("name", { columnHelper.accessor("title", {
header: t("fields.name"), header: t("fields.name"),
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("type", {
header: t("fields.type"),
cell: ({ getValue }) => {
const label =
getValue() === "sale"
? t("pricing.type.sale")
: t("pricing.type.override")
return (
<div className="flex size-full items-center overflow-hidden">
<span>{label}</span>
</div>
)
},
}),
columnHelper.accessor("status", { columnHelper.accessor("status", {
header: t("fields.status"), header: t("fields.status"),
cell: ({ row }) => { cell: ({ row }) => {
@@ -40,8 +25,8 @@ export const usePricingTableColumns = () => {
return <StatusCell color={color}>{text}</StatusCell> return <StatusCell color={color}>{text}</StatusCell>
}, },
}), }),
columnHelper.accessor("customer_groups", { columnHelper.accessor("prices", {
header: t("customerGroups.domain"), header: t("fields.prices"),
cell: (info) => info.getValue()?.length || "-", cell: (info) => info.getValue()?.length || "-",
}), }),
columnHelper.display({ columnHelper.display({
@@ -0,0 +1 @@
export * from "./pricing-products-prices-form"
@@ -0,0 +1,388 @@
import {
CreatePriceListPriceDTO,
PriceListDTO,
UpdatePriceListPriceDTO,
} from "@medusajs/types"
import { Button } from "@medusajs/ui"
import { UseFormReturn, useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useMemo } from "react"
import { DataGrid } from "../../../../../components/grid/data-grid"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCurrencies } from "../../../../../hooks/api/currencies"
import {
usePriceListAddPrices,
usePriceListRemovePrices,
useUpdatePriceList,
} from "../../../../../hooks/api/price-lists"
import { useStore } from "../../../../../hooks/api/store"
import { castNumber } from "../../../../../lib/cast-number"
import {
getDbAmount,
getPresentationalAmount,
} from "../../../../../lib/money-amount-helpers"
import { ExtendedProductDTO } from "../../../../../types/api-responses"
import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns"
import {
PricingProductsRecordSchema,
PricingVariantsRecordType,
} from "../../../common/schemas"
import { isProductRow } from "../../../common/utils"
type PricingProductPricesFormProps = {
priceList: PriceListDTO
products: ExtendedProductDTO[]
}
const PricingProductPricesSchema = z.object({
products: PricingProductsRecordSchema,
})
type VariantsPriceRecord = Record<
string,
{ currency_code: string; amount: number; id: string }[]
>
const initRecord = (priceList: PriceListDTO): VariantsPriceRecord => {
const prices = priceList.prices
const sortedPrices: VariantsPriceRecord = {}
if (!prices) {
return sortedPrices
}
prices.forEach((price) => {
// @ts-ignore - Type is wrong
const { variant_id, currency_code, amount, id } = price
if (!currency_code || !amount || !variant_id) {
return
}
if (!sortedPrices[variant_id]) {
sortedPrices[variant_id] = []
}
sortedPrices[variant_id] = [
...sortedPrices[variant_id],
{ currency_code, amount, id },
]
})
return sortedPrices
}
export const PricingProductPricesForm = ({
priceList,
products,
}: PricingProductPricesFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const record = useMemo(() => initRecord(priceList), [priceList])
const {
store,
isLoading: isStoreLoading,
isError: isStoreError,
error: storeError,
} = useStore()
const {
currencies,
isLoading: isCurrenciesLoading,
isError: isCurrencyError,
error: currencyError,
} = useCurrencies(
{
code: store?.supported_currency_codes,
},
{
enabled: !!store,
}
)
const form = useForm<z.infer<typeof PricingProductPricesSchema>>({
defaultValues: {
products: {},
},
resolver: zodResolver(PricingProductPricesSchema),
})
const existingProducts = useWatch({
control: form.control,
name: "products",
})
useEffect(() => {
initDefaultValues(products, existingProducts, form, record)
}, [existingProducts, form, products, record])
const { mutateAsync: updateAsync, isPending: isUpdatePending } =
useUpdatePriceList(priceList.id)
const { mutateAsync: deleteAsync, isPending: isDeletePending } =
usePriceListRemovePrices(priceList.id)
const { mutateAsync: addAsync, isPending: isAddPending } =
usePriceListAddPrices(priceList.id)
const isPending = isUpdatePending || isDeletePending || isAddPending
const handleSubmit = form.handleSubmit(async (values) => {
const { products } = values
const { pricesToDelete, pricesToCreate, pricesToUpdate } = sortPrices(
products,
record
)
let failed = false
// TODO: Currently not working, need to fix the API
// await updateAsync(
// {
// // @ts-expect-error - type is wrong
// prices: pricesToUpdate,
// },
// {
// onError(error) {
// console.error(error)
// failed = true
// },
// }
// )
// if (failed) {
// return
// }
if (pricesToDelete.length) {
await deleteAsync(
{
ids: pricesToDelete,
},
{
onError(error) {
console.error(error)
failed = true
},
}
)
}
if (failed) {
return
}
if (pricesToCreate.length) {
await addAsync(
{
// @ts-expect-error - type is wrong
prices: pricesToCreate,
},
{
onError(error) {
console.error(error)
failed = true
},
}
)
}
if (failed) {
return
}
handleSuccess()
})
const columns = usePriceListGridColumns({ currencies })
const initializing =
isStoreLoading || isCurrenciesLoading || !products || !store || !currencies
if (isStoreError) {
throw storeError
}
if (isCurrencyError) {
throw currencyError
}
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex size-full flex-col">
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
<DataGrid
columns={columns}
data={products}
getSubRows={(row) => {
if (isProductRow(row)) {
return row.variants
}
}}
isLoading={initializing}
state={form}
/>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
function initDefaultValues(
products: ExtendedProductDTO[],
existingProducts: any,
form: UseFormReturn<z.infer<typeof PricingProductPricesSchema>>,
record: VariantsPriceRecord
) {
products.forEach((product) => {
if (existingProducts[product.id] || !product.variants) {
return
}
form.setValue(
`products.${product.id}.variants`,
{
...product.variants.reduce((variants, variant) => {
const currencyPrices = record[variant.id] || []
variants[variant.id] = {
currency_prices: currencyPrices.reduce(
(prices, { currency_code, amount, id }) => {
const presentationAmount = getPresentationalAmount(
amount,
currency_code
).toString()
prices[currency_code] = {
amount: presentationAmount,
id,
}
return prices
},
{} as Record<
string,
{
amount: string
id: string
}
>
),
region_prices: {},
}
return variants
}, {} as PricingVariantsRecordType),
},
{
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
}
)
})
}
function sortPrices(
products: Record<
string,
{
variants: Record<
string,
{
currency_prices: Record<
string,
| { amount?: string | undefined; id?: string | null | undefined }
| undefined
>
region_prices: Record<
string,
| { amount?: string | undefined; id?: string | null | undefined }
| undefined
>
}
>
}
>,
record: VariantsPriceRecord
) {
const pricesToUpdate: UpdatePriceListPriceDTO[] = []
const pricesToCreate: CreatePriceListPriceDTO[] = []
const pricesToDelete: string[] = []
for (const [_productId, product] of Object.entries(products)) {
const { variants } = product
for (const [variantId, variant] of Object.entries(variants)) {
const { currency_prices } = variant
for (const [currencyCode, currencyPrice] of Object.entries(
currency_prices
)) {
if (!currencyPrice) {
continue
}
if (currencyPrice.id && currencyPrice.amount) {
const originalPrice = record[variantId].find(
(p) => p.id === currencyPrice.id
)
// If the price has not changed, we don't need to update it
if (
originalPrice &&
originalPrice.amount ===
getDbAmount(castNumber(currencyPrice.amount), currencyCode)
) {
continue
}
pricesToUpdate.push({
id: currencyPrice.id,
amount: getDbAmount(castNumber(currencyPrice.amount), currencyCode),
currency_code: currencyCode,
// @ts-expect-error type is wrong
variant_id: variantId,
})
continue
}
if (currencyPrice.id && !currencyPrice.amount) {
pricesToDelete.push(currencyPrice.id)
continue
}
if (!currencyPrice.id && currencyPrice.amount) {
pricesToCreate.push({
amount: getDbAmount(castNumber(currencyPrice.amount), currencyCode),
currency_code: currencyCode,
// @ts-expect-error type is wrong
variant_id: variantId,
})
continue
}
}
}
}
return { pricesToDelete, pricesToCreate, pricesToUpdate }
}
@@ -0,0 +1 @@
export { PricingProductsPrices as Component } from "./pricing-products-prices"
@@ -0,0 +1,43 @@
import { useParams, useSearchParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { usePriceList } from "../../../hooks/api/price-lists"
import { useProducts } from "../../../hooks/api/products"
import { PricingProductPricesForm } from "./components/pricing-products-prices-form"
export const PricingProductsPrices = () => {
const { id } = useParams()
const [searchParams] = useSearchParams()
const ids = searchParams.get("ids[]")
const { price_list, isLoading, isError, error } = usePriceList(id!)
const productIds = ids?.split(",")
const {
products,
isLoading: isProductsLoading,
isError: isProductsError,
error: productError,
} = useProducts({
id: productIds,
limit: productIds?.length || 9999, // Temporary until we support lazy loading in the DataGrid
fields: "title,thumbnail,*variants",
})
const ready = !isLoading && !!price_list && !isProductsLoading && !!products
if (isError) {
throw error
}
if (isProductsError) {
throw productError
}
return (
<RouteFocusModal>
{ready && (
<PricingProductPricesForm priceList={price_list} products={products} />
)}
</RouteFocusModal>
)
}
@@ -5,23 +5,24 @@ import {
RowSelectionState, RowSelectionState,
createColumnHelper, createColumnHelper,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { adminCurrenciesKeys, useAdminCustomQuery } from "medusa-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { CurrencyDTO, StoreDTO } from "@medusajs/types" import { StoreDTO } from "@medusajs/types"
import { keepPreviousData } from "@tanstack/react-query"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { import {
RouteFocusModal, RouteFocusModal,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useCurrencies } from "../../../../../hooks/api/currencies"
import { useUpdateStore } from "../../../../../hooks/api/store"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { useCurrenciesTableColumns } from "../../../common/hooks/use-currencies-table-columns" import { useCurrenciesTableColumns } from "../../../common/hooks/use-currencies-table-columns"
import { useCurrenciesTableQuery } from "../../../common/hooks/use-currencies-table-query" import { useCurrenciesTableQuery } from "../../../common/hooks/use-currencies-table-query"
import { useUpdateStore } from "../../../../../hooks/api/store"
type AddCurrenciesFormProps = { type AddCurrenciesFormProps = {
store: StoreDTO store: StoreDTO
@@ -67,12 +68,10 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
prefix: PREFIX, prefix: PREFIX,
}) })
const { data, isLoading, isError, error } = useAdminCustomQuery( const { currencies, count, isLoading, isError, error } = useCurrencies(
"/admin/currencies",
adminCurrenciesKeys.list(raw),
searchParams, searchParams,
{ {
keepPreviousData: true, placeholderData: keepPreviousData,
} }
) )
@@ -81,9 +80,9 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
const columns = useColumns() const columns = useColumns()
const { table } = useDataTable({ const { table } = useDataTable({
data: (data?.currencies ?? []) as CurrencyDTO[], data: currencies ?? [],
columns, columns,
count: data?.count, count: count,
getRowId: (row) => row.code, getRowId: (row) => row.code,
enableRowSelection: (row) => !preSelectedRows.includes(row.original.code), enableRowSelection: (row) => !preSelectedRows.includes(row.original.code),
enablePagination: true, enablePagination: true,
@@ -95,7 +94,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
}, },
}) })
const { mutateAsync, isLoading: isMutating } = useUpdateStore(store.id) const { mutateAsync, isPending } = useUpdateStore(store.id)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
const currencies = Array.from( const currencies = Array.from(
@@ -139,7 +138,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
{t("actions.cancel")} {t("actions.cancel")}
</Button> </Button>
</RouteFocusModal.Close> </RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}> <Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")} {t("actions.save")}
</Button> </Button>
</div> </div>
@@ -149,7 +148,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
<DataTable <DataTable
table={table} table={table}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
count={data?.count} count={count}
columns={columns} columns={columns}
layout="fill" layout="fill"
pagination pagination
@@ -1,9 +1,9 @@
import { RouteFocusModal } from "../../../components/route-modal" import { RouteFocusModal } from "../../../components/route-modal"
import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form"
import { useStore } from "../../../hooks/api/store" import { useStore } from "../../../hooks/api/store"
import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form"
export const StoreAddCurrencies = () => { export const StoreAddCurrencies = () => {
const { store, isLoading, isError, error } = useStore({}) const { store, isLoading, isError, error } = useStore()
if (isError) { if (isError) {
throw error throw error
@@ -9,7 +9,6 @@ import {
} from "@medusajs/ui" } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query" import { keepPreviousData } from "@tanstack/react-query"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { adminStoreKeys, useAdminCustomPost } from "medusa-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../../components/common/action-menu"
@@ -157,10 +156,7 @@ const CurrencyActions = ({
currency: CurrencyDTO currency: CurrencyDTO
currencyCodes: string[] currencyCodes: string[]
}) => { }) => {
const { mutateAsync } = useAdminCustomPost( const { mutateAsync } = useUpdateStore(storeId)
`/admin/stores/${storeId}`,
adminStoreKeys.details()
)
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
+15 -55
View File
@@ -3854,15 +3854,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.23.7, @babel/runtime@npm:^7.23.8":
version: 7.23.9
resolution: "@babel/runtime@npm:7.23.9"
dependencies:
regenerator-runtime: ^0.14.0
checksum: e71205fdd7082b2656512cc98e647d9ea7e222e4fe5c36e9e5adc026446fcc3ba7b3cdff8b0b694a0b78bb85db83e7b1e3d4c56ef90726682b74f13249cf952d
languageName: node
linkType: hard
"@babel/runtime@npm:^7.19.4, @babel/runtime@npm:^7.20.6": "@babel/runtime@npm:^7.19.4, @babel/runtime@npm:^7.20.6":
version: 7.22.11 version: 7.22.11
resolution: "@babel/runtime@npm:7.22.11" resolution: "@babel/runtime@npm:7.22.11"
@@ -3890,6 +3881,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.23.7, @babel/runtime@npm:^7.23.8":
version: 7.23.9
resolution: "@babel/runtime@npm:7.23.9"
dependencies:
regenerator-runtime: ^0.14.0
checksum: e71205fdd7082b2656512cc98e647d9ea7e222e4fe5c36e9e5adc026446fcc3ba7b3cdff8b0b694a0b78bb85db83e7b1e3d4c56ef90726682b74f13249cf952d
languageName: node
linkType: hard
"@babel/template@npm:^7.12.13, @babel/template@npm:^7.12.7, @babel/template@npm:^7.16.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3": "@babel/template@npm:^7.12.13, @babel/template@npm:^7.12.7, @babel/template@npm:^7.16.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3":
version: 7.22.5 version: 7.22.5
resolution: "@babel/template@npm:7.22.5" resolution: "@babel/template@npm:7.22.5"
@@ -8182,12 +8182,12 @@ __metadata:
qs: ^6.12.0 qs: ^6.12.0
react: 18.2.0 react: 18.2.0
react-country-flag: ^3.1.0 react-country-flag: ^3.1.0
react-currency-input-field: ^3.6.11
react-dom: 18.2.0 react-dom: 18.2.0
react-focus-lock: ^2.11.1
react-hook-form: 7.49.1 react-hook-form: 7.49.1
react-i18next: 13.5.0 react-i18next: 13.5.0
react-jwt: ^1.2.0 react-jwt: ^1.2.0
react-resizable-panels: ^2.0.9 react-resizable-panels: ^2.0.16
react-router-dom: 6.20.1 react-router-dom: 6.20.1
tailwindcss: ^3.4.1 tailwindcss: ^3.4.1
typescript: 5.2.2 typescript: 5.2.2
@@ -28984,15 +28984,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"focus-lock@npm:^1.3.2":
version: 1.3.3
resolution: "focus-lock@npm:1.3.3"
dependencies:
tslib: ^2.0.3
checksum: 38b978ab30f5be6f061689c747b05193217e9cb9c5d5a41b9b322454c6bb14e84cbd6c928fa245c62e744f070cfa939820a0ae2dd51011da233de7c912cea412
languageName: node
linkType: hard
"follow-redirects@npm:1.5.10": "follow-redirects@npm:1.5.10":
version: 1.5.10 version: 1.5.10
resolution: "follow-redirects@npm:1.5.10" resolution: "follow-redirects@npm:1.5.10"
@@ -43946,17 +43937,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-clientside-effect@npm:^1.2.6":
version: 1.2.6
resolution: "react-clientside-effect@npm:1.2.6"
dependencies:
"@babel/runtime": ^7.12.13
peerDependencies:
react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
checksum: aba0adb666018e5c64657c31f4914a8558be73f71d6e2210fa871ebfcab94d786c83082868d7c7fa66feddc2aec19e375745cf0903e0619f2efffef08b92d941
languageName: node
linkType: hard
"react-collapsible@npm:^2.8.3": "react-collapsible@npm:^2.8.3":
version: 2.10.0 version: 2.10.0
resolution: "react-collapsible@npm:2.10.0" resolution: "react-collapsible@npm:2.10.0"
@@ -44227,26 +44207,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-focus-lock@npm:^2.11.1":
version: 2.11.1
resolution: "react-focus-lock@npm:2.11.1"
dependencies:
"@babel/runtime": ^7.0.0
focus-lock: ^1.3.2
prop-types: ^15.6.2
react-clientside-effect: ^1.2.6
use-callback-ref: ^1.3.0
use-sidecar: ^1.1.2
peerDependencies:
"@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
"@types/react":
optional: true
checksum: b4905ec18bb6bd6732cbd2d3bd70853007dcc31363fa8d0700ea645eec2e2e685f2fe09443d070983c2077fc2d6c06279f2838fc9046c6218facc3e310e50279
languageName: node
linkType: hard
"react-helmet-async@npm:^1.3.0": "react-helmet-async@npm:^1.3.0":
version: 1.3.0 version: 1.3.0
resolution: "react-helmet-async@npm:1.3.0" resolution: "react-helmet-async@npm:1.3.0"
@@ -44562,13 +44522,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-resizable-panels@npm:^2.0.9": "react-resizable-panels@npm:^2.0.16":
version: 2.0.9 version: 2.0.16
resolution: "react-resizable-panels@npm:2.0.9" resolution: "react-resizable-panels@npm:2.0.16"
peerDependencies: peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 react: ^16.14.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
checksum: 0302ab32d3aa7d2887a2ad58882e447a8e22cef3c5c728cde7436442fcd7475969a86bb45d1a6e285b1b4e2a9ac49d13a0e2df4b29bc63542dc99044309e211c checksum: 91e125a10de5908cdca9c82247106927a98a2686097cca5aa31415d3ba7013425f3b3c10f1465eadcc6a4977a2d549cabadb71c58586cddfae4995c8bb575ace
languageName: node languageName: node
linkType: hard linkType: hard