From c95a52c6c47f7f6a7f53c3e4363cff81bfccddda Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 27 Nov 2024 17:35:22 +0100 Subject: [PATCH 01/13] progress --- .../components/price-rule-form/index.ts | 1 + .../price-rule-form/price-rule-form.tsx | 153 ++++++++++++++++++ .../use-shipping-option-price-columns.tsx | 135 +++++++++++++++- .../locations/location-list/location-list.tsx | 28 +++- .../create-shipping-options-form.tsx | 4 +- .../create-shipping-options-prices-form.tsx | 5 +- .../create-shipping-options-form/schema.ts | 22 ++- 7 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx diff --git a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts new file mode 100644 index 0000000000000..f90cd951c06cd --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts @@ -0,0 +1 @@ +export * from "./price-rule-form" diff --git a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx new file mode 100644 index 0000000000000..7fa2ecac3a55e --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx @@ -0,0 +1,153 @@ +import { TriangleDownMini, XMarkMini } from "@medusajs/icons" +import { + Badge, + Button, + CurrencyInput, + Heading, + IconButton, + Text, +} from "@medusajs/ui" +import * as Accordion from "@radix-ui/react-accordion" +import { ReactNode, useState } from "react" +import { Divider } from "../../../../../components/common/divider" + +const RULE_ITEM_PREFIX = "rule-item" + +const getRuleValue = (index: number) => `${RULE_ITEM_PREFIX}-${index}` + +export const PriceRuleForm = () => { + return ( +
+
+ Conditional Prices for [Cell Name] + + Set custom prices for this shipping option based on the cart total. + +
+ + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + +
+ +
+
+ ) +} + +interface PriceRuleListProps { + children?: ReactNode +} + +const PriceRuleList = ({ children }: PriceRuleListProps) => { + return ( + + {children} + + ) +} + +interface PriceRuleItemProps { + index: number +} + +const PriceRuleItem = ({ index }: PriceRuleItemProps) => { + const [numbers, setNumbers] = useState<{ + price: number | undefined | null + min: number | undefined | null + max: number | undefined | null + }>({ + price: 0, + min: 0, + max: 0, + }) + + return ( + + +
+ + $ 0.00 + +
+
+
+ If + Cart total + is above + $ 100.00 +
+ e.stopPropagation()} + > + + + + + +
+
+ + +
+ + Shipping option price + + + setNumbers({ ...numbers, price: values?.float }) + } + /> +
+ +
+ + Minimum cart total + + + setNumbers({ ...numbers, min: values?.float }) + } + /> +
+ +
+ + Maximum cart total + + + setNumbers({ ...numbers, max: values?.float }) + } + /> +
+
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx b/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx index 95d2c6096ee5e..545ce2604d66a 100644 --- a/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx @@ -1,11 +1,17 @@ import { HttpTypes } from "@medusajs/types" +import { ColumnDef } from "@tanstack/react-table" +import { TFunction } from "i18next" import { useMemo } from "react" +import { FieldPath, FieldValues } from "react-hook-form" import { useTranslation } from "react-i18next" +import { IncludesTaxTooltip } from "../../../../components/common/tax-badge/tax-badge" import { createDataGridHelper, DataGrid, } from "../../../../components/data-grid" -import { createDataGridPriceColumns } from "../../../../components/data-grid/helpers/create-data-grid-price-columns" +import { DataGridCurrencyCell } from "../../../../components/data-grid/components" +import { DataGridReadonlyCell } from "../../../../components/data-grid/components/data-grid-readonly-cell" +import { FieldContext } from "../../../../components/data-grid/types" const columnHelper = createDataGridHelper() @@ -51,3 +57,130 @@ export const useShippingOptionPriceColumns = ({ ] }, [t, currencies, regions, pricePreferences, name]) } + +type CreateDataGridPriceColumnsProps< + TData, + TFieldValues extends FieldValues, +> = { + currencies?: string[] + regions?: HttpTypes.AdminRegion[] + pricePreferences?: HttpTypes.AdminPricePreference[] + isReadyOnly?: (context: FieldContext) => boolean + getFieldName: ( + context: FieldContext, + value: string + ) => FieldPath | null + t: TFunction +} + +export const createDataGridPriceColumns = < + TData, + TFieldValues extends FieldValues, +>({ + currencies, + regions, + pricePreferences, + isReadyOnly, + getFieldName, + t, +}: CreateDataGridPriceColumnsProps): ColumnDef< + TData, + unknown +>[] => { + const columnHelper = createDataGridHelper() + + return [ + ...(currencies?.map((currency) => { + const preference = pricePreferences?.find( + (p) => p.attribute === "currency_code" && p.value === currency + ) + + const translatedCurrencyName = t("fields.priceTemplate", { + regionOrCurrency: currency.toUpperCase(), + }) + + return columnHelper.column({ + id: `currency_prices.${currency}`, + name: t("fields.priceTemplate", { + regionOrCurrency: currency.toUpperCase(), + }), + field: (context) => { + const isReadyOnlyValue = isReadyOnly?.(context) + + if (isReadyOnlyValue) { + return null + } + + return getFieldName(context, currency) + }, + type: "number", + header: () => ( +
+ + {translatedCurrencyName} + + +
+ ), + cell: (context) => { + if (isReadyOnly?.(context)) { + return + } + + return + }, + }) + }) ?? []), + ...(regions?.map((region) => { + const preference = pricePreferences?.find( + (p) => p.attribute === "region_id" && p.value === region.id + ) + + const translatedRegionName = t("fields.priceTemplate", { + regionOrCurrency: region.name, + }) + + return columnHelper.column({ + id: `region_prices.${region.id}`, + name: t("fields.priceTemplate", { + regionOrCurrency: region.name, + }), + field: (context) => { + const isReadyOnlyValue = isReadyOnly?.(context) + + if (isReadyOnlyValue) { + return null + } + + return getFieldName(context, region.id) + }, + type: "number", + header: () => ( +
+ + {translatedRegionName} + + +
+ ), + cell: (context) => { + if (isReadyOnly?.(context)) { + return + } + + const currency = currencies?.find((c) => c === region.currency_code) + if (!currency) { + return null + } + + return ( + + ) + }, + }) + }) ?? []), + ] +} diff --git a/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx b/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx index e95b0bca3f2d7..bdc66964b21a9 100644 --- a/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx @@ -1,5 +1,5 @@ import { ShoppingBag, TriangleRightMini } from "@medusajs/icons" -import { Container, Heading, Text } from "@medusajs/ui" +import { Button, Container, FocusModal, Heading, Text } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { Link, useLoaderData } from "react-router-dom" @@ -12,6 +12,7 @@ import { ReactNode } from "react" import { IconAvatar } from "../../../components/common/icon-avatar" import { TwoColumnPage } from "../../../components/layout/pages" import { useDashboardExtension } from "../../../extensions" +import { PriceRuleForm } from "../common/components/price-rule-form" import { LocationListHeader } from "./components/location-list-header" export function LocationList() { @@ -107,6 +108,31 @@ const LinksSection = () => { return ( + + + + + + + +
+
+ +
+
+
+ +
+ + +
+
+
+
{t("stockLocations.sidebar.header")}
diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx index 1cf638d2248ea..36f6b720803b2 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -76,13 +76,13 @@ export function CreateShippingOptionsForm({ const regionPrices = Object.entries(data.region_prices) .map(([region_id, value]) => { - if (value === "" || value === undefined) { + if (value.amount === "" || value.amount === undefined) { return undefined } return { region_id, - amount: castNumber(value), + amount: castNumber(value.amount), } }) .filter((o) => !!o) as { region_id: string; amount: number }[] diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx index f0048538449e0..1562a9a90103b 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react" -import { UseFormReturn } from "react-hook-form" +import { UseFormReturn, useWatch } from "react-hook-form" import { DataGrid } from "../../../../../components/data-grid" import { useRouteModal } from "../../../../../components/modals" @@ -42,7 +42,10 @@ export const CreateShippingOptionsPricesForm = ({ const { setCloseOnEscape } = useRouteModal() + const name = useWatch({ control: form.control, name: "name" }) + const columns = useShippingOptionPriceColumns({ + name, currencies, regions, pricePreferences, diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts index a9553e504ee1a..eb510c449f0fc 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts @@ -13,9 +13,29 @@ export const CreateShippingOptionDetailsSchema = z.object({ provider_id: z.string().min(1), }) +enum PriceRuleAttribute { + gte = "gte", + gt = "gt", + lte = "lte", + lt = "lt", +} + +const PriceRuleAttributeSchema = z.nativeEnum(PriceRuleAttribute) + +const PriceRuleSchema = z.object({ + attribute: z.literal("cart_total"), + operator: PriceRuleAttributeSchema, + value: z.string(), +}) + +const ShippingOptionPriceSchema = z.object({ + amount: z.string(), + rules: z.array(PriceRuleSchema).optional(), +}) + export const CreateShippingOptionSchema = z .object({ - region_prices: z.record(z.string(), z.string().optional()), + region_prices: z.record(z.string(), ShippingOptionPriceSchema), currency_prices: z.record(z.string(), z.string().optional()), }) .merge(CreateShippingOptionDetailsSchema) From 2f0bf9c40c3ca856e5120bd63ac99cb5ae251764 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:28:59 +0100 Subject: [PATCH 02/13] init work on temp form --- .../components/data-grid-cell-container.tsx | 84 ++-- .../data-grid/components/data-grid-root.tsx | 8 +- .../src/components/data-grid/types.ts | 1 + .../stacked-focus-modal.tsx | 17 +- .../price-rule-form/price-rule-form.tsx | 436 ++++++++++++++---- .../shipping-option-price-cell/index.ts | 1 + .../shipping-option-price-cell.tsx | 256 ++++++++++ .../shipping-option-price-provider/index.ts | 2 + .../shipping-option-price-context.tsx | 9 + .../shipping-option-price-provider.tsx | 23 + .../use-shipping-option-price.tsx | 14 + .../src/routes/locations/common/constants.ts | 2 + .../use-shipping-option-price-columns.tsx | 40 +- .../src/routes/locations/common/types.ts | 12 + ...custom-shipping-option-price-field-info.ts | 17 + .../locations/location-list/location-list.tsx | 28 +- .../create-service-zone-form.tsx | 3 - .../create-shipping-option-details-form.tsx | 2 +- .../create-shipping-options-form.tsx | 169 +++---- .../create-shipping-options-prices-form.tsx | 50 +- .../create-shipping-options-form/schema.ts | 34 +- 21 files changed, 917 insertions(+), 291 deletions(-) create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/index.ts create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/index.ts create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-provider.tsx create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/use-shipping-option-price.tsx create mode 100644 packages/admin/dashboard/src/routes/locations/common/types.ts create mode 100644 packages/admin/dashboard/src/routes/locations/common/utils/get-custom-shipping-option-price-field-info.ts diff --git a/packages/admin/dashboard/src/components/data-grid/components/data-grid-cell-container.tsx b/packages/admin/dashboard/src/components/data-grid/components/data-grid-cell-container.tsx index 56a0861556e6a..f9e2908d32114 100644 --- a/packages/admin/dashboard/src/components/data-grid/components/data-grid-cell-container.tsx +++ b/packages/admin/dashboard/src/components/data-grid/components/data-grid-cell-container.tsx @@ -19,52 +19,56 @@ export const DataGridCellContainer = ({ children, errors, rowErrors, + outerComponent, }: DataGridCellContainerProps & DataGridErrorRenderProps) => { const error = get(errors, field) const hasError = !!error return ( -
- { - return ( -
- - - -
- ) - }} - /> -
- - {children} - -
- - {showOverlay && ( -
+
+ { + return ( +
+ + + +
+ ) + }} /> - )} +
+ + {children} + +
+ + {showOverlay && ( +
+ )} +
+ {outerComponent}
) } diff --git a/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx b/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx index 63001cd8a05b8..d8d62536bb717 100644 --- a/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx +++ b/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx @@ -57,6 +57,7 @@ export interface DataGridRootProps< state: UseFormReturn getSubRows?: (row: TData) => TData[] | undefined onEditingChange?: (isEditing: boolean) => void + disableInteractions?: boolean } const ROW_HEIGHT = 40 @@ -102,6 +103,7 @@ export const DataGridRoot = < state, getSubRows, onEditingChange, + disableInteractions, }: DataGridRootProps) => { const containerRef = useRef(null) @@ -114,7 +116,9 @@ export const DataGridRoot = < formState: { errors }, } = state - const [trapActive, setTrapActive] = useState(true) + const [internalTrapActive, setTrapActive] = useState(true) + + const trapActive = !disableInteractions && internalTrapActive const [anchor, setAnchor] = useState(null) const [rangeEnd, setRangeEnd] = useState(null) @@ -533,7 +537,7 @@ export const DataGridRoot = < queryTool?.getContainer(anchor)?.focus() }) } - }, [anchor, trapActive, queryTool]) + }, [anchor, trapActive, setSingleRange, scrollToCoordinates, queryTool]) return ( diff --git a/packages/admin/dashboard/src/components/data-grid/types.ts b/packages/admin/dashboard/src/components/data-grid/types.ts index 5286a7a06df8b..3f59ecbb70f45 100644 --- a/packages/admin/dashboard/src/components/data-grid/types.ts +++ b/packages/admin/dashboard/src/components/data-grid/types.ts @@ -96,6 +96,7 @@ export interface DataGridCellContainerProps extends PropsWithChildren<{}> { isDragSelected: boolean placeholder?: ReactNode showOverlay: boolean + outerComponent?: ReactNode } export type DataGridCellSnapshot< diff --git a/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx b/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx index 599b14861da3e..5e2cfe6b47e23 100644 --- a/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx +++ b/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx @@ -13,12 +13,17 @@ type StackedFocusModalProps = PropsWithChildren<{ * when multiple stacked modals are registered to the same parent modal. */ id: string + onOpenChangeHook?: (open: boolean) => void }> /** * A stacked modal that can be rendered above a parent modal. */ -export const Root = ({ id, children }: StackedFocusModalProps) => { +export const Root = ({ + id, + onOpenChangeHook, + children, +}: StackedFocusModalProps) => { const { register, unregister, getIsOpen, setIsOpen } = useStackedModal() useEffect(() => { @@ -28,11 +33,13 @@ export const Root = ({ id, children }: StackedFocusModalProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + const handleOpenChange = (open: boolean) => { + setIsOpen(id, open) + onOpenChangeHook?.(open) + } + return ( - setIsOpen(id, open)} - > + {children} ) diff --git a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx index 7fa2ecac3a55e..e11c0ea89f0e3 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx @@ -8,45 +8,173 @@ import { Text, } from "@medusajs/ui" import * as Accordion from "@radix-ui/react-accordion" -import { ReactNode, useState } from "react" +import React, { ReactNode, useEffect, useRef, useState } from "react" +import { + Control, + Controller, + useFieldArray, + useForm, + useFormContext, + useWatch, +} from "react-hook-form" +import { useTranslation } from "react-i18next" import { Divider } from "../../../../../components/common/divider" +import { + StackedFocusModal, + useStackedModal, +} from "../../../../../components/modals" +import { castNumber } from "../../../../../lib/cast-number" +import { CurrencyInfo } from "../../../../../lib/data/currencies" +import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" +import { CreateShippingOptionSchemaType } from "../../../location-service-zone-shipping-option-create/components/create-shipping-options-form/schema" +import { CONDITIONAL_PRICES_STACKED_MODAL_ID } from "../../constants" +import { + ConditionalPriceInfo, + ConditionalShippingOptionPriceAccessor, +} from "../../types" +import { getCustomShippingOptionPriceFieldName } from "../../utils/get-custom-shipping-option-price-field-info" +import { useShippingOptionPrice } from "../shipping-option-price-provider" const RULE_ITEM_PREFIX = "rule-item" const getRuleValue = (index: number) => `${RULE_ITEM_PREFIX}-${index}` -export const PriceRuleForm = () => { +interface PriceRuleFormProps { + info: ConditionalPriceInfo +} + +export const PriceRuleForm = ({ info }: PriceRuleFormProps) => { + const { t } = useTranslation() + const { getValues, setValue: setFormValue } = + useFormContext() + const { onCloseConditionalPricesModal } = useShippingOptionPrice() + const { getIsOpen } = useStackedModal() + + const [value, setValue] = useState([getRuleValue(0)]) + + const { field, type, currency, name: header } = info + + const name = getCustomShippingOptionPriceFieldName(field, type) + const snapshot = useRef(getValues(name)) + + const tempForm = useForm({ + defaultValues: { + [name]: snapshot.current, + }, + }) + + const open = getIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID) + + useEffect(() => { + if (open) { + tempForm.reset({ + [name]: snapshot.current, + }) + } + }, [open, name, tempForm]) + + const { fields, append, remove } = useFieldArray({ + control: tempForm.control, + name, + }) + + const handleAdd = () => { + append({ + amount: "", + gte: "", + lte: "", + }) + + setValue([...value, getRuleValue(fields.length)]) + } + + const handleRemove = (index: number) => { + remove(index) + } + + const handleSave = () => { + setFormValue(name, tempForm.getValues(name)) + tempForm.reset() + onCloseConditionalPricesModal() + } + return ( -
-
- Conditional Prices for [Cell Name] - - Set custom prices for this shipping option based on the cart total. - -
- - {Array.from({ length: 3 }).map((_, index) => ( - - ))} - -
- -
-
+ + + +
+
+
+
+ + Conditional Prices for {header} + + + + Set custom prices for this shipping option based on the cart + total. + + +
+ + {fields.map((field, index) => ( + + ))} + +
+ +
+
+
+
+
+ +
+ + + + +
+
+
) } interface PriceRuleListProps { children?: ReactNode + value: string[] + onValueChange: (value: string[]) => void } -const PriceRuleList = ({ children }: PriceRuleListProps) => { +const PriceRuleList = ({ + children, + value, + onValueChange, +}: PriceRuleListProps) => { return ( {children} @@ -56,52 +184,60 @@ const PriceRuleList = ({ children }: PriceRuleListProps) => { interface PriceRuleItemProps { index: number + accessor: ConditionalShippingOptionPriceAccessor + currency: CurrencyInfo + onRemove: (index: number) => void + control: Control } -const PriceRuleItem = ({ index }: PriceRuleItemProps) => { - const [numbers, setNumbers] = useState<{ - price: number | undefined | null - min: number | undefined | null - max: number | undefined | null - }>({ - price: 0, - min: 0, - max: 0, - }) +const PriceRuleItem = ({ + index, + accessor, + currency, + onRemove, + control, +}: PriceRuleItemProps) => { + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation() + onRemove(index) + } return ( - -
- - $ 0.00 - -
-
-
- If - Cart total - is above - $ 100.00 + +
+
+ +
+
+ + + + + + +
- e.stopPropagation()} - > - - - - -
@@ -110,13 +246,22 @@ const PriceRuleItem = ({ index }: PriceRuleItemProps) => { Shipping option price - - setNumbers({ ...numbers, price: values?.float }) - } + { + return ( + + onChange(values?.float) + } + {...props} + /> + ) + }} />
@@ -124,13 +269,22 @@ const PriceRuleItem = ({ index }: PriceRuleItemProps) => { Minimum cart total - - setNumbers({ ...numbers, min: values?.float }) - } + { + return ( + + onChange(values?.float) + } + {...props} + /> + ) + }} />
@@ -138,16 +292,140 @@ const PriceRuleItem = ({ index }: PriceRuleItemProps) => { Maximum cart total - - setNumbers({ ...numbers, max: values?.float }) - } + { + return ( + + onChange(values?.float) + } + {...props} + /> + ) + }} />
) } + +const AmountDisplay = ({ + accessor, + index, + currency, +}: { + accessor: ConditionalShippingOptionPriceAccessor + index: number + currency: CurrencyInfo +}) => { + const { control } = useFormContext() + + const amount = useWatch({ + control, + name: `${accessor}.${index}.amount`, + }) + + if (amount === "" || amount === undefined) { + return ( + + - + + ) + } + + const castAmount = castNumber(amount) + + return ( + + {getLocaleAmount(castAmount, currency.code)} + + ) +} + +const ConditionDisplay = ({ + accessor, + index, + currency, +}: { + accessor: ConditionalShippingOptionPriceAccessor + index: number + currency: CurrencyInfo +}) => { + const { control } = useFormContext() + + const gte = useWatch({ + control, + name: `${accessor}.${index}.gte`, + }) + + const lte = useWatch({ + control, + name: `${accessor}.${index}.lte`, + }) + + const renderCondition = () => { + const castGte = gte ? castNumber(gte) : undefined + const castLte = lte ? castNumber(lte) : undefined + + if (!castGte && !castLte) { + return null + } + + if (castGte && !castLte) { + return ( + <> + If + Cart total + + + {getLocaleAmount(castGte, currency.code)} + + + ) + } + + if (!castGte && castLte) { + return ( + <> + If + Cart total + + + {getLocaleAmount(castLte, currency.code)} + + + ) + } + + if (castGte && castLte) { + return ( + <> + If + Cart total + is between + + {getLocaleAmount(castGte, currency.code)} + + and + + {getLocaleAmount(castLte, currency.code)} + + + ) + } + + return null + } + + return ( +
+ {renderCondition()} +
+ ) +} diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/index.ts b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/index.ts new file mode 100644 index 0000000000000..df3d6951d3f7c --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/index.ts @@ -0,0 +1 @@ +export * from "./shipping-option-price-cell" diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx new file mode 100644 index 0000000000000..8fc561516fd3a --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx @@ -0,0 +1,256 @@ +import { Adjustments, ArrowsPointingOut } from "@medusajs/icons" +import { clx } from "@medusajs/ui" +import { useCallback, useEffect, useRef, useState } from "react" +import CurrencyInput, { + CurrencyInputProps, + formatValue, +} from "react-currency-input-field" +import { + Control, + Controller, + ControllerRenderProps, + useWatch, +} from "react-hook-form" +import { DataGridCellContainer } from "../../../../../components/data-grid/components/data-grid-cell-container" +import { + useDataGridCell, + useDataGridCellError, +} from "../../../../../components/data-grid/hooks" +import { + DataGridCellProps, + InputProps, +} from "../../../../../components/data-grid/types" +import { useCombinedRefs } from "../../../../../hooks/use-combined-refs" +import { currencies, CurrencyInfo } from "../../../../../lib/data/currencies" +import { getCustomShippingOptionPriceFieldName } from "../../utils/get-custom-shipping-option-price-field-info" +import { useShippingOptionPrice } from "../shipping-option-price-provider" + +interface ShippingOptionPriceCellProps + extends DataGridCellProps { + code: string + header: string + type: "currency" | "region" +} + +export const ShippingOptionPriceCell = ({ + context, + code, + header, + type, +}: ShippingOptionPriceCellProps) => { + const [symbolWidth, setSymbolWidth] = useState(0) + + const measuredRef = useCallback((node: HTMLSpanElement) => { + if (node) { + const width = node.offsetWidth + setSymbolWidth(width) + } + }, []) + + const { field, control, renderProps } = useDataGridCell({ + context, + }) + + const errorProps = useDataGridCellError({ context }) + + const { container, input } = renderProps + const { isAnchor } = container + + const currency = currencies[code.toUpperCase()] + + return ( + { + return ( + + } + > + + + ) + }} + /> + ) +} + +const OuterComponent = ({ + isAnchor, + header, + field, + control, + symbolWidth, + type, + currency, +}: { + isAnchor: boolean + header: string + field: string + control: Control + symbolWidth: number + type: "currency" | "region" + currency: CurrencyInfo +}) => { + const { onOpenConditionalPricesModal } = useShippingOptionPrice() + + const buttonRef = useRef(null) + + const name = getCustomShippingOptionPriceFieldName(field, type) + const price = useWatch({ control, name }) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isAnchor && (e.metaKey || e.ctrlKey) && e.key === "c") { + e.preventDefault() + buttonRef.current?.click() + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isAnchor]) + + return ( +
+ {price?.length > 0 && !isAnchor && ( +
+ +
+ )} + +
+ ) +} + +const Inner = ({ + field, + onMeasureSymbol, + inputProps, + currencyInfo, +}: { + field: ControllerRenderProps + onMeasureSymbol: (node: HTMLSpanElement) => void + inputProps: InputProps + currencyInfo: CurrencyInfo +}) => { + const { value, onChange: _, onBlur, ref, ...rest } = field + const { + ref: inputRef, + onBlur: onInputBlur, + onFocus, + onChange, + ...attributes + } = inputProps + + const formatter = useCallback( + (value?: string | number) => { + const ensuredValue = + typeof value === "number" ? value.toString() : value || "" + + return formatValue({ + value: ensuredValue, + decimalScale: currencyInfo.decimal_digits, + disableGroupSeparators: true, + decimalSeparator: ".", + }) + }, + [currencyInfo] + ) + + const [localValue, setLocalValue] = useState(value || "") + + const handleValueChange: CurrencyInputProps["onValueChange"] = ( + value, + _name, + _values + ) => { + if (!value) { + setLocalValue("") + return + } + + setLocalValue(value) + } + + useEffect(() => { + let update = value + + // The component we use is a bit fidly when the value is updated externally + // so we need to ensure a format that will result in the cell being formatted correctly + // according to the users locale on the next render. + if (!isNaN(Number(value))) { + update = formatter(update) + } + + setLocalValue(update) + }, [value, formatter]) + + const combinedRed = useCombinedRefs(inputRef, ref) + + return ( +
+ + {currencyInfo.symbol_native} + + { + onBlur() + onInputBlur() + + onChange(localValue, value) + }} + onFocus={onFocus} + decimalScale={currencyInfo.decimal_digits} + decimalsLimit={currencyInfo.decimal_digits} + autoComplete="off" + tabIndex={-1} + /> +
+ ) +} diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/index.ts b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/index.ts new file mode 100644 index 0000000000000..2d0cdea4ce397 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/index.ts @@ -0,0 +1,2 @@ +export * from "./shipping-option-price-provider" +export * from "./use-shipping-option-price" diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx new file mode 100644 index 0000000000000..29a1b03c73cfc --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx @@ -0,0 +1,9 @@ +import { createContext } from "react" +import { ConditionalPriceInfo } from "../../types" + +type ShippingOptionPriceContextType = { + onOpenConditionalPricesModal: (info: ConditionalPriceInfo) => void +} + +export const ShippingOptionPriceContext = + createContext(null) diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-provider.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-provider.tsx new file mode 100644 index 0000000000000..88d26abf888b7 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-provider.tsx @@ -0,0 +1,23 @@ +import { ShippingOptionPriceContext } from "./shipping-option-price-context" + +import { PropsWithChildren } from "react" +import { ConditionalPriceInfo } from "../../types" + +type ShippingOptionPriceProviderProps = PropsWithChildren<{ + onOpenConditionalPricesModal: (info: ConditionalPriceInfo) => void + onCloseConditionalPricesModal: () => void +}> + +export const ShippingOptionPriceProvider = ({ + children, + onOpenConditionalPricesModal, + onCloseConditionalPricesModal, +}: ShippingOptionPriceProviderProps) => { + return ( + + {children} + + ) +} diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/use-shipping-option-price.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/use-shipping-option-price.tsx new file mode 100644 index 0000000000000..214ba4d89308a --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/use-shipping-option-price.tsx @@ -0,0 +1,14 @@ +import { useContext } from "react" +import { ShippingOptionPriceContext } from "./shipping-option-price-context" + +export const useShippingOptionPrice = () => { + const context = useContext(ShippingOptionPriceContext) + + if (!context) { + throw new Error( + "useShippingOptionPrice must be used within a ShippingOptionPriceProvider" + ) + } + + return context +} diff --git a/packages/admin/dashboard/src/routes/locations/common/constants.ts b/packages/admin/dashboard/src/routes/locations/common/constants.ts index 687386f572074..04fbf0c17f29e 100644 --- a/packages/admin/dashboard/src/routes/locations/common/constants.ts +++ b/packages/admin/dashboard/src/routes/locations/common/constants.ts @@ -9,3 +9,5 @@ export enum ShippingOptionPriceType { } export const GEO_ZONE_STACKED_MODAL_ID = "geo-zone" + +export const CONDITIONAL_PRICES_STACKED_MODAL_ID = "conditional-prices" diff --git a/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx b/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx index 545ce2604d66a..ad51cc2ffe263 100644 --- a/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx @@ -9,9 +9,8 @@ import { createDataGridHelper, DataGrid, } from "../../../../components/data-grid" -import { DataGridCurrencyCell } from "../../../../components/data-grid/components" -import { DataGridReadonlyCell } from "../../../../components/data-grid/components/data-grid-readonly-cell" import { FieldContext } from "../../../../components/data-grid/types" +import { ShippingOptionPriceCell } from "../components/shipping-option-price-cell" const columnHelper = createDataGridHelper() @@ -32,6 +31,8 @@ export const useShippingOptionPriceColumns = ({ return [ columnHelper.column({ id: "name", + name: t("fields.name"), + disableHiding: true, header: t("fields.name"), cell: (context) => { return ( @@ -65,7 +66,6 @@ type CreateDataGridPriceColumnsProps< currencies?: string[] regions?: HttpTypes.AdminRegion[] pricePreferences?: HttpTypes.AdminPricePreference[] - isReadyOnly?: (context: FieldContext) => boolean getFieldName: ( context: FieldContext, value: string @@ -80,7 +80,6 @@ export const createDataGridPriceColumns = < currencies, regions, pricePreferences, - isReadyOnly, getFieldName, t, }: CreateDataGridPriceColumnsProps): ColumnDef< @@ -105,12 +104,6 @@ export const createDataGridPriceColumns = < regionOrCurrency: currency.toUpperCase(), }), field: (context) => { - const isReadyOnlyValue = isReadyOnly?.(context) - - if (isReadyOnlyValue) { - return null - } - return getFieldName(context, currency) }, type: "number", @@ -123,11 +116,14 @@ export const createDataGridPriceColumns = <
), cell: (context) => { - if (isReadyOnly?.(context)) { - return - } - - return + return ( + + ) }, }) }) ?? []), @@ -146,12 +142,6 @@ export const createDataGridPriceColumns = < regionOrCurrency: region.name, }), field: (context) => { - const isReadyOnlyValue = isReadyOnly?.(context) - - if (isReadyOnlyValue) { - return null - } - return getFieldName(context, region.id) }, type: "number", @@ -164,17 +154,15 @@ export const createDataGridPriceColumns = < ), cell: (context) => { - if (isReadyOnly?.(context)) { - return - } - const currency = currencies?.find((c) => c === region.currency_code) if (!currency) { return null } return ( - diff --git a/packages/admin/dashboard/src/routes/locations/common/types.ts b/packages/admin/dashboard/src/routes/locations/common/types.ts new file mode 100644 index 0000000000000..c6916bf1e4e29 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/types.ts @@ -0,0 +1,12 @@ +import { CurrencyInfo } from "../../../lib/data/currencies" + +export type ConditionalShippingOptionPriceAccessor = + | `custom_region_prices.${string}` + | `custom_currency_prices.${string}` + +export type ConditionalPriceInfo = { + type: "currency" | "region" + field: string + name: string + currency: CurrencyInfo +} diff --git a/packages/admin/dashboard/src/routes/locations/common/utils/get-custom-shipping-option-price-field-info.ts b/packages/admin/dashboard/src/routes/locations/common/utils/get-custom-shipping-option-price-field-info.ts new file mode 100644 index 0000000000000..833671fb7f6cb --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/utils/get-custom-shipping-option-price-field-info.ts @@ -0,0 +1,17 @@ +import { ConditionalShippingOptionPriceAccessor } from "../types" + +export const getCustomShippingOptionPriceFieldName = ( + field: string, + type: "region" | "currency" +): ConditionalShippingOptionPriceAccessor => { + const prefix = type === "region" ? "region_prices" : "currency_prices" + const customPrefix = + type === "region" ? "custom_region_prices" : "custom_currency_prices" + + const name = field.replace( + prefix, + customPrefix + ) as ConditionalShippingOptionPriceAccessor + + return name +} diff --git a/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx b/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx index bdc66964b21a9..e95b0bca3f2d7 100644 --- a/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx @@ -1,5 +1,5 @@ import { ShoppingBag, TriangleRightMini } from "@medusajs/icons" -import { Button, Container, FocusModal, Heading, Text } from "@medusajs/ui" +import { Container, Heading, Text } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { Link, useLoaderData } from "react-router-dom" @@ -12,7 +12,6 @@ import { ReactNode } from "react" import { IconAvatar } from "../../../components/common/icon-avatar" import { TwoColumnPage } from "../../../components/layout/pages" import { useDashboardExtension } from "../../../extensions" -import { PriceRuleForm } from "../common/components/price-rule-form" import { LocationListHeader } from "./components/location-list-header" export function LocationList() { @@ -108,31 +107,6 @@ const LinksSection = () => { return ( - - - - - - - -
-
- -
-
-
- -
- - -
-
-
-
{t("stockLocations.sidebar.header")}
diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx index 9da23bb7f3f53..5d4688025ff3e 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx @@ -1,7 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod" import { HttpTypes } from "@medusajs/types" import { Button, Heading, Input, toast } from "@medusajs/ui" -import { useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" @@ -42,8 +41,6 @@ export function CreateServiceZoneForm({ const { t } = useTranslation() const { handleSuccess } = useRouteModal() - const [open, setOpen] = useState(false) - const form = useForm>({ defaultValues: { name: "", diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx index dac4f1678e5d8..dd95fb5f1c6f4 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx @@ -54,7 +54,7 @@ export const CreateShippingOptionDetailsForm = ({ return (
-
+
{t( diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx index 36f6b720803b2..2394debe13cb5 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -7,12 +7,16 @@ import { useTranslation } from "react-i18next" import { useState } from "react" import { RouteFocusModal, + StackedFocusModal, useRouteModal, } from "../../../../../components/modals" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options" import { castNumber } from "../../../../../lib/cast-number" -import { ShippingOptionPriceType } from "../../../common/constants" +import { + CONDITIONAL_PRICES_STACKED_MODAL_ID, + ShippingOptionPriceType, +} from "../../../common/constants" import { CreateShippingOptionDetailsForm } from "./create-shipping-option-details-form" import { CreateShippingOptionsPricesForm } from "./create-shipping-options-prices-form" import { @@ -51,6 +55,8 @@ export function CreateShippingOptionsForm({ provider_id: "", region_prices: {}, currency_prices: {}, + custom_region_prices: {}, + custom_currency_prices: {}, }, resolver: zodResolver(CreateShippingOptionSchema), }) @@ -76,13 +82,13 @@ export function CreateShippingOptionsForm({ const regionPrices = Object.entries(data.region_prices) .map(([region_id, value]) => { - if (value.amount === "" || value.amount === undefined) { + if (value === "" || value === undefined) { return undefined } return { region_id, - amount: castNumber(value.amount), + amount: castNumber(value), } }) .filter((o) => !!o) as { region_id: string; amount: number }[] @@ -192,87 +198,92 @@ export function CreateShippingOptionsForm({ : "in-progress" return ( - - onTabChange(tab as Tab)} - > - - - - - - {t("stockLocations.shippingOptions.create.tabs.details")} - - - {!isCalculatedPriceType && ( + + + onTabChange(tab as Tab)} + > + + + - - {t("stockLocations.shippingOptions.create.tabs.prices")} + + {t("stockLocations.shippingOptions.create.tabs.details")} - )} - - + {!isCalculatedPriceType && ( + + + {t("stockLocations.shippingOptions.create.tabs.prices")} + + + )} + + - - - - - - - - - -
- - - - {activeTab === Tab.PRICING || isCalculatedPriceType ? ( - - ) : ( - - )} -
-
-
-
-
+ + + + + + + + + +
+ + + + {activeTab === Tab.PRICING || isCalculatedPriceType ? ( + + ) : ( + + )} +
+
+ + + + ) } diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx index 1562a9a90103b..f753a190c8cc5 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx @@ -1,12 +1,19 @@ -import { useMemo } from "react" +import { useMemo, useState } from "react" import { UseFormReturn, useWatch } from "react-hook-form" import { DataGrid } from "../../../../../components/data-grid" -import { useRouteModal } from "../../../../../components/modals" +import { + useRouteModal, + useStackedModal, +} from "../../../../../components/modals" import { usePricePreferences } from "../../../../../hooks/api/price-preferences" import { useRegions } from "../../../../../hooks/api/regions" import { useStore } from "../../../../../hooks/api/store" +import { PriceRuleForm } from "../../../common/components/price-rule-form" +import { ShippingOptionPriceProvider } from "../../../common/components/shipping-option-price-provider" +import { CONDITIONAL_PRICES_STACKED_MODAL_ID } from "../../../common/constants" import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns" +import { ConditionalPriceInfo } from "../../../common/types" import { CreateShippingOptionSchema } from "./schema" type PricingPricesFormProps = { @@ -16,6 +23,20 @@ type PricingPricesFormProps = { export const CreateShippingOptionsPricesForm = ({ form, }: PricingPricesFormProps) => { + const { getIsOpen, setIsOpen } = useStackedModal() + const [selectedPrice, setSelectedPrice] = + useState(null) + + const onOpenConditionalPricesModal = (info: ConditionalPriceInfo) => { + setIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID, true) + setSelectedPrice(info) + } + + const onCloseConditionalPricesModal = () => { + setIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID, false) + setSelectedPrice(null) + } + const { store, isLoading: isStoreLoading, @@ -67,14 +88,21 @@ export const CreateShippingOptionsPricesForm = ({ } return ( -
- setCloseOnEscape(!editing)} - /> -
+ +
+ setCloseOnEscape(!editing)} + disableInteractions={getIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID)} + /> + {selectedPrice && } +
+
) } diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts index eb510c449f0fc..77486fb74190b 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts @@ -13,29 +13,27 @@ export const CreateShippingOptionDetailsSchema = z.object({ provider_id: z.string().min(1), }) -enum PriceRuleAttribute { - gte = "gte", - gt = "gt", - lte = "lte", - lt = "lt", -} - -const PriceRuleAttributeSchema = z.nativeEnum(PriceRuleAttribute) - -const PriceRuleSchema = z.object({ - attribute: z.literal("cart_total"), - operator: PriceRuleAttributeSchema, - value: z.string(), -}) - const ShippingOptionPriceSchema = z.object({ - amount: z.string(), - rules: z.array(PriceRuleSchema).optional(), + amount: z.string().optional(), + gte: z.string().optional(), + lte: z.string().optional(), }) export const CreateShippingOptionSchema = z .object({ - region_prices: z.record(z.string(), ShippingOptionPriceSchema), + region_prices: z.record(z.string(), z.string().optional()), currency_prices: z.record(z.string(), z.string().optional()), + custom_region_prices: z.record( + z.string(), + z.array(ShippingOptionPriceSchema).optional() + ), + custom_currency_prices: z.record( + z.string(), + z.array(ShippingOptionPriceSchema).optional() + ), }) .merge(CreateShippingOptionDetailsSchema) + +export type CreateShippingOptionSchemaType = z.infer< + typeof CreateShippingOptionSchema +> From e083b5d5ea2ca1962bd2eddb824f2e3f3f388cf3 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:29:31 +0100 Subject: [PATCH 03/13] save column changes --- .../shipping-option-price-context.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx index 29a1b03c73cfc..8c80fc7632932 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx @@ -3,6 +3,7 @@ import { ConditionalPriceInfo } from "../../types" type ShippingOptionPriceContextType = { onOpenConditionalPricesModal: (info: ConditionalPriceInfo) => void + onCloseConditionalPricesModal: () => void } export const ShippingOptionPriceContext = From a372c0e8f13f3ce2e4ad73dd60671852b28846d6 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 2 Dec 2024 09:21:16 +0100 Subject: [PATCH 04/13] progress --- .../price-rule-form/price-rule-form.tsx | 363 ++++++++++-------- .../create-shipping-options-form.tsx | 227 ++++++----- .../create-shipping-options-prices-form.tsx | 37 +- .../create-shipping-options-form/schema.ts | 69 +++- 4 files changed, 417 insertions(+), 279 deletions(-) diff --git a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx index e11c0ea89f0e3..7c148df6af96a 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx @@ -1,3 +1,4 @@ +import { zodResolver } from "@hookform/resolvers/zod" import { TriangleDownMini, XMarkMini } from "@medusajs/icons" import { Badge, @@ -8,10 +9,9 @@ import { Text, } from "@medusajs/ui" import * as Accordion from "@radix-ui/react-accordion" -import React, { ReactNode, useEffect, useRef, useState } from "react" +import React, { ReactNode, useState } from "react" import { Control, - Controller, useFieldArray, useForm, useFormContext, @@ -19,19 +19,18 @@ import { } from "react-hook-form" import { useTranslation } from "react-i18next" import { Divider } from "../../../../../components/common/divider" -import { - StackedFocusModal, - useStackedModal, -} from "../../../../../components/modals" +import { Form } from "../../../../../components/common/form" +import { StackedFocusModal } from "../../../../../components/modals" +import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { castNumber } from "../../../../../lib/cast-number" import { CurrencyInfo } from "../../../../../lib/data/currencies" import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" -import { CreateShippingOptionSchemaType } from "../../../location-service-zone-shipping-option-create/components/create-shipping-options-form/schema" -import { CONDITIONAL_PRICES_STACKED_MODAL_ID } from "../../constants" import { - ConditionalPriceInfo, - ConditionalShippingOptionPriceAccessor, -} from "../../types" + CondtionalPriceRuleSchema, + CondtionalPriceRuleSchemaType, + CreateShippingOptionSchemaType, +} from "../../../location-service-zone-shipping-option-create/components/create-shipping-options-form/schema" +import { ConditionalPriceInfo } from "../../types" import { getCustomShippingOptionPriceFieldName } from "../../utils/get-custom-shipping-option-price-field-info" import { useShippingOptionPrice } from "../shipping-option-price-provider" @@ -48,34 +47,31 @@ export const PriceRuleForm = ({ info }: PriceRuleFormProps) => { const { getValues, setValue: setFormValue } = useFormContext() const { onCloseConditionalPricesModal } = useShippingOptionPrice() - const { getIsOpen } = useStackedModal() const [value, setValue] = useState([getRuleValue(0)]) const { field, type, currency, name: header } = info const name = getCustomShippingOptionPriceFieldName(field, type) - const snapshot = useRef(getValues(name)) - const tempForm = useForm({ + const tempForm = useForm({ defaultValues: { - [name]: snapshot.current, + prices: getValues(name) || [ + { + amount: "", + gte: "", + lte: "", + }, + ], }, + resolver: zodResolver(CondtionalPriceRuleSchema), }) - const open = getIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID) - - useEffect(() => { - if (open) { - tempForm.reset({ - [name]: snapshot.current, - }) - } - }, [open, name, tempForm]) + console.log(tempForm.formState.errors) const { fields, append, remove } = useFieldArray({ control: tempForm.control, - name, + name: "prices", }) const handleAdd = () => { @@ -92,69 +88,88 @@ export const PriceRuleForm = ({ info }: PriceRuleFormProps) => { remove(index) } - const handleSave = () => { - setFormValue(name, tempForm.getValues(name)) - tempForm.reset() - onCloseConditionalPricesModal() + const handleOnSubmit = tempForm.handleSubmit( + (values) => { + setFormValue(name, values.prices, { + shouldDirty: true, + shouldValidate: true, + shouldTouch: true, + }) + onCloseConditionalPricesModal() + }, + (e) => console.log(e) + ) + + // Intercept the Cmd + Enter key to only save the inner form. + const handleOnKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + event.stopPropagation() + + handleOnSubmit() + } } return ( - - - -
-
-
-
- - Conditional Prices for {header} - - - - Set custom prices for this shipping option based on the cart - total. - - +
+ + + + +
+
+
+
+ + Conditional Prices for {header} + + + + Set custom prices for this shipping option based on the + cart total. + + +
+ + {fields.map((field, index) => ( + + ))} + +
+ +
+
- - {fields.map((field, index) => ( - - ))} - -
-
+ + +
+ + -
+ +
-
-
- - -
- - - - -
-
- + + + + ) } @@ -184,15 +199,13 @@ const PriceRuleList = ({ interface PriceRuleItemProps { index: number - accessor: ConditionalShippingOptionPriceAccessor currency: CurrencyInfo onRemove: (index: number) => void - control: Control + control: Control } const PriceRuleItem = ({ index, - accessor, currency, onRemove, control, @@ -208,19 +221,19 @@ const PriceRuleItem = ({ className="bg-ui-bg-component shadow-elevation-card-rest rounded-lg" > -
+
-
- - Shipping option price - - { - return ( - - onChange(values?.float) - } - {...props} - /> - ) - }} - /> -
+ { + return ( + +
+
+ Shipping option price +
+
+ + + onChange(values?.float || "") + } + {...props} + /> + + +
+
+
+ ) + }} + /> -
- - Minimum cart total - - { - return ( - - onChange(values?.float) - } - {...props} - /> - ) - }} - /> -
+ { + return ( + +
+
+ Minimum cart total +
+
+ + + onChange(values?.float || "") + } + {...props} + /> + + +
+
+
+ ) + }} + /> -
- - Maximum cart total - - { - return ( - - onChange(values?.float) - } - {...props} - /> - ) - }} - /> -
+ { + return ( + +
+
+ Maximum cart total +
+
+ + + onChange(values?.float || "") + } + {...props} + /> + + +
+
+
+ ) + }} + />
) } const AmountDisplay = ({ - accessor, index, currency, + control, }: { - accessor: ConditionalShippingOptionPriceAccessor index: number currency: CurrencyInfo + control: Control }) => { - const { control } = useFormContext() - const amount = useWatch({ control, - name: `${accessor}.${index}.amount`, + name: `prices.${index}.amount`, }) if (amount === "" || amount === undefined) { @@ -349,24 +384,22 @@ const AmountDisplay = ({ } const ConditionDisplay = ({ - accessor, index, currency, + control, }: { - accessor: ConditionalShippingOptionPriceAccessor index: number currency: CurrencyInfo + control: Control }) => { - const { control } = useFormContext() - const gte = useWatch({ control, - name: `${accessor}.${index}.gte`, + name: `prices.${index}.gte`, }) const lte = useWatch({ control, - name: `${accessor}.${index}.lte`, + name: `prices.${index}.lte`, }) const renderCondition = () => { diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx index 2394debe13cb5..f45c0da1e8146 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -7,16 +7,12 @@ import { useTranslation } from "react-i18next" import { useState } from "react" import { RouteFocusModal, - StackedFocusModal, useRouteModal, } from "../../../../../components/modals" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options" import { castNumber } from "../../../../../lib/cast-number" -import { - CONDITIONAL_PRICES_STACKED_MODAL_ID, - ShippingOptionPriceType, -} from "../../../common/constants" +import { ShippingOptionPriceType } from "../../../common/constants" import { CreateShippingOptionDetailsForm } from "./create-shipping-option-details-form" import { CreateShippingOptionsPricesForm } from "./create-shipping-options-prices-form" import { @@ -93,6 +89,70 @@ export function CreateShippingOptionsForm({ }) .filter((o) => !!o) as { region_id: string; amount: number }[] + const conditionalRegionPrices = Object.entries(data.custom_region_prices) + .flatMap(([region_id, value]) => { + return value?.map((rule) => ({ + region_id, + amount: castNumber(rule.amount), + rules: [ + ...(rule.gte + ? [ + { + attribute: "cart_total", + operator: "gte", + value: castNumber(rule.gte), + }, + ] + : []), + ...(rule.lte + ? [ + { + attribute: "cart_total", + operator: "lte", + value: castNumber(rule.lte), + }, + ] + : []), + ].filter((o) => !!o), + })) + }) + .filter((o) => !!o) + + regionPrices.push(...conditionalRegionPrices) + + const conditionalCurrencyPrices = Object.entries( + data.custom_currency_prices + ) + .flatMap(([currency_code, value]) => { + return value?.map((rule) => ({ + currency_code, + amount: castNumber(rule.amount), + rules: [ + ...(rule.gte + ? [ + { + attribute: "cart_total", + operator: "gte", + value: castNumber(rule.gte), + }, + ] + : []), + ...(rule.lte + ? [ + { + attribute: "cart_total", + operator: "lte", + value: castNumber(rule.lte), + }, + ] + : []), + ].filter((o) => !!o), + })) + }) + .filter((o) => !!o) + + currencyPrices.push(...conditionalCurrencyPrices) + await mutateAsync( { name: data.name, @@ -198,92 +258,87 @@ export function CreateShippingOptionsForm({ : "in-progress" return ( - - - onTabChange(tab as Tab)} - > - - - + + onTabChange(tab as Tab)} + > + + + + + + {t("stockLocations.shippingOptions.create.tabs.details")} + + + {!isCalculatedPriceType && ( - - {t("stockLocations.shippingOptions.create.tabs.details")} + + {t("stockLocations.shippingOptions.create.tabs.prices")} - {!isCalculatedPriceType && ( - - - {t("stockLocations.shippingOptions.create.tabs.prices")} - - - )} - - + )} + + - - - - - - - - - -
- - - - {activeTab === Tab.PRICING || isCalculatedPriceType ? ( - - ) : ( - - )} -
-
-
-
-
-
+ + + + + + + + + +
+ + + + {activeTab === Tab.PRICING || isCalculatedPriceType ? ( + + ) : ( + + )} +
+
+ + + ) } diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx index f753a190c8cc5..db6671085b5bd 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx @@ -3,6 +3,7 @@ import { UseFormReturn, useWatch } from "react-hook-form" import { DataGrid } from "../../../../../components/data-grid" import { + StackedFocusModal, useRouteModal, useStackedModal, } from "../../../../../components/modals" @@ -88,21 +89,25 @@ export const CreateShippingOptionsPricesForm = ({ } return ( - -
- setCloseOnEscape(!editing)} - disableInteractions={getIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID)} - /> - {selectedPrice && } -
-
+ + +
+ setCloseOnEscape(!editing)} + disableInteractions={getIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID)} + /> + {selectedPrice && ( + + )} +
+
+
) } diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts index 77486fb74190b..c31ad9b0d1b73 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { castNumber } from "../../../../../lib/cast-number" import { ShippingOptionPriceType } from "../../../common/constants" export type CreateShippingOptionSchema = z.infer< @@ -13,26 +14,70 @@ export const CreateShippingOptionDetailsSchema = z.object({ provider_id: z.string().min(1), }) -const ShippingOptionPriceSchema = z.object({ - amount: z.string().optional(), - gte: z.string().optional(), - lte: z.string().optional(), +const ShippingOptionPriceSchema = z + .object({ + amount: z.union([z.string(), z.number()]), + gte: z.union([z.string(), z.number()]).optional(), + lte: z.union([z.string(), z.number()]).optional(), + }) + .refine( + (data) => { + return ( + (data.gte !== undefined && data.gte !== "") || + (data.lte !== undefined && data.lte !== "") + ) + }, + { + message: "At least one of minimum or maximum cart total must be defined", + path: ["gte"], + } + ) + .refine( + (data) => { + if (data.gte !== undefined && data.lte !== undefined) { + const gte = castNumber(data.gte) + const lte = castNumber(data.lte) + return gte <= lte + } + return true + }, + { + message: + "Minimum cart total must be less than or equal to maximum cart total", + path: ["gte"], + } + ) + +export const ShippingOptionConditionalPriceSchema = z.object({ + custom_region_prices: z.record( + z.string(), + z.array(ShippingOptionPriceSchema).optional() + ), + custom_currency_prices: z.record( + z.string(), + z.array(ShippingOptionPriceSchema).optional() + ), }) +export type ShippingOptionConditionalPriceSchemaType = z.infer< + typeof ShippingOptionConditionalPriceSchema +> + +export const CondtionalPriceRuleSchema = z.object({ + prices: z.array(ShippingOptionPriceSchema), +}) + +export type CondtionalPriceRuleSchemaType = z.infer< + typeof CondtionalPriceRuleSchema +> + export const CreateShippingOptionSchema = z .object({ region_prices: z.record(z.string(), z.string().optional()), currency_prices: z.record(z.string(), z.string().optional()), - custom_region_prices: z.record( - z.string(), - z.array(ShippingOptionPriceSchema).optional() - ), - custom_currency_prices: z.record( - z.string(), - z.array(ShippingOptionPriceSchema).optional() - ), }) .merge(CreateShippingOptionDetailsSchema) + .merge(ShippingOptionConditionalPriceSchema) export type CreateShippingOptionSchemaType = z.infer< typeof CreateShippingOptionSchema From 5f66b04dea0efb5c15ad605ebad6a06bf79ba252 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 2 Dec 2024 09:35:44 +0100 Subject: [PATCH 05/13] update attrubute --- .../price-rule-form/price-rule-form.tsx | 18 +++++++++++++++--- .../create-shipping-options-form.tsx | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx index 7c148df6af96a..d2a81ab1f9120 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx @@ -273,7 +273,11 @@ const PriceRuleItem = ({ code={currency.code} value={value} onValueChange={(_value, _name, values) => - onChange(values?.float || "") + onChange( + typeof values?.float === "number" + ? values?.float + : "" + ) } {...props} /> @@ -304,7 +308,11 @@ const PriceRuleItem = ({ code={currency.code} value={value} onValueChange={(_value, _name, values) => - onChange(values?.float || "") + onChange( + typeof values?.float === "number" + ? values?.float + : "" + ) } {...props} /> @@ -335,7 +343,11 @@ const PriceRuleItem = ({ code={currency.code} value={value} onValueChange={(_value, _name, values) => - onChange(values?.float || "") + onChange( + typeof values?.float === "number" + ? values?.float + : "" + ) } {...props} /> diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx index f45c0da1e8146..2d29c4b886d92 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -131,7 +131,7 @@ export function CreateShippingOptionsForm({ ...(rule.gte ? [ { - attribute: "cart_total", + attribute: "total", operator: "gte", value: castNumber(rule.gte), }, @@ -140,7 +140,7 @@ export function CreateShippingOptionsForm({ ...(rule.lte ? [ { - attribute: "cart_total", + attribute: "total", operator: "lte", value: castNumber(rule.lte), }, From 49de282fcf1364b5677d568d271f617574450169 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 2 Dec 2024 09:55:54 +0100 Subject: [PATCH 06/13] fix attribute for region prices --- .../create-shipping-options-form.tsx | 4 +- .../edit-shipping-options-pricing-form.tsx | 110 ++++++++++++------ 2 files changed, 74 insertions(+), 40 deletions(-) diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx index 2d29c4b886d92..42b9c308cf32c 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -98,7 +98,7 @@ export function CreateShippingOptionsForm({ ...(rule.gte ? [ { - attribute: "cart_total", + attribute: "total", operator: "gte", value: castNumber(rule.gte), }, @@ -107,7 +107,7 @@ export function CreateShippingOptionsForm({ ...(rule.lte ? [ { - attribute: "cart_total", + attribute: "total", operator: "lte", value: castNumber(rule.lte), }, diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx index ba3eacdafddc6..c08cb73d4544a 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { useMemo } from "react" +import { useMemo, useState } from "react" import { useForm } from "react-hook-form" import * as zod from "zod" @@ -10,7 +10,9 @@ import { useTranslation } from "react-i18next" import { DataGrid } from "../../../../../components/data-grid" import { RouteFocusModal, + StackedFocusModal, useRouteModal, + useStackedModal, } from "../../../../../components/modals/index" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { usePricePreferences } from "../../../../../hooks/api/price-preferences" @@ -18,11 +20,17 @@ import { useRegions } from "../../../../../hooks/api/regions" import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options" import { useStore } from "../../../../../hooks/api/store" import { castNumber } from "../../../../../lib/cast-number" +import { PriceRuleForm } from "../../../common/components/price-rule-form" +import { ShippingOptionPriceProvider } from "../../../common/components/shipping-option-price-provider" +import { CONDITIONAL_PRICES_STACKED_MODAL_ID } from "../../../common/constants" import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns" +import { ConditionalPriceInfo } from "../../../common/types" const getInitialCurrencyPrices = ( prices: HttpTypes.AdminShippingOptionPrice[] ) => { + console.log("prices", prices) + const ret: Record = {} prices.forEach((p) => { if (p.price_rules!.length) { @@ -75,6 +83,19 @@ export function EditShippingOptionsPricingForm({ }: EditShippingOptionPricingFormProps) { const { t } = useTranslation() const { handleSuccess } = useRouteModal() + const { getIsOpen, setIsOpen } = useStackedModal() + const [selectedPrice, setSelectedPrice] = + useState(null) + + const onOpenConditionalPricesModal = (info: ConditionalPriceInfo) => { + setIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID, true) + setSelectedPrice(info) + } + + const onCloseConditionalPricesModal = () => { + setIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID, false) + setSelectedPrice(null) + } const form = useForm>({ defaultValues: { @@ -225,43 +246,56 @@ export function EditShippingOptionsPricingForm({ } return ( - - + - - - -
- setCloseOnEscape(!editing)} - /> -
-
- -
- - - - -
-
-
-
+ + + + + +
+ setCloseOnEscape(!editing)} + /> +
+ {selectedPrice && ( + + )} +
+ +
+ + + + +
+
+
+
+ + ) } From fbc9db798ede6df19d1ae849bfdf14d6bc8c3766 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:33:19 +0100 Subject: [PATCH 07/13] fix: types and tab state in create flow --- .../components/common/tax-badge/tax-badge.tsx | 12 +- .../stacked-focus-modal.tsx | 9 +- .../dashboard/src/i18n/translations/en.json | 28 + .../conditional-price-form.tsx | 701 ++++++++++++++++++ .../conditional-price-form/index.ts | 1 + .../components/price-rule-form/index.ts | 1 - .../price-rule-form/price-rule-form.tsx | 476 ------------ .../shipping-option-price-cell.tsx | 13 +- .../src/routes/locations/common/schema.ts | 80 ++ .../src/routes/locations/common/types.ts | 4 +- ...custom-shipping-option-price-field-info.ts | 4 +- .../create-shipping-options-form.tsx | 163 ++-- .../create-shipping-options-prices-form.tsx | 13 +- .../create-shipping-options-form/schema.ts | 52 +- .../edit-shipping-options-pricing-form.tsx | 267 ++++--- .../http/shipping-option/admin/entities.ts | 9 +- .../http/shipping-option/admin/payloads.ts | 22 +- 17 files changed, 1146 insertions(+), 709 deletions(-) create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx create mode 100644 packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/index.ts delete mode 100644 packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts delete mode 100644 packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx create mode 100644 packages/admin/dashboard/src/routes/locations/common/schema.ts diff --git a/packages/admin/dashboard/src/components/common/tax-badge/tax-badge.tsx b/packages/admin/dashboard/src/components/common/tax-badge/tax-badge.tsx index 60801b9ccaef9..c8ca429586255 100644 --- a/packages/admin/dashboard/src/components/common/tax-badge/tax-badge.tsx +++ b/packages/admin/dashboard/src/components/common/tax-badge/tax-badge.tsx @@ -1,5 +1,5 @@ -import { BuildingTax } from "@medusajs/icons" -import { Tooltip, clx } from "@medusajs/ui" +import { TaxExclusive, TaxInclusive } from "@medusajs/icons" +import { Tooltip } from "@medusajs/ui" import { useTranslation } from "react-i18next" type IncludesTaxTooltipProps = { @@ -20,9 +20,11 @@ export const IncludesTaxTooltip = ({ : t("general.excludesTaxTooltip") } > - + {includesTax ? ( + + ) : ( + + )} ) } diff --git a/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx b/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx index 5e2cfe6b47e23..92764504270a8 100644 --- a/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx +++ b/packages/admin/dashboard/src/components/modals/stacked-focus-modal/stacked-focus-modal.tsx @@ -13,7 +13,10 @@ type StackedFocusModalProps = PropsWithChildren<{ * when multiple stacked modals are registered to the same parent modal. */ id: string - onOpenChangeHook?: (open: boolean) => void + /** + * An optional callback that is called when the modal is opened or closed. + */ + onOpenChangeCallback?: (open: boolean) => void }> /** @@ -21,7 +24,7 @@ type StackedFocusModalProps = PropsWithChildren<{ */ export const Root = ({ id, - onOpenChangeHook, + onOpenChangeCallback, children, }: StackedFocusModalProps) => { const { register, unregister, getIsOpen, setIsOpen } = useStackedModal() @@ -35,7 +38,7 @@ export const Root = ({ const handleOpenChange = (open: boolean) => { setIsOpen(id, open) - onOpenChangeHook?.(open) + onOpenChangeCallback?.(open) } return ( diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 3648ef378d9cd..e1ff17880679d 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1439,6 +1439,34 @@ "pricing": { "action": "Edit prices" }, + "conditionalPrices": { + "header": "Conditional Prices for {{name}}", + "description": "Manage the conditional prices for this shipping option based on the cart item total.", + "attributes": { + "cartItemTotal": "Cart item total" + }, + "summaries": { + "range": "If <0>{{attribute}} is between <1>{{gte}} and <2>{{lte}}", + "greaterThan": "If <0>{{attribute}} ≥ <1>{{gte}}", + "lessThan": "If <0>{{attribute}} ≤ <1>{{lte}}" + }, + "actions": { + "addPrice": "Add price", + "manageConditionalPrices": "Manage conditional prices" + }, + "rules": { + "amount": "Shipping option price", + "gte": "Minimum cart item total", + "lte": "Maximum cart item total" + }, + "customRules": { + "label": "Custom rules", + "tooltip": "This conditional price has rules that cannot be managed in the dashboard.", + "eq": "Cart item total must equal", + "gt": "Cart item total must be greater than", + "lt": "Cart item total must be less than" + } + }, "fields": { "count": { "shipping_one": "{{count}} shipping option", diff --git a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx new file mode 100644 index 0000000000000..3be4d617e52e5 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx @@ -0,0 +1,701 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { + InformationCircleSolid, + Plus, + TriangleDownMini, + XMark, + XMarkMini, +} from "@medusajs/icons" +import { + Badge, + Button, + CurrencyInput, + Heading, + IconButton, + Label, + Text, + Tooltip, +} from "@medusajs/ui" +import * as Accordion from "@radix-ui/react-accordion" +import React, { Fragment, ReactNode, useState } from "react" +import { + Control, + useFieldArray, + useForm, + useFormContext, + useWatch, +} from "react-hook-form" +import { Trans, useTranslation } from "react-i18next" + +import { formatValue } from "react-currency-input-field" +import { Divider } from "../../../../../components/common/divider" +import { Form } from "../../../../../components/common/form" +import { StackedFocusModal } from "../../../../../components/modals" +import { KeyboundForm } from "../../../../../components/utilities/keybound-form" +import { castNumber } from "../../../../../lib/cast-number" +import { CurrencyInfo } from "../../../../../lib/data/currencies" +import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" +import { CreateShippingOptionSchemaType } from "../../../location-service-zone-shipping-option-create/components/create-shipping-options-form/schema" +import { + CondtionalPriceRuleSchema, + CondtionalPriceRuleSchemaType, + UpdateConditionalPriceRuleSchema, + UpdateConditionalPriceRuleSchemaType, +} from "../../schema" +import { ConditionalPriceInfo } from "../../types" +import { getCustomShippingOptionPriceFieldName } from "../../utils/get-custom-shipping-option-price-field-info" +import { useShippingOptionPrice } from "../shipping-option-price-provider" + +const RULE_ITEM_PREFIX = "rule-item" + +const getRuleValue = (index: number) => `${RULE_ITEM_PREFIX}-${index}` + +interface ConditionalPriceFormProps { + info: ConditionalPriceInfo + variant: "create" | "update" +} + +export const ConditionalPriceForm = ({ + info, + variant, +}: ConditionalPriceFormProps) => { + const { t } = useTranslation() + const { getValues, setValue: setFormValue } = + useFormContext() + const { onCloseConditionalPricesModal } = useShippingOptionPrice() + + const [value, setValue] = useState([getRuleValue(0)]) + + const { field, type, currency, name: header } = info + + const name = getCustomShippingOptionPriceFieldName(field, type) + + const conditionalPriceForm = useForm< + CondtionalPriceRuleSchemaType | UpdateConditionalPriceRuleSchemaType + >({ + defaultValues: { + prices: getValues(name) || [ + { + amount: "", + gte: "", + lte: null, + }, + ], + }, + resolver: zodResolver( + variant === "create" + ? CondtionalPriceRuleSchema + : UpdateConditionalPriceRuleSchema + ), + }) + + const { fields, append, remove } = useFieldArray({ + control: conditionalPriceForm.control, + name: "prices", + }) + + const handleAdd = () => { + append({ + amount: "", + gte: "", + lte: null, + }) + + setValue([...value, getRuleValue(fields.length)]) + } + + const handleRemove = (index: number) => { + remove(index) + } + + const handleOnSubmit = conditionalPriceForm.handleSubmit((values) => { + setFormValue(name, values.prices, { + shouldDirty: true, + shouldValidate: true, + shouldTouch: true, + }) + onCloseConditionalPricesModal() + }) + + // Intercept the Cmd + Enter key to only save the inner form. + const handleOnKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + event.stopPropagation() + + handleOnSubmit() + } + } + + return ( +
+ + + + +
+
+
+
+ + + {t( + "stockLocations.shippingOptions.conditionalPrices.header", + { + name: header, + } + )} + + + + + {t( + "stockLocations.shippingOptions.conditionalPrices.description" + )} + + +
+ + {fields.map((field, index) => ( + + ))} + +
+ +
+
+
+
+
+ +
+ + + + +
+
+
+
+
+ ) +} + +interface ConditionalPriceListProps { + children?: ReactNode + value: string[] + onValueChange: (value: string[]) => void +} + +const ConditionalPriceList = ({ + children, + value, + onValueChange, +}: ConditionalPriceListProps) => { + return ( + + {children} + + ) +} + +interface ConditionalPriceItemProps { + index: number + currency: CurrencyInfo + onRemove: (index: number) => void + control: Control +} + +const ConditionalPriceItem = ({ + index, + currency, + onRemove, + control, +}: ConditionalPriceItemProps) => { + const { t } = useTranslation() + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation() + onRemove(index) + } + + return ( + + +
+
+
+ +
+
+ +
+
+
+ + + + + + +
+
+
+ + + { + return ( + +
+
+ + {t( + "stockLocations.shippingOptions.conditionalPrices.rules.amount" + )} + +
+
+ + + onChange(values?.value ? values?.value : "") + } + autoFocus + {...props} + /> + + +
+
+
+ ) + }} + /> + + { + const action = () => { + if (value === null) { + onChange("") + return + } + + onChange(null) + } + + const isNull = value === null + + return ( + +
+
+ + {isNull ? : } + + + {t( + "stockLocations.shippingOptions.conditionalPrices.rules.gte" + )} + +
+ {!isNull && ( +
+ + + onChange(values?.value ? values?.value : "") + } + autoFocus + {...props} + /> + + +
+ )} +
+
+ ) + }} + /> + + { + const action = () => { + if (value === null) { + onChange("") + return + } + + onChange(null) + } + + const isNull = value === null + + return ( + +
+
+ + {isNull ? : } + + + {t( + "stockLocations.shippingOptions.conditionalPrices.rules.lte" + )} + +
+ {!isNull && ( +
+ + + onChange(values?.value ? values?.value : "") + } + autoFocus + {...props} + /> + + +
+ )} +
+
+ ) + }} + /> + +
+
+ ) +} + +const ReadOnlyConditions = ({ + index, + control, + currency, +}: { + index: number + control: Control + currency: CurrencyInfo +}) => { + const { t } = useTranslation() + + const item = useWatch({ + control, + name: `prices.${index}`, + }) + + if (item.eq == null && item.gt == null && item.lt == null) { + return null + } + + return ( +
+ +
+ + {t( + "stockLocations.shippingOptions.conditionalPrices.customRules.label" + )} + + + + +
+
+ {item.eq != null && ( +
+
+ +
+ +
+ )} + {item.gt != null && ( + + +
+
+ +
+ +
+
+ )} + {item.lt != null && ( + + +
+
+ +
+ +
+
+ )} +
+
+ ) +} + +const AmountDisplay = ({ + index, + currency, + control, +}: { + index: number + currency: CurrencyInfo + control: Control +}) => { + const amount = useWatch({ + control, + name: `prices.${index}.amount`, + }) + + if (amount === "" || amount === undefined) { + return ( + + - + + ) + } + + const castAmount = castNumber(amount) + + return ( + + {getLocaleAmount(castAmount, currency.code)} + + ) +} + +const ConditionContainer = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+) + +const ConditionDisplay = ({ + index, + currency, + control, +}: { + index: number + currency: CurrencyInfo + control: Control +}) => { + const { t, i18n } = useTranslation() + + const gte = useWatch({ + control, + name: `prices.${index}.gte`, + }) + + const lte = useWatch({ + control, + name: `prices.${index}.lte`, + }) + + const renderCondition = () => { + const castGte = gte ? castNumber(gte) : undefined + const castLte = lte ? castNumber(lte) : undefined + + if (!castGte && !castLte) { + return null + } + + if (castGte && !castLte) { + return ( + + , + , + ]} + values={{ + attribute: t( + "stockLocations.shippingOptions.conditionalPrices.attributes.cartItemTotal" + ), + gte: getLocaleAmount(castGte, currency.code), + }} + /> + + ) + } + + if (!castGte && castLte) { + return ( + + , + , + ]} + values={{ + attribute: t( + "stockLocations.shippingOptions.conditionalPrices.attributes.cartItemTotal" + ), + lte: getLocaleAmount(castLte, currency.code), + }} + /> + + ) + } + + if (castGte && castLte) { + return ( + + , + , + , + ]} + values={{ + attribute: t( + "stockLocations.shippingOptions.conditionalPrices.attributes.cartItemTotal" + ), + gte: getLocaleAmount(castGte, currency.code), + lte: getLocaleAmount(castLte, currency.code), + }} + /> + + ) + } + + return null + } + + return renderCondition() +} diff --git a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/index.ts b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/index.ts new file mode 100644 index 0000000000000..b184d947d1c4d --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/index.ts @@ -0,0 +1 @@ +export * from "./conditional-price-form" diff --git a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts deleted file mode 100644 index f90cd951c06cd..0000000000000 --- a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./price-rule-form" diff --git a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx deleted file mode 100644 index d2a81ab1f9120..0000000000000 --- a/packages/admin/dashboard/src/routes/locations/common/components/price-rule-form/price-rule-form.tsx +++ /dev/null @@ -1,476 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { TriangleDownMini, XMarkMini } from "@medusajs/icons" -import { - Badge, - Button, - CurrencyInput, - Heading, - IconButton, - Text, -} from "@medusajs/ui" -import * as Accordion from "@radix-ui/react-accordion" -import React, { ReactNode, useState } from "react" -import { - Control, - useFieldArray, - useForm, - useFormContext, - useWatch, -} from "react-hook-form" -import { useTranslation } from "react-i18next" -import { Divider } from "../../../../../components/common/divider" -import { Form } from "../../../../../components/common/form" -import { StackedFocusModal } from "../../../../../components/modals" -import { KeyboundForm } from "../../../../../components/utilities/keybound-form" -import { castNumber } from "../../../../../lib/cast-number" -import { CurrencyInfo } from "../../../../../lib/data/currencies" -import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" -import { - CondtionalPriceRuleSchema, - CondtionalPriceRuleSchemaType, - CreateShippingOptionSchemaType, -} from "../../../location-service-zone-shipping-option-create/components/create-shipping-options-form/schema" -import { ConditionalPriceInfo } from "../../types" -import { getCustomShippingOptionPriceFieldName } from "../../utils/get-custom-shipping-option-price-field-info" -import { useShippingOptionPrice } from "../shipping-option-price-provider" - -const RULE_ITEM_PREFIX = "rule-item" - -const getRuleValue = (index: number) => `${RULE_ITEM_PREFIX}-${index}` - -interface PriceRuleFormProps { - info: ConditionalPriceInfo -} - -export const PriceRuleForm = ({ info }: PriceRuleFormProps) => { - const { t } = useTranslation() - const { getValues, setValue: setFormValue } = - useFormContext() - const { onCloseConditionalPricesModal } = useShippingOptionPrice() - - const [value, setValue] = useState([getRuleValue(0)]) - - const { field, type, currency, name: header } = info - - const name = getCustomShippingOptionPriceFieldName(field, type) - - const tempForm = useForm({ - defaultValues: { - prices: getValues(name) || [ - { - amount: "", - gte: "", - lte: "", - }, - ], - }, - resolver: zodResolver(CondtionalPriceRuleSchema), - }) - - console.log(tempForm.formState.errors) - - const { fields, append, remove } = useFieldArray({ - control: tempForm.control, - name: "prices", - }) - - const handleAdd = () => { - append({ - amount: "", - gte: "", - lte: "", - }) - - setValue([...value, getRuleValue(fields.length)]) - } - - const handleRemove = (index: number) => { - remove(index) - } - - const handleOnSubmit = tempForm.handleSubmit( - (values) => { - setFormValue(name, values.prices, { - shouldDirty: true, - shouldValidate: true, - shouldTouch: true, - }) - onCloseConditionalPricesModal() - }, - (e) => console.log(e) - ) - - // Intercept the Cmd + Enter key to only save the inner form. - const handleOnKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - event.stopPropagation() - - handleOnSubmit() - } - } - - return ( -
- - - - -
-
-
-
- - Conditional Prices for {header} - - - - Set custom prices for this shipping option based on the - cart total. - - -
- - {fields.map((field, index) => ( - - ))} - -
- -
-
-
-
-
- -
- - - - -
-
-
-
-
- ) -} - -interface PriceRuleListProps { - children?: ReactNode - value: string[] - onValueChange: (value: string[]) => void -} - -const PriceRuleList = ({ - children, - value, - onValueChange, -}: PriceRuleListProps) => { - return ( - - {children} - - ) -} - -interface PriceRuleItemProps { - index: number - currency: CurrencyInfo - onRemove: (index: number) => void - control: Control -} - -const PriceRuleItem = ({ - index, - currency, - onRemove, - control, -}: PriceRuleItemProps) => { - const handleRemove = (e: React.MouseEvent) => { - e.stopPropagation() - onRemove(index) - } - - return ( - - -
-
- -
-
- - - - - - - -
-
-
- - - { - return ( - -
-
- Shipping option price -
-
- - - onChange( - typeof values?.float === "number" - ? values?.float - : "" - ) - } - {...props} - /> - - -
-
-
- ) - }} - /> - - { - return ( - -
-
- Minimum cart total -
-
- - - onChange( - typeof values?.float === "number" - ? values?.float - : "" - ) - } - {...props} - /> - - -
-
-
- ) - }} - /> - - { - return ( - -
-
- Maximum cart total -
-
- - - onChange( - typeof values?.float === "number" - ? values?.float - : "" - ) - } - {...props} - /> - - -
-
-
- ) - }} - /> -
-
- ) -} - -const AmountDisplay = ({ - index, - currency, - control, -}: { - index: number - currency: CurrencyInfo - control: Control -}) => { - const amount = useWatch({ - control, - name: `prices.${index}.amount`, - }) - - if (amount === "" || amount === undefined) { - return ( - - - - - ) - } - - const castAmount = castNumber(amount) - - return ( - - {getLocaleAmount(castAmount, currency.code)} - - ) -} - -const ConditionDisplay = ({ - index, - currency, - control, -}: { - index: number - currency: CurrencyInfo - control: Control -}) => { - const gte = useWatch({ - control, - name: `prices.${index}.gte`, - }) - - const lte = useWatch({ - control, - name: `prices.${index}.lte`, - }) - - const renderCondition = () => { - const castGte = gte ? castNumber(gte) : undefined - const castLte = lte ? castNumber(lte) : undefined - - if (!castGte && !castLte) { - return null - } - - if (castGte && !castLte) { - return ( - <> - If - Cart total - - - {getLocaleAmount(castGte, currency.code)} - - - ) - } - - if (!castGte && castLte) { - return ( - <> - If - Cart total - - - {getLocaleAmount(castLte, currency.code)} - - - ) - } - - if (castGte && castLte) { - return ( - <> - If - Cart total - is between - - {getLocaleAmount(castGte, currency.code)} - - and - - {getLocaleAmount(castLte, currency.code)} - - - ) - } - - return null - } - - return ( -
- {renderCondition()} -
- ) -} diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx index 8fc561516fd3a..0f2522ced32ac 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx @@ -1,4 +1,4 @@ -import { Adjustments, ArrowsPointingOut } from "@medusajs/icons" +import { ArrowsPointingOut, CircleSliders } from "@medusajs/icons" import { clx } from "@medusajs/ui" import { useCallback, useEffect, useRef, useState } from "react" import CurrencyInput, { @@ -118,7 +118,7 @@ const OuterComponent = ({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (isAnchor && (e.metaKey || e.ctrlKey) && e.key === "c") { + if (isAnchor && (e.metaKey || e.ctrlKey) && e.key === "b") { e.preventDefault() buttonRef.current?.click() } @@ -137,7 +137,7 @@ const OuterComponent = ({ > {price?.length > 0 && !isAnchor && (
- +
)}
diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts index c31ad9b0d1b73..442e2920dc1ef 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { castNumber } from "../../../../../lib/cast-number" import { ShippingOptionPriceType } from "../../../common/constants" +import { ConditionalPriceSchema } from "../../../common/schema" export type CreateShippingOptionSchema = z.infer< typeof CreateShippingOptionSchema @@ -14,48 +14,14 @@ export const CreateShippingOptionDetailsSchema = z.object({ provider_id: z.string().min(1), }) -const ShippingOptionPriceSchema = z - .object({ - amount: z.union([z.string(), z.number()]), - gte: z.union([z.string(), z.number()]).optional(), - lte: z.union([z.string(), z.number()]).optional(), - }) - .refine( - (data) => { - return ( - (data.gte !== undefined && data.gte !== "") || - (data.lte !== undefined && data.lte !== "") - ) - }, - { - message: "At least one of minimum or maximum cart total must be defined", - path: ["gte"], - } - ) - .refine( - (data) => { - if (data.gte !== undefined && data.lte !== undefined) { - const gte = castNumber(data.gte) - const lte = castNumber(data.lte) - return gte <= lte - } - return true - }, - { - message: - "Minimum cart total must be less than or equal to maximum cart total", - path: ["gte"], - } - ) - export const ShippingOptionConditionalPriceSchema = z.object({ - custom_region_prices: z.record( + conditional_region_prices: z.record( z.string(), - z.array(ShippingOptionPriceSchema).optional() + z.array(ConditionalPriceSchema).optional() ), - custom_currency_prices: z.record( + conditional_currency_prices: z.record( z.string(), - z.array(ShippingOptionPriceSchema).optional() + z.array(ConditionalPriceSchema).optional() ), }) @@ -63,14 +29,6 @@ export type ShippingOptionConditionalPriceSchemaType = z.infer< typeof ShippingOptionConditionalPriceSchema > -export const CondtionalPriceRuleSchema = z.object({ - prices: z.array(ShippingOptionPriceSchema), -}) - -export type CondtionalPriceRuleSchemaType = z.infer< - typeof CondtionalPriceRuleSchema -> - export const CreateShippingOptionSchema = z .object({ region_prices: z.record(z.string(), z.string().optional()), diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx index c08cb73d4544a..638bc9fdad224 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx @@ -20,42 +20,16 @@ import { useRegions } from "../../../../../hooks/api/regions" import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options" import { useStore } from "../../../../../hooks/api/store" import { castNumber } from "../../../../../lib/cast-number" -import { PriceRuleForm } from "../../../common/components/price-rule-form" +import { ConditionalPriceForm } from "../../../common/components/conditional-price-form" import { ShippingOptionPriceProvider } from "../../../common/components/shipping-option-price-provider" import { CONDITIONAL_PRICES_STACKED_MODAL_ID } from "../../../common/constants" import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns" +import { + UpdateConditionalPrice, + UpdateConditionalPriceSchema, +} from "../../../common/schema" import { ConditionalPriceInfo } from "../../../common/types" -const getInitialCurrencyPrices = ( - prices: HttpTypes.AdminShippingOptionPrice[] -) => { - console.log("prices", prices) - - const ret: Record = {} - prices.forEach((p) => { - if (p.price_rules!.length) { - // this is a region price - return - } - ret[p.currency_code!] = p.amount - }) - return ret -} - -const getInitialRegionPrices = ( - prices: HttpTypes.AdminShippingOptionPrice[] -) => { - const ret: Record = {} - prices.forEach((p) => { - if (p.price_rules!.length) { - const regionId = p.price_rules![0].value - ret[regionId] = p.amount - } - }) - - return ret -} - type PriceRecord = { id?: string currency_code?: string @@ -72,6 +46,14 @@ const EditShippingOptionPricingSchema = zod.object({ zod.string(), zod.string().or(zod.number()).optional() ), + conditional_region_prices: zod.record( + zod.string(), + zod.array(UpdateConditionalPriceSchema) + ), + conditional_currency_prices: zod.record( + zod.string(), + zod.array(UpdateConditionalPriceSchema) + ), }) type EditShippingOptionPricingFormProps = { @@ -98,10 +80,7 @@ export function EditShippingOptionsPricingForm({ } const form = useForm>({ - defaultValues: { - region_prices: getInitialRegionPrices(shippingOption.prices), - currency_prices: getInitialCurrencyPrices(shippingOption.prices), - }, + defaultValues: getDefaultValues(shippingOption.prices), resolver: zodResolver(EditShippingOptionPricingSchema), }) @@ -145,83 +124,107 @@ export function EditShippingOptionsPricingForm({ [currencies, regions] ) + const createPriceRule = ( + attribute: string, + operator: string, + value: string | number + ) => ({ + attribute, + operator, + value: castNumber(value), + }) + + const buildRules = (rule: UpdateConditionalPrice) => { + const conditions = [ + { value: rule.gte, operator: "gte" }, + { value: rule.lte, operator: "lte" }, + { value: rule.gt, operator: "gt" }, + { value: rule.lt, operator: "lt" }, + { value: rule.eq, operator: "eq" }, + ] + + return conditions + .filter(({ value }) => value) + .map(({ operator, value }) => createPriceRule("total", operator, value!)) + } + const handleSubmit = form.handleSubmit(async (data) => { const currencyPrices = Object.entries(data.currency_prices) .map(([code, value]) => { - if (value === "" || value === undefined) { + if ( + !value || + !currencies.some((c) => c.toLowerCase() === code.toLowerCase()) + ) { return undefined } - const currencyExists = currencies.some( - (currencyCode) => currencyCode.toLowerCase() == code.toLowerCase() - ) - if (!currencyExists) { - return undefined - } - - const amount = castNumber(value) - const priceRecord: PriceRecord = { currency_code: code, - amount: amount, + amount: castNumber(value), } - const price = shippingOption.prices.find( + const existingPrice = shippingOption.prices.find( (p) => p.currency_code === code && !p.price_rules!.length ) - // if that currency price is already defined for the SO, we will do an update - if (price) { - priceRecord["id"] = price.id + if (existingPrice) { + priceRecord.id = existingPrice.id } return priceRecord }) - .filter((p) => !!p) as PriceRecord[] + .filter((p): p is PriceRecord => !!p) + + const conditionalCurrencyPrices = Object.entries( + data.conditional_currency_prices + ).flatMap(([currency_code, value]) => + value?.map((rule) => ({ + id: rule.id, + currency_code, + amount: castNumber(rule.amount), + rules: buildRules(rule), + })) + ) + /** + * TODO: If we try to update an existing region price the API throws an error. + * Instead we re-create region prices. + */ const regionPrices = Object.entries(data.region_prices) .map(([region_id, value]) => { - if (value === "" || value === undefined) { - return undefined - } - - // Check if the region_id exists in the regions array to avoid - // sending updates of region prices where the region has been - // deleted - const regionExists = regions?.some((region) => region.id === region_id) - if (!regionExists) { + if (!value || !regions?.some((region) => region.id === region_id)) { return undefined } - const amount = castNumber(value) - const priceRecord: PriceRecord = { region_id, - amount: amount, + amount: castNumber(value), } - /** - * HACK - when trying to update prices which already have a region price - * we get error: `Price rule with price_id: , rule_type_id: already exist`, - * so for now, we recreate region prices. - */ - - // const price = shippingOption.prices.find( - // (p) => p.price_rules?.[0]?.value === region_id - // ) - - // if (price) { - // priceRecord["id"] = price.id - // } - return priceRecord }) - .filter((p) => !!p) as PriceRecord[] + .filter((p): p is PriceRecord => !!p) + + const conditionalRegionPrices = Object.entries( + data.conditional_region_prices + ).flatMap(([region_id, value]) => + value?.map((rule) => ({ + id: rule.id, + region_id, + amount: castNumber(rule.amount), + rules: buildRules(rule), + })) + ) + + const allPrices = [ + ...currencyPrices, + ...conditionalCurrencyPrices, + ...regionPrices, + ...conditionalRegionPrices, + ] await mutateAsync( - { - prices: [...currencyPrices, ...regionPrices], - }, + { prices: allPrices }, { onSuccess: () => { toast.success(t("general.success")) @@ -246,7 +249,14 @@ export function EditShippingOptionsPricingForm({ } return ( - + { + if (!open) { + setSelectedPrice(null) + } + }} + > setCloseOnEscape(!editing)} + disableInteractions={getIsOpen( + CONDITIONAL_PRICES_STACKED_MODAL_ID + )} />
{selectedPrice && ( - + )} @@ -299,3 +309,88 @@ export function EditShippingOptionsPricingForm({ ) } + +const findRuleValue = ( + rules: HttpTypes.AdminShippingOptionPriceRule[], + operator: string +) => { + const fallbackValue = ["eq", "gt", "lt"].includes(operator) ? undefined : "" + + return ( + rules?.find((r) => r.attribute === "total" && r.operator === operator) + ?.value || fallbackValue + ) +} + +const mapToConditionalPrice = ( + price: HttpTypes.AdminShippingOptionPrice +): UpdateConditionalPrice => { + const rules = price.price_rules || [] + + return { + id: price.id, + amount: price.amount, + gte: findRuleValue(rules, "gte"), + lte: findRuleValue(rules, "lte"), + gt: findRuleValue(rules, "gt") as undefined | null, + lt: findRuleValue(rules, "lt") as undefined | null, + eq: findRuleValue(rules, "eq") as undefined | null, + } +} + +const getDefaultValues = (prices: HttpTypes.AdminShippingOptionPrice[]) => { + const hasAttributes = ( + price: HttpTypes.AdminShippingOptionPrice, + required: string[], + forbidden: string[] = [] + ) => { + const attributes = price.price_rules?.map((r) => r.attribute) || [] + return ( + required.every((attr) => attributes.includes(attr)) && + !forbidden.some((attr) => attributes.includes(attr)) + ) + } + + const currency_prices: Record = {} + const conditional_currency_prices: Record = + {} + const region_prices: Record = {} + const conditional_region_prices: Record = {} + + prices.forEach((price) => { + if (!price.price_rules?.length) { + currency_prices[price.currency_code!] = price.amount + return + } + + if (hasAttributes(price, ["total"], ["region_id"])) { + const code = price.currency_code! + if (!conditional_currency_prices[code]) { + conditional_currency_prices[code] = [] + } + conditional_currency_prices[code].push(mapToConditionalPrice(price)) + return + } + + if (hasAttributes(price, ["region_id"], ["total"])) { + const regionId = price.price_rules[0].value + region_prices[regionId] = price.amount + return + } + + if (hasAttributes(price, ["region_id", "total"])) { + const regionId = price.price_rules[0].value + if (!conditional_region_prices[regionId]) { + conditional_region_prices[regionId] = [] + } + conditional_region_prices[regionId].push(mapToConditionalPrice(price)) + } + }) + + return { + currency_prices, + conditional_currency_prices, + region_prices, + conditional_region_prices, + } +} diff --git a/packages/core/types/src/http/shipping-option/admin/entities.ts b/packages/core/types/src/http/shipping-option/admin/entities.ts index d22eac5c52e0e..cab9ad9429c27 100644 --- a/packages/core/types/src/http/shipping-option/admin/entities.ts +++ b/packages/core/types/src/http/shipping-option/admin/entities.ts @@ -30,7 +30,14 @@ export interface AdminShippingOptionRule { // TODO: This type is complete, but it's not clear what the `rules` field is supposed to return in all cases. export interface AdminShippingOptionPriceRule { id: string - value: string + value: string | number + operator: RuleOperatorType + attribute: string + price_id: string + priority: number + created_at: string + updated_at: string + deleted_at: string | null } export interface AdminShippingOptionPrice extends AdminPrice { diff --git a/packages/core/types/src/http/shipping-option/admin/payloads.ts b/packages/core/types/src/http/shipping-option/admin/payloads.ts index c0297dde6b794..514226f795494 100644 --- a/packages/core/types/src/http/shipping-option/admin/payloads.ts +++ b/packages/core/types/src/http/shipping-option/admin/payloads.ts @@ -13,12 +13,24 @@ export interface AdminCreateShippingOptionType { code: string } -export interface AdminCreateShippingOptionPriceWithCurrency { +interface AdminShippingOptionPriceRulePayload { + operator: string + attribute: string + value: string | string[] | number +} + +interface AdminShippingOptionPriceWithRules { + rules?: AdminShippingOptionPriceRulePayload[] +} + +export interface AdminCreateShippingOptionPriceWithCurrency + extends AdminShippingOptionPriceWithRules { currency_code: string amount: number } -export interface AdminCreateShippingOptionPriceWithRegion { +export interface AdminCreateShippingOptionPriceWithRegion + extends AdminShippingOptionPriceWithRules { region_id: string amount: number } @@ -43,13 +55,15 @@ export interface AdminUpdateShippingOptionRule id: string } -export interface AdminUpdateShippingOptionPriceWithCurrency { +export interface AdminUpdateShippingOptionPriceWithCurrency + extends AdminShippingOptionPriceWithRules { id?: string currency_code?: string amount?: number } -export interface AdminUpdateShippingOptionPriceWithRegion { +export interface AdminUpdateShippingOptionPriceWithRegion + extends AdminShippingOptionPriceWithRules { id?: string region_id?: string amount?: number From 33f26c3bd162a8019b5309759741b680bcf3dae4 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:34:06 +0100 Subject: [PATCH 08/13] add changeset --- .changeset/dry-cheetahs-wait.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/dry-cheetahs-wait.md diff --git a/.changeset/dry-cheetahs-wait.md b/.changeset/dry-cheetahs-wait.md new file mode 100644 index 0000000000000..5da63fb7c3298 --- /dev/null +++ b/.changeset/dry-cheetahs-wait.md @@ -0,0 +1,6 @@ +--- +"@medusajs/dashboard": patch +"@medusajs/types": patch +--- + +feat(dashboard,types): Add UI to manage conditional SO prices From 1ab6ad7a967ae0c15041ddcafe64307001a87393 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:07:37 +0100 Subject: [PATCH 09/13] update schema --- .../src/i18n/translations/$schema.json | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 53a017191a54f..6b5aea65f7792 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -5963,6 +5963,123 @@ ], "additionalProperties": false }, + "conditionalPrices": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "description": { + "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "cartItemTotal": { + "type": "string" + } + }, + "required": [ + "cartItemTotal" + ], + "additionalProperties": false + }, + "summaries": { + "type": "object", + "properties": { + "range": { + "type": "string" + }, + "greaterThan": { + "type": "string" + }, + "lessThan": { + "type": "string" + } + }, + "required": [ + "range", + "greaterThan", + "lessThan" + ], + "additionalProperties": false + }, + "actions": { + "type": "object", + "properties": { + "addPrice": { + "type": "string" + }, + "manageConditionalPrices": { + "type": "string" + } + }, + "required": [ + "addPrice", + "manageConditionalPrices" + ], + "additionalProperties": false + }, + "rules": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "gte": { + "type": "string" + }, + "lte": { + "type": "string" + } + }, + "required": [ + "amount", + "gte", + "lte" + ], + "additionalProperties": false + }, + "customRules": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "tooltip": { + "type": "string" + }, + "eq": { + "type": "string" + }, + "gt": { + "type": "string" + }, + "lt": { + "type": "string" + } + }, + "required": [ + "label", + "tooltip", + "eq", + "gt", + "lt" + ], + "additionalProperties": false + } + }, + "required": [ + "header", + "description", + "attributes", + "summaries", + "actions", + "rules", + "customRules" + ], + "additionalProperties": false + }, "fields": { "type": "object", "properties": { @@ -6083,6 +6200,7 @@ "delete", "edit", "pricing", + "conditionalPrices", "fields" ], "additionalProperties": false From 7970c5953f42d59627c06bc44b1c7700ba117a03 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:46:10 +0100 Subject: [PATCH 10/13] address feedback --- .../src/i18n/translations/$schema.json | 23 +- .../dashboard/src/i18n/translations/en.json | 5 + .../conditional-price-form.tsx | 209 +++++++++--------- .../src/routes/locations/common/schema.ts | 16 +- ...n-service-zone-shipping-option-pricing.tsx | 11 +- 5 files changed, 146 insertions(+), 118 deletions(-) diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 6b5aea65f7792..fa4fb91fe609f 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -6067,6 +6067,26 @@ "lt" ], "additionalProperties": false + }, + "errors": { + "type": "object", + "properties": { + "amountRequired": { + "type": "string" + }, + "minOrMaxRequired": { + "type": "string" + }, + "minGreaterThanMax": { + "type": "string" + } + }, + "required": [ + "amountRequired", + "minOrMaxRequired", + "minGreaterThanMax" + ], + "additionalProperties": false } }, "required": [ @@ -6076,7 +6096,8 @@ "summaries", "actions", "rules", - "customRules" + "customRules", + "errors" ], "additionalProperties": false }, diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index e1ff17880679d..70202c5aea6d1 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1465,6 +1465,11 @@ "eq": "Cart item total must equal", "gt": "Cart item total must be greater than", "lt": "Cart item total must be less than" + }, + "errors": { + "amountRequired": "Shipping option price is required", + "minOrMaxRequired": "At least one of minimum or maximum cart item total must be provided", + "minGreaterThanMax": "Minimum cart item total must be less than or equal to maximum cart item total" } }, "fields": { diff --git a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx index 3be4d617e52e5..fb1f129bf908a 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx @@ -17,9 +17,10 @@ import { Tooltip, } from "@medusajs/ui" import * as Accordion from "@radix-ui/react-accordion" -import React, { Fragment, ReactNode, useState } from "react" +import React, { Fragment, ReactNode, useRef, useState } from "react" import { Control, + ControllerRenderProps, useFieldArray, useForm, useFormContext, @@ -32,6 +33,7 @@ import { Divider } from "../../../../../components/common/divider" import { Form } from "../../../../../components/common/form" import { StackedFocusModal } from "../../../../../components/modals" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" +import { useCombinedRefs } from "../../../../../hooks/use-combined-refs" import { castNumber } from "../../../../../lib/cast-number" import { CurrencyInfo } from "../../../../../lib/data/currencies" import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" @@ -250,7 +252,7 @@ const ConditionalPriceItem = ({ className="bg-ui-bg-component shadow-elevation-card-rest rounded-lg" > -
+
- +
@@ -302,7 +304,7 @@ const ConditionalPriceItem = ({ )}
-
+
{ - const action = () => { - if (value === null) { - onChange("") - return - } - - onChange(null) - } - - const isNull = value === null - + render={({ field }) => { return ( - -
-
- - {isNull ? : } - - - {t( - "stockLocations.shippingOptions.conditionalPrices.rules.gte" - )} - -
- {!isNull && ( -
- - - onChange(values?.value ? values?.value : "") - } - autoFocus - {...props} - /> - - -
- )} -
-
+ ) }} /> @@ -393,60 +351,16 @@ const ConditionalPriceItem = ({ { - const action = () => { - if (value === null) { - onChange("") - return - } - - onChange(null) - } - - const isNull = value === null - + render={({ field }) => { return ( - -
-
- - {isNull ? : } - - - {t( - "stockLocations.shippingOptions.conditionalPrices.rules.lte" - )} - -
- {!isNull && ( -
- - - onChange(values?.value ? values?.value : "") - } - autoFocus - {...props} - /> - - -
- )} -
-
+ ) }} /> @@ -460,6 +374,81 @@ const ConditionalPriceItem = ({ ) } +interface OperatorInputProps { + currency: CurrencyInfo + placeholder: string + label: string + field: ControllerRenderProps< + CondtionalPriceRuleSchemaType, + `prices.${number}.lte` | `prices.${number}.gte` + > +} + +const OperatorInput = ({ + field, + label, + currency, + placeholder, +}: OperatorInputProps) => { + const innerRef = useRef(null) + + const { value, onChange, ref, ...props } = field + + const refs = useCombinedRefs(innerRef, ref) + + const action = () => { + if (value === null) { + onChange("") + + requestAnimationFrame(() => { + innerRef.current?.focus() + }) + + return + } + + onChange(null) + } + + const isNull = value === null + + return ( + +
+
+ + {isNull ? : } + + {label} +
+ {!isNull && ( +
+ + + onChange(values?.value ? values?.value : "") + } + {...props} + /> + + +
+ )} +
+
+ ) +} + const ReadOnlyConditions = ({ index, control, diff --git a/packages/admin/dashboard/src/routes/locations/common/schema.ts b/packages/admin/dashboard/src/routes/locations/common/schema.ts index a1c41d63003fd..3548d75774e0f 100644 --- a/packages/admin/dashboard/src/routes/locations/common/schema.ts +++ b/packages/admin/dashboard/src/routes/locations/common/schema.ts @@ -1,3 +1,4 @@ +import { t } from "i18next" import { z } from "zod" import { castNumber } from "../../../lib/cast-number" @@ -10,6 +11,12 @@ export const ConditionalPriceSchema = z gt: z.number().nullish(), eq: z.number().nullish(), }) + .refine((data) => data.amount !== "", { + message: t( + "stockLocations.shippingOptions.conditionalPrices.errors.amountRequired" + ), + path: ["amount"], + }) .refine( (data) => { const hasEqLtGt = @@ -26,7 +33,9 @@ export const ConditionalPriceSchema = z ) }, { - message: "At least one of minimum or maximum cart total must be defined", + message: t( + "stockLocations.shippingOptions.conditionalPrices.errors.minOrMaxRequired" + ), path: ["gte"], } ) @@ -45,8 +54,9 @@ export const ConditionalPriceSchema = z return true }, { - message: - "Minimum cart total must be less than or equal to maximum cart total", + message: t( + "stockLocations.shippingOptions.conditionalPrices.errors.minGreaterThanMax" + ), path: ["gte"], } ) diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/location-service-zone-shipping-option-pricing.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/location-service-zone-shipping-option-pricing.tsx index 9cd94ca5687cd..066940c15f7cb 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/location-service-zone-shipping-option-pricing.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/location-service-zone-shipping-option-pricing.tsx @@ -14,10 +14,13 @@ export function LocationServiceZoneShippingOptionPricing() { }) } - const { shipping_option: shippingOption, isError, error } = - useShippingOption(so_id, { - fields: "*prices,*prices.price_rules", - }) + const { + shipping_option: shippingOption, + isError, + error, + } = useShippingOption(so_id, { + fields: "*prices,*prices.price_rules", + }) if (isError) { throw error From b0d3b244285aafd66ac47e0795353e69df0e508b Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:44:50 +0100 Subject: [PATCH 11/13] prevent hijacking cmd + enter event in DataGrid --- .../hooks/use-data-grid-keydown-event.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx index 86e2a7f890d66..5dd60852cd484 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx @@ -46,7 +46,7 @@ const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"] export const useDataGridKeydownEvent = < TData, - TFieldValues extends FieldValues, + TFieldValues extends FieldValues >({ containerRef, matrix, @@ -108,8 +108,8 @@ export const useDataGridKeydownEvent = < direction === "horizontal" ? setSingleRange : e.shiftKey - ? setRangeEnd - : setSingleRange + ? setRangeEnd + : setSingleRange if (!basis) { return @@ -385,6 +385,14 @@ export const useDataGridKeydownEvent = < return } + /** + * If the user is holding the meta key, we don't want to handle the event + * as it's used to submit forms. + */ + if (e.metaKey || e.ctrlKey) { + return + } + e.preventDefault() const type = matrix.getCellType(anchor) From 0920afd8263c34d047b06c46bf6f2274d0e9d1e6 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 4 Dec 2024 13:16:55 +0100 Subject: [PATCH 12/13] fix: broken close on edit prices modal --- .../hooks/use-data-grid-keydown-event.tsx | 8 -- .../conditional-price-form.tsx | 12 ++- .../create-shipping-options-form.tsx | 29 ++++--- .../edit-shipping-options-pricing-form.tsx | 84 +++++++++---------- 4 files changed, 68 insertions(+), 65 deletions(-) diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx index 5dd60852cd484..3e7da7b1a2a3e 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx @@ -385,14 +385,6 @@ export const useDataGridKeydownEvent = < return } - /** - * If the user is holding the meta key, we don't want to handle the event - * as it's used to submit forms. - */ - if (e.metaKey || e.ctrlKey) { - return - } - e.preventDefault() const type = matrix.getCellType(anchor) diff --git a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx index fb1f129bf908a..d58764defe46f 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx @@ -122,6 +122,8 @@ export const ConditionalPriceForm = ({ // Intercept the Cmd + Enter key to only save the inner form. const handleOnKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + console.log("Fired") + event.preventDefault() event.stopPropagation() @@ -131,11 +133,15 @@ export const ConditionalPriceForm = ({ return (
- + - -
+ +
diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx index 668545942d1ba..f14473f6ed5b5 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -253,22 +253,27 @@ export function CreateShippingOptionsForm({ className="flex h-full flex-col" onSubmit={handleSubmit} onKeyDown={(e) => { - // We want to continue to the next tab on enter instead of saving immediately - if (e.key === "Enter") { - e.preventDefault() + const isEnterKey = e.key === "Enter" + const isModifierPressed = e.metaKey || e.ctrlKey + const shouldContinueToPricing = + activeTab !== Tab.PRICING && !isCalculatedPriceType - if (e.metaKey || e.ctrlKey) { - if (activeTab !== Tab.PRICING) { - e.preventDefault() - e.stopPropagation() - onTabChange(Tab.PRICING) + if (!isEnterKey) { + return + } + e.preventDefault() - return - } + if (!isModifierPressed) { + return + } - handleSubmit() - } + if (shouldContinueToPricing) { + e.stopPropagation() + onTabChange(Tab.PRICING) + return } + + handleSubmit() }} > { - if (!open) { - setSelectedPrice(null) - } - }} - > - + - - + + + { + if (!open) { + setSelectedPrice(null) + } + }} > - - - +
)} - - -
- - - - -
-
- - - - + + + + +
+ + + + +
+
+ + ) } @@ -314,7 +314,7 @@ const findRuleValue = ( rules: HttpTypes.AdminShippingOptionPriceRule[], operator: string ) => { - const fallbackValue = ["eq", "gt", "lt"].includes(operator) ? undefined : "" + const fallbackValue = ["eq", "gt", "lt"].includes(operator) ? undefined : null return ( rules?.find((r) => r.attribute === "total" && r.operator === operator) From 848e2c877777bdbddb1df7f8266b49f05884a74d Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:06:25 +0100 Subject: [PATCH 13/13] update according to new API --- .../shipping-option-price-cell.tsx | 2 +- .../src/routes/locations/common/constants.ts | 3 ++ .../common/utils/price-rule-helpers.ts | 41 +++++++++++++++++ ...location-fulfillment-providers-section.tsx | 5 +- .../create-shipping-options-form.tsx | 29 ++---------- .../edit-shipping-options-pricing-form.tsx | 46 ++++++------------- 6 files changed, 65 insertions(+), 61 deletions(-) create mode 100644 packages/admin/dashboard/src/routes/locations/common/utils/price-rule-helpers.ts diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx index 0f2522ced32ac..856e4ad93957b 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx @@ -118,7 +118,7 @@ const OuterComponent = ({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (isAnchor && (e.metaKey || e.ctrlKey) && e.key === "b") { + if (isAnchor && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") { e.preventDefault() buttonRef.current?.click() } diff --git a/packages/admin/dashboard/src/routes/locations/common/constants.ts b/packages/admin/dashboard/src/routes/locations/common/constants.ts index 04fbf0c17f29e..3d5e2e30e1407 100644 --- a/packages/admin/dashboard/src/routes/locations/common/constants.ts +++ b/packages/admin/dashboard/src/routes/locations/common/constants.ts @@ -11,3 +11,6 @@ export enum ShippingOptionPriceType { export const GEO_ZONE_STACKED_MODAL_ID = "geo-zone" export const CONDITIONAL_PRICES_STACKED_MODAL_ID = "conditional-prices" + +export const ITEM_TOTAL_ATTRIBUTE = "item_total" +export const REGION_ID_ATTRIBUTE = "region_id" diff --git a/packages/admin/dashboard/src/routes/locations/common/utils/price-rule-helpers.ts b/packages/admin/dashboard/src/routes/locations/common/utils/price-rule-helpers.ts new file mode 100644 index 0000000000000..a47493f0a9aa1 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/utils/price-rule-helpers.ts @@ -0,0 +1,41 @@ +import { castNumber } from "../../../../lib/cast-number" +import { ITEM_TOTAL_ATTRIBUTE } from "../constants" + +const createPriceRule = ( + attribute: string, + operator: string, + value: string | number +) => { + const rule = { + attribute, + operator, + value: castNumber(value), + } + + return rule +} + +export const buildShippingOptionPriceRules = (rule: { + gte?: string | number | null + lte?: string | number | null + gt?: string | number | null + lt?: string | number | null + eq?: string | number | null +}) => { + const conditions = [ + { value: rule.gte, operator: "gte" }, + { value: rule.lte, operator: "lte" }, + { value: rule.gt, operator: "gt" }, + { value: rule.lt, operator: "lt" }, + { value: rule.eq, operator: "eq" }, + ] + + const conditionsWithValues = conditions.filter(({ value }) => value) as { + value: string | number + operator: string + }[] + + return conditionsWithValues.map(({ operator, value }) => + createPriceRule(ITEM_TOTAL_ATTRIBUTE, operator, value) + ) +} diff --git a/packages/admin/dashboard/src/routes/locations/location-detail/components/location-fulfillment-providers-section/location-fulfillment-providers-section.tsx b/packages/admin/dashboard/src/routes/locations/location-detail/components/location-fulfillment-providers-section/location-fulfillment-providers-section.tsx index cc68879a0de92..0a94bb2504dea 100644 --- a/packages/admin/dashboard/src/routes/locations/location-detail/components/location-fulfillment-providers-section/location-fulfillment-providers-section.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-detail/components/location-fulfillment-providers-section/location-fulfillment-providers-section.tsx @@ -1,6 +1,7 @@ import { HandTruck, PencilSquare } from "@medusajs/icons" import { HttpTypes } from "@medusajs/types" import { Container, Heading } from "@medusajs/ui" +import { Fragment } from "react" import { useTranslation } from "react-i18next" import { ActionMenu } from "../../../../../components/common/action-menu" @@ -50,7 +51,7 @@ function LocationsFulfillmentProvidersSection({
{fulfillment_providers?.map((fulfillmentProvider) => { return ( - <> + @@ -58,7 +59,7 @@ function LocationsFulfillmentProvidersSection({
{formatProvider(fulfillmentProvider.id)}
- +
) })}
diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx index f14473f6ed5b5..1ed449e6c8533 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -13,6 +13,7 @@ import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options" import { castNumber } from "../../../../../lib/cast-number" import { ShippingOptionPriceType } from "../../../common/constants" +import { buildShippingOptionPriceRules } from "../../../common/utils/price-rule-helpers" import { CreateShippingOptionDetailsForm } from "./create-shipping-option-details-form" import { CreateShippingOptionsPricesForm } from "./create-shipping-options-prices-form" import { @@ -62,30 +63,6 @@ export function CreateShippingOptionsForm({ const { mutateAsync, isPending: isLoading } = useCreateShippingOptions() - const createPriceRule = ( - attribute: string, - operator: string, - value: string | number - ) => ({ - attribute, - operator, - value: castNumber(value), - }) - - const buildRules = (rule: { - gte?: string | number | null - lte?: string | number | null - }) => { - const conditions = [ - { value: rule.gte, operator: "gte" }, - { value: rule.lte, operator: "lte" }, - ] - - return conditions - .filter(({ value }) => value) - .map(({ operator, value }) => createPriceRule("total", operator, value!)) - } - const handleSubmit = form.handleSubmit(async (data) => { const currencyPrices = Object.entries(data.currency_prices) .map(([code, value]) => { @@ -120,7 +97,7 @@ export function CreateShippingOptionsForm({ value?.map((rule) => ({ region_id: region_id, amount: castNumber(rule.amount), - rules: buildRules(rule), + rules: buildShippingOptionPriceRules(rule), })) || [] return prices?.filter(Boolean) @@ -133,7 +110,7 @@ export function CreateShippingOptionsForm({ value?.map((rule) => ({ currency_code, amount: castNumber(rule.amount), - rules: buildRules(rule), + rules: buildShippingOptionPriceRules(rule), })) || [] return prices?.filter(Boolean) diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx index b38c5b8acb383..8b2659b251b76 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-pricing/components/create-shipping-options-form/edit-shipping-options-pricing-form.tsx @@ -22,13 +22,18 @@ import { useStore } from "../../../../../hooks/api/store" import { castNumber } from "../../../../../lib/cast-number" import { ConditionalPriceForm } from "../../../common/components/conditional-price-form" import { ShippingOptionPriceProvider } from "../../../common/components/shipping-option-price-provider" -import { CONDITIONAL_PRICES_STACKED_MODAL_ID } from "../../../common/constants" +import { + CONDITIONAL_PRICES_STACKED_MODAL_ID, + ITEM_TOTAL_ATTRIBUTE, + REGION_ID_ATTRIBUTE, +} from "../../../common/constants" import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns" import { UpdateConditionalPrice, UpdateConditionalPriceSchema, } from "../../../common/schema" import { ConditionalPriceInfo } from "../../../common/types" +import { buildShippingOptionPriceRules } from "../../../common/utils/price-rule-helpers" type PriceRecord = { id?: string @@ -124,30 +129,6 @@ export function EditShippingOptionsPricingForm({ [currencies, regions] ) - const createPriceRule = ( - attribute: string, - operator: string, - value: string | number - ) => ({ - attribute, - operator, - value: castNumber(value), - }) - - const buildRules = (rule: UpdateConditionalPrice) => { - const conditions = [ - { value: rule.gte, operator: "gte" }, - { value: rule.lte, operator: "lte" }, - { value: rule.gt, operator: "gt" }, - { value: rule.lt, operator: "lt" }, - { value: rule.eq, operator: "eq" }, - ] - - return conditions - .filter(({ value }) => value) - .map(({ operator, value }) => createPriceRule("total", operator, value!)) - } - const handleSubmit = form.handleSubmit(async (data) => { const currencyPrices = Object.entries(data.currency_prices) .map(([code, value]) => { @@ -182,7 +163,7 @@ export function EditShippingOptionsPricingForm({ id: rule.id, currency_code, amount: castNumber(rule.amount), - rules: buildRules(rule), + rules: buildShippingOptionPriceRules(rule), })) ) @@ -212,7 +193,7 @@ export function EditShippingOptionsPricingForm({ id: rule.id, region_id, amount: castNumber(rule.amount), - rules: buildRules(rule), + rules: buildShippingOptionPriceRules(rule), })) ) @@ -317,8 +298,9 @@ const findRuleValue = ( const fallbackValue = ["eq", "gt", "lt"].includes(operator) ? undefined : null return ( - rules?.find((r) => r.attribute === "total" && r.operator === operator) - ?.value || fallbackValue + rules?.find( + (r) => r.attribute === ITEM_TOTAL_ATTRIBUTE && r.operator === operator + )?.value || fallbackValue ) } @@ -363,7 +345,7 @@ const getDefaultValues = (prices: HttpTypes.AdminShippingOptionPrice[]) => { return } - if (hasAttributes(price, ["total"], ["region_id"])) { + if (hasAttributes(price, [ITEM_TOTAL_ATTRIBUTE], [REGION_ID_ATTRIBUTE])) { const code = price.currency_code! if (!conditional_currency_prices[code]) { conditional_currency_prices[code] = [] @@ -372,13 +354,13 @@ const getDefaultValues = (prices: HttpTypes.AdminShippingOptionPrice[]) => { return } - if (hasAttributes(price, ["region_id"], ["total"])) { + if (hasAttributes(price, [REGION_ID_ATTRIBUTE], [ITEM_TOTAL_ATTRIBUTE])) { const regionId = price.price_rules[0].value region_prices[regionId] = price.amount return } - if (hasAttributes(price, ["region_id", "total"])) { + if (hasAttributes(price, [REGION_ID_ATTRIBUTE, ITEM_TOTAL_ATTRIBUTE])) { const regionId = price.price_rules[0].value if (!conditional_region_prices[regionId]) { conditional_region_prices[regionId] = []