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
2024-04-08 18:22:53 +00:00
committed by GitHub
parent 6cc9a5e469
commit 0f05790ae4
81 changed files with 2628 additions and 745 deletions
+2 -2
View File
@@ -38,12 +38,12 @@
"qs": "^6.12.0",
"react": "18.2.0",
"react-country-flag": "^3.1.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "18.2.0",
"react-focus-lock": "^2.11.1",
"react-hook-form": "7.49.1",
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
"react-resizable-panels": "^2.0.9",
"react-resizable-panels": "^2.0.16",
"react-router-dom": "6.20.1",
"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,
useCommandHistory,
} from "../../../../hooks/use-command-history"
import { GridCellType, NON_INTERACTIVE_CELL_TYPES } from "../../constants"
type FieldCoordinates = {
column: number
@@ -28,9 +29,9 @@ type FieldCoordinates = {
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
> {
data: TData[]
data?: TData[]
columns: ColumnDef<TData>[]
state: UseFormReturn<TFieldValues>
getSubRows: (row: TData) => TData[] | undefined
@@ -40,9 +41,9 @@ const ROW_HEIGHT = 40
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
>({
data,
data = [],
columns,
state,
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>) => {
e.stopPropagation()
e.preventDefault()
const target = e.target
/**
* Check if the click was on a presentation element.
* If so, we don't want to set the anchor.
*/
if (
target instanceof HTMLElement &&
target.querySelector("[data-role=presentation]")
) {
if (target instanceof HTMLElement && isNonInteractive(target)) {
return
}
@@ -121,6 +140,9 @@ export const DataGridRoot = <
if (e.detail === 2 || isAnchor) {
handleFocusInner(e.currentTarget)
return
} else {
// reset focus so the previous cell doesn't keep the focus
handleBlurAnchor()
}
const coordinates: FieldCoordinates = {
@@ -157,10 +179,7 @@ export const DataGridRoot = <
* Check if the click was on a presentation element.
* If so, we don't want to add it to the selection.
*/
if (
target instanceof HTMLElement &&
target.querySelector("[data-role=presentation]")
) {
if (target instanceof HTMLElement && isNonInteractive(target)) {
return
}
@@ -487,7 +506,7 @@ export const DataGridRoot = <
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
return (
<div className="size-full overflow-hidden">
<div className="bg-ui-bg-subtle size-full overflow-hidden">
<div
ref={tableContainerRef}
style={{
@@ -559,10 +578,11 @@ export const DataGridRoot = <
data-column-index={index}
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",
"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,
"bg-ui-bg-highlight": isSelected,
"bg-ui-bg-highlight focus-within:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-base-hover": isDragTarget,
}
)}
@@ -1,14 +1,14 @@
import { Select } from "@medusajs/ui"
import { Controller, FieldValues } from "react-hook-form"
import { FieldProps } from "../../../types"
import { CellProps } from "../../../types"
interface BooleanFieldProps<TFieldValues extends FieldValues = any>
extends FieldProps<TFieldValues> {}
interface BooleanCellProps<TFieldValues extends FieldValues = any>
extends CellProps<TFieldValues> {}
export const BooleanField = <TFieldValues extends FieldValues = any>({
export const BooleanCell = <TFieldValues extends FieldValues = any>({
field,
meta,
}: BooleanFieldProps<TFieldValues>) => {
}: BooleanCellProps<TFieldValues>) => {
const { control } = meta
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 { FieldProps } from "../../../types"
import { CellProps } from "../../../types"
interface TextFieldProps<TFieldValues extends FieldValues = any>
extends FieldProps<TFieldValues> {}
interface TextCellProps<TFieldValues extends FieldValues = any>
extends CellProps<TFieldValues> {}
export const TextField = <TFieldValues extends FieldValues = any>({
export const TextCell = <TFieldValues extends FieldValues = any>({
field,
meta,
}: TextFieldProps<TFieldValues>) => {
}: TextCellProps<TFieldValues>) => {
const { register } = meta
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>
}
export interface FieldProps<TFieldValues extends FieldValues = FieldValues> {
export interface CellProps<TFieldValues extends FieldValues = FieldValues> {
field: Path<TFieldValues>
meta: DataGridMeta<TFieldValues>
}
@@ -1,12 +1,17 @@
import { Tooltip } from "@medusajs/ui"
import format from "date-fns/format"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../placeholder-cell"
type DateCellProps = {
date: Date
date: Date | string | undefined
}
export const CreatedAtCell = ({ date }: DateCellProps) => {
if (!date) {
return <PlaceholderCell />
}
const value = new Date(date)
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
@@ -7,10 +7,10 @@ type StatusCellProps = PropsWithChildren<{
export const StatusCell = ({ color, children }: StatusCellProps) => {
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
role="presentation"
className="flex size-5 items-center justify-center"
className="flex h-5 w-2 items-center justify-center"
>
<div
className={clx(
@@ -1,10 +1,10 @@
import {
AdminCustomerGroupListResponse,
AdminCustomerGroupResponse,
} from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import {
AdminCustomerGroupResponse,
AdminCustomerGroupListResponse,
} from "@medusajs/types"
const CUSTOMER_GROUPS_QUERY_KEY = "customer_groups" as const
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 { useMemo } from "react"
import { Text } from "@medusajs/ui"
import { NameHeader } from "../../../components/table/table-cells/common/name-cell"
import { CustomerGroupDTO } from "@medusajs/types"
import {
CreatedAtHeader,
CreatedAtCell,
CreatedAtHeader,
} 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 = () => {
return useMemo(
@@ -8,6 +8,7 @@ import { customerGroups } from "./customer-groups"
import { customers } from "./customers"
import { invites } from "./invites"
import { payments } from "./payments"
import { priceLists } from "./price-lists"
import { productTypes } from "./product-types"
import { products } from "./products"
import { promotions } from "./promotions"
@@ -40,6 +41,7 @@ export const client = {
invites: invites,
products: products,
productTypes: productTypes,
priceLists: priceLists,
stockLocations: stockLocations,
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"
const getDecimalDigits = (currency: string) => {
return currencies[currency.toUpperCase()]?.decimal_digits
export const getDecimalDigits = (currency: string) => {
return currencies[currency.toUpperCase()]?.decimal_digits ?? 0
}
/**
@@ -18,7 +18,7 @@ const getDecimalDigits = (currency: string) => {
export const getPresentationalAmount = (amount: number, currency: string) => {
const decimalDigits = getDecimalDigits(currency)
if (!decimalDigits) {
if (decimalDigits === undefined) {
throw new Error("Currency has no decimal digits")
}
@@ -36,9 +36,9 @@ export const getPresentationalAmount = (amount: number, currency: string) => {
* getDbAmount(10, "jpy") // 10
*/
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")
}
@@ -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,
@@ -17,6 +17,7 @@ import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout-v2/main-layout"
import { SettingsLayout } from "../../components/layout/settings-layout"
import { useMe } from "../../hooks/api/users"
import { PriceListRes } from "../../types/api-responses"
import { SearchProvider } from "../search-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",
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,
CreateCustomerDTO,
CreateInviteDTO,
CreatePriceListDTO,
CreateProductCollectionDTO,
CreatePromotionDTO,
CreatePromotionRuleDTO,
@@ -16,6 +17,7 @@ import {
UpdateApiKeyDTO,
UpdateCampaignDTO,
UpdateCustomerDTO,
UpdatePriceListDTO,
UpdateProductCollectionDTO,
UpdatePromotionDTO,
UpdatePromotionRuleDTO,
@@ -64,6 +66,18 @@ export type UpdateStockLocationReq = UpdateStockLocationInput
export type CreateProductCollectionReq = CreateProductCollectionDTO
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
export type CreatePromotionReq = CreatePromotionDTO
export type UpdatePromotionReq = UpdatePromotionDTO
@@ -5,8 +5,10 @@
import {
CampaignDTO,
CurrencyDTO,
CustomerGroupDTO,
InviteDTO,
PaymentProviderDTO,
PriceListDTO,
ProductCategoryDTO,
ProductCollectionDTO,
ProductDTO,
@@ -137,3 +139,15 @@ export type ProductCollectionListRes = {
collections: ProductCollectionDTO[]
} & ListRes
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 { ExtendedProductDTO } from "../../../types/api-responses"
import { PriceListStatus } from "./constants"
const getValues = (priceList: PriceList) => {
const getValues = (priceList: PriceListDTO) => {
const startsAt = priceList.starts_at
const endsAt = priceList.ends_at
@@ -19,7 +20,7 @@ const getValues = (priceList: PriceList) => {
export const getPriceListStatus = (
t: TFunction<"translation">,
priceList: PriceList
priceList: PriceListDTO
) => {
const { isExpired, isScheduled, isDraft } = getValues(priceList)
@@ -46,3 +47,9 @@ export const getPriceListStatus = (
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 { PriceList } from "@medusajs/medusa"
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
import { format } from "date-fns"
import { PriceListDTO } from "@medusajs/types"
import { Container, Heading, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDate } from "../../../../../hooks/use-date"
type PricingConfigurationSectionProps = {
priceList: PriceList
priceList: PriceListDTO
}
export const PricingConfigurationSection = ({
priceList,
}: PricingConfigurationSectionProps) => {
const { t } = useTranslation()
const { getFullDate } = useDate()
const firstCustomerGroups = priceList.customer_groups?.slice(0, 3)
const remainingCustomerGroups = priceList.customer_groups?.slice(3)
// TODO: Customer groups are not available in the price list schema out-of-the-box atm.
// const firstCustomerGroups = priceList.customer_groups?.slice(0, 3) || []
// const remainingCustomerGroups = priceList.customer_groups?.slice(3) || []
return (
<Container className="divide-y p-0">
@@ -27,7 +29,7 @@ export const PricingConfigurationSection = ({
actions: [
{
label: t("actions.edit"),
to: "configurations/edit",
to: "configuration",
icon: <PencilSquare />,
},
],
@@ -41,7 +43,7 @@ export const PricingConfigurationSection = ({
</Text>
<Text size="small" className="text-pretty">
{priceList.starts_at
? format(new Date(priceList.starts_at), "dd MMM yyyy")
? getFullDate({ date: priceList.starts_at })
: "-"}
</Text>
</div>
@@ -50,12 +52,10 @@ export const PricingConfigurationSection = ({
{t("fields.endDate")}
</Text>
<Text size="small" className="text-pretty">
{priceList.ends_at
? format(new Date(priceList.ends_at), "dd MMM yyyy")
: "-"}
{priceList.ends_at ? getFullDate({ date: priceList.ends_at }) : "-"}
</Text>
</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">
{t("pricing.settings.customerGroupsLabel")}
</Text>
@@ -84,7 +84,7 @@ export const PricingConfigurationSection = ({
</Tooltip>
)}
</Text>
</div>
</div> */}
</Container>
)
}
@@ -1,14 +1,14 @@
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 { useAdminDeletePriceList } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeletePriceList } from "../../../../../hooks/api/price-lists"
import { getPriceListStatus } from "../../../common/utils"
type PricingGeneralSectionProps = {
priceList: PriceList
priceList: PriceListDTO
}
export const PricingGeneralSection = ({
@@ -18,7 +18,7 @@ export const PricingGeneralSection = ({
const navigate = useNavigate()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
const { mutateAsync } = useDeletePriceList(priceList.id)
const overrideCount = priceList.prices?.length || 0
@@ -28,7 +28,7 @@ export const PricingGeneralSection = ({
const res = await prompt({
title: t("general.areYouSure"),
description: t("pricing.deletePriceListWarning", {
name: priceList.name,
name: priceList.title,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
@@ -53,7 +53,7 @@ export const PricingGeneralSection = ({
return (
<Container className="divide-y p-0">
<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">
<StatusBadge color={color}>{text}</StatusBadge>
<ActionMenu
@@ -1,23 +1,22 @@
import { PencilSquare, Plus } from "@medusajs/icons"
import { PriceList, Product } from "@medusajs/medusa"
import { PencilSquare, Plus, Trash } from "@medusajs/icons"
import { PriceListDTO } from "@medusajs/types"
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import {
useAdminDeletePriceListProductsPrices,
useAdminPriceListProducts,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
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"
type PricingProductSectionProps = {
priceList: PriceList
priceList: PriceListDTO
}
const PAGE_SIZE = 10
@@ -36,23 +35,21 @@ export const PricingProductSection = ({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { products, count, isLoading, isError, error } =
useAdminPriceListProducts(
priceList.id,
{
...searchParams,
expand: "variants,sales_channels,collection",
},
{
keepPreviousData: true,
}
)
const { products, count, isLoading, isError, error } = useProducts(
{
...searchParams,
price_list_id: [priceList.id],
},
{
placeholderData: keepPreviousData,
}
)
const filters = useProductTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: (products || []) as Product[],
data: products || [],
count,
columns,
enablePagination: true,
@@ -66,7 +63,6 @@ export const PricingProductSection = ({
prefix: PREFIX,
})
const { mutateAsync } = useAdminDeletePriceListProductsPrices(priceList.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
@@ -81,9 +77,9 @@ export const PricingProductSection = ({
return
}
await mutateAsync({
product_ids: Object.keys(rowSelection),
})
// The endpoint to batch remove prices by product ids is not implemented in
// v2. We either need to implement it or remove the feature.
console.log("Not implemented yet.")
}
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 base = useProductTableColumns()
@@ -185,6 +205,10 @@ const useColumns = () => {
},
}),
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => <ProductRowAction product={row.original} />,
}),
],
[base]
)
@@ -1 +1,2 @@
export { pricingLoader as loader } from "./loader"
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 { JsonViewSection } from "../../../components/common/json-view-section"
import { usePriceList } from "../../../hooks/api/price-lists"
import { PricingConfigurationSection } from "./components/pricing-configuration-section"
import { PricingGeneralSection } from "./components/pricing-general-section"
import { PricingProductSection } from "./components/pricing-product-section"
@@ -8,7 +8,7 @@ import { PricingProductSection } from "./components/pricing-product-section"
export const PricingDetail = () => {
const { id } = useParams()
const { price_list, isLoading, isError, error } = useAdminPriceList(id!)
const { price_list, isLoading, isError, error } = usePriceList(id!)
if (isLoading || !price_list) {
return <div>Loading...</div>
@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PriceList } from "@medusajs/medusa"
import { Button, Input, RadioGroup, Switch, Textarea } from "@medusajs/ui"
import { useAdminUpdatePriceList } from "medusa-react"
import { PriceListDTO } from "@medusajs/types"
import { Button, Input, RadioGroup, Textarea } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
@@ -10,17 +9,17 @@ import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdatePriceList } from "../../../../../hooks/api/price-lists"
import { PriceListType } from "../../../common/constants"
type EditPriceListFormProps = {
priceList: PriceList
priceList: PriceListDTO
}
const EditPriceListFormSchema = z.object({
type: z.nativeEnum(PriceListType),
name: z.string().min(1),
title: z.string().min(1),
description: z.string().min(1),
includes_tax: z.boolean(),
})
export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
@@ -30,14 +29,13 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
const form = useForm<z.infer<typeof EditPriceListFormSchema>>({
defaultValues: {
type: priceList.type,
name: priceList.name,
title: priceList.title,
description: priceList.description,
includes_tax: priceList.includes_tax,
},
resolver: zodResolver(EditPriceListFormSchema),
})
const { mutateAsync } = useAdminUpdatePriceList(priceList.id)
const { mutateAsync } = useUpdatePriceList(priceList.id)
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(values, {
@@ -85,11 +83,11 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="name"
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
@@ -114,32 +112,6 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
}}
/>
</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.Footer className="shrink-0">
<div className="flex items-center justify-end gap-x-2">
@@ -1,15 +1,15 @@
import { Heading } from "@medusajs/ui"
import { useAdminPriceList } from "medusa-react"
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 { EditPriceListForm } from "./components/edit-price-list-form"
export const PricingEdit = () => {
const { t } = useTranslation()
const { id } = useParams()
const { price_list, isLoading, isError, error } = useAdminPriceList(id!)
const { price_list, isLoading, isError, error } = usePriceList(id!)
const ready = !isLoading && price_list
@@ -1,8 +1,9 @@
import { Button, Container, Heading } from "@medusajs/ui"
import { useAdminPriceLists } from "medusa-react"
import { keepPreviousData } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { DataTable } from "../../../../../components/table/data-table"
import { usePriceLists } from "../../../../../hooks/api/price-lists"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { usePricingTableColumns } from "./use-pricing-table-columns"
import { usePricingTableQuery } from "./use-pricing-table-query"
@@ -15,12 +16,13 @@ export const PricingListTable = () => {
const { searchParams, raw } = usePricingTableQuery({
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,
},
{
keepPreviousData: true,
placeholderData: keepPreviousData,
}
)
@@ -1,12 +1,12 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PriceList } from "@medusajs/medusa"
import { PriceListDTO } from "@medusajs/types"
import { usePrompt } from "@medusajs/ui"
import { useAdminDeletePriceList } from "medusa-react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeletePriceList } from "../../../../../hooks/api/price-lists"
type PricingTableActionsProps = {
priceList: PriceList
priceList: PriceListDTO
}
export const PricingTableActions = ({
@@ -15,13 +15,13 @@ export const PricingTableActions = ({
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
const { mutateAsync } = useDeletePriceList(priceList.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("pricing.deletePriceListWarning", {
name: priceList.name,
name: priceList.title,
}),
confirmText: t("actions.delete"),
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 { useMemo } from "react"
import { useTranslation } from "react-i18next"
@@ -6,32 +6,17 @@ import { StatusCell } from "../../../../../components/table/table-cells/common/s
import { getPriceListStatus } from "../../../common/utils"
import { PricingTableActions } from "./pricing-table-actions"
const columnHelper = createColumnHelper<PriceList>()
const columnHelper = createColumnHelper<PriceListDTO>()
export const usePricingTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
columnHelper.accessor("title", {
header: t("fields.name"),
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", {
header: t("fields.status"),
cell: ({ row }) => {
@@ -40,8 +25,8 @@ export const usePricingTableColumns = () => {
return <StatusCell color={color}>{text}</StatusCell>
},
}),
columnHelper.accessor("customer_groups", {
header: t("customerGroups.domain"),
columnHelper.accessor("prices", {
header: t("fields.prices"),
cell: (info) => info.getValue()?.length || "-",
}),
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,
createColumnHelper,
} from "@tanstack/react-table"
import { adminCurrenciesKeys, useAdminCustomQuery } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import * as zod from "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 {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
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 { useCurrenciesTableColumns } from "../../../common/hooks/use-currencies-table-columns"
import { useCurrenciesTableQuery } from "../../../common/hooks/use-currencies-table-query"
import { useUpdateStore } from "../../../../../hooks/api/store"
type AddCurrenciesFormProps = {
store: StoreDTO
@@ -67,12 +68,10 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
prefix: PREFIX,
})
const { data, isLoading, isError, error } = useAdminCustomQuery(
"/admin/currencies",
adminCurrenciesKeys.list(raw),
const { currencies, count, isLoading, isError, error } = useCurrencies(
searchParams,
{
keepPreviousData: true,
placeholderData: keepPreviousData,
}
)
@@ -81,9 +80,9 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
const columns = useColumns()
const { table } = useDataTable({
data: (data?.currencies ?? []) as CurrencyDTO[],
data: currencies ?? [],
columns,
count: data?.count,
count: count,
getRowId: (row) => row.code,
enableRowSelection: (row) => !preSelectedRows.includes(row.original.code),
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 currencies = Array.from(
@@ -139,7 +138,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
@@ -149,7 +148,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
<DataTable
table={table}
pageSize={PAGE_SIZE}
count={data?.count}
count={count}
columns={columns}
layout="fill"
pagination
@@ -1,9 +1,9 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form"
import { useStore } from "../../../hooks/api/store"
import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form"
export const StoreAddCurrencies = () => {
const { store, isLoading, isError, error } = useStore({})
const { store, isLoading, isError, error } = useStore()
if (isError) {
throw error
@@ -9,7 +9,6 @@ import {
} from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { adminStoreKeys, useAdminCustomPost } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../../components/common/action-menu"
@@ -157,10 +156,7 @@ const CurrencyActions = ({
currency: CurrencyDTO
currencyCodes: string[]
}) => {
const { mutateAsync } = useAdminCustomPost(
`/admin/stores/${storeId}`,
adminStoreKeys.details()
)
const { mutateAsync } = useUpdateStore(storeId)
const { t } = useTranslation()
const prompt = usePrompt()