From a1a1e0e789424546443ce195b95f652d081d7b3b Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:44:11 +0100 Subject: [PATCH] feat(dashboard): SO cart item total rules UI (#10386) --- .changeset/dry-cheetahs-wait.md | 6 + .../components/common/tax-badge/tax-badge.tsx | 12 +- .../components/data-grid-cell-container.tsx | 84 ++- .../data-grid/components/data-grid-root.tsx | 8 +- .../hooks/use-data-grid-keydown-event.tsx | 6 +- .../src/components/data-grid/types.ts | 1 + .../stacked-focus-modal.tsx | 20 +- .../src/i18n/translations/$schema.json | 147 +++- .../dashboard/src/i18n/translations/en.json | 33 + .../conditional-price-form.tsx | 696 ++++++++++++++++++ .../conditional-price-form/index.ts | 1 + .../shipping-option-price-cell/index.ts | 1 + .../shipping-option-price-cell.tsx | 261 +++++++ .../shipping-option-price-provider/index.ts | 2 + .../shipping-option-price-context.tsx | 10 + .../shipping-option-price-provider.tsx | 23 + .../use-shipping-option-price.tsx | 14 + .../src/routes/locations/common/constants.ts | 5 + .../use-shipping-option-price-columns.tsx | 123 +++- .../src/routes/locations/common/schema.ts | 90 +++ .../src/routes/locations/common/types.ts | 12 + ...custom-shipping-option-price-field-info.ts | 19 + .../common/utils/price-rule-helpers.ts | 41 ++ ...location-fulfillment-providers-section.tsx | 5 +- .../create-service-zone-form.tsx | 3 - .../create-shipping-option-details-form.tsx | 2 +- .../create-shipping-options-form.tsx | 95 ++- .../create-shipping-options-prices-form.tsx | 67 +- .../create-shipping-options-form/schema.ts | 21 + .../edit-shipping-options-pricing-form.tsx | 287 +++++--- ...n-service-zone-shipping-option-pricing.tsx | 11 +- .../http/shipping-option/admin/entities.ts | 9 +- .../http/shipping-option/admin/payloads.ts | 22 +- 33 files changed, 1947 insertions(+), 190 deletions(-) create mode 100644 .changeset/dry-cheetahs-wait.md 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 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/schema.ts 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 create mode 100644 packages/admin/dashboard/src/routes/locations/common/utils/price-rule-helpers.ts 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 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/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/hooks/use-data-grid-keydown-event.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx index 86e2a7f890d66..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 @@ -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 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..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,12 +13,20 @@ type StackedFocusModalProps = PropsWithChildren<{ * when multiple stacked modals are registered to the same parent modal. */ id: string + /** + * An optional callback that is called when the modal is opened or closed. + */ + onOpenChangeCallback?: (open: boolean) => void }> /** * A stacked modal that can be rendered above a parent modal. */ -export const Root = ({ id, children }: StackedFocusModalProps) => { +export const Root = ({ + id, + onOpenChangeCallback, + children, +}: StackedFocusModalProps) => { const { register, unregister, getIsOpen, setIsOpen } = useStackedModal() useEffect(() => { @@ -28,11 +36,13 @@ export const Root = ({ id, children }: StackedFocusModalProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + const handleOpenChange = (open: boolean) => { + setIsOpen(id, open) + onOpenChangeCallback?.(open) + } + return ( - setIsOpen(id, open)} - > + {children} ) diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index be9d4f399ab35..9e3667c732043 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -5544,6 +5544,144 @@ "required": ["action"], "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 + }, + "errors": { + "type": "object", + "properties": { + "amountRequired": { + "type": "string" + }, + "minOrMaxRequired": { + "type": "string" + }, + "minGreaterThanMax": { + "type": "string" + } + }, + "required": [ + "amountRequired", + "minOrMaxRequired", + "minGreaterThanMax" + ], + "additionalProperties": false + } + }, + "required": [ + "header", + "description", + "attributes", + "summaries", + "actions", + "rules", + "customRules", + "errors" + ], + "additionalProperties": false + }, "fields": { "type": "object", "properties": { @@ -5644,7 +5782,14 @@ "additionalProperties": false } }, - "required": ["create", "delete", "edit", "pricing", "fields"], + "required": [ + "create", + "delete", + "edit", + "pricing", + "conditionalPrices", + "fields" + ], "additionalProperties": false }, "serviceZones": { diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 4b46b2556e7d8..056492c9f1bc3 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1463,6 +1463,39 @@ "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" + }, + "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": { "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..d58764defe46f --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx @@ -0,0 +1,696 @@ +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, useRef, useState } from "react" +import { + Control, + ControllerRenderProps, + 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 { 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" +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)) { + console.log("Fired") + + 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} + /> + + +
+
+
+ ) + }} + /> + + { + return ( + + ) + }} + /> + + { + return ( + + ) + }} + /> + +
+
+ ) +} + +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, + 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/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..856e4ad93957b --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx @@ -0,0 +1,261 @@ +import { ArrowsPointingOut, CircleSliders } 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.toLowerCase() === "b") { + 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..8c80fc7632932 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-provider/shipping-option-price-context.tsx @@ -0,0 +1,10 @@ +import { createContext } from "react" +import { ConditionalPriceInfo } from "../../types" + +type ShippingOptionPriceContextType = { + onOpenConditionalPricesModal: (info: ConditionalPriceInfo) => void + onCloseConditionalPricesModal: () => 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..3d5e2e30e1407 100644 --- a/packages/admin/dashboard/src/routes/locations/common/constants.ts +++ b/packages/admin/dashboard/src/routes/locations/common/constants.ts @@ -9,3 +9,8 @@ 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/hooks/use-shipping-option-price-columns.tsx b/packages/admin/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx index 95d2c6096ee5e..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 @@ -1,11 +1,16 @@ 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 { FieldContext } from "../../../../components/data-grid/types" +import { ShippingOptionPriceCell } from "../components/shipping-option-price-cell" const columnHelper = createDataGridHelper() @@ -26,6 +31,8 @@ export const useShippingOptionPriceColumns = ({ return [ columnHelper.column({ id: "name", + name: t("fields.name"), + disableHiding: true, header: t("fields.name"), cell: (context) => { return ( @@ -51,3 +58,117 @@ export const useShippingOptionPriceColumns = ({ ] }, [t, currencies, regions, pricePreferences, name]) } + +type CreateDataGridPriceColumnsProps< + TData, + TFieldValues extends FieldValues, +> = { + currencies?: string[] + regions?: HttpTypes.AdminRegion[] + pricePreferences?: HttpTypes.AdminPricePreference[] + getFieldName: ( + context: FieldContext, + value: string + ) => FieldPath | null + t: TFunction +} + +export const createDataGridPriceColumns = < + TData, + TFieldValues extends FieldValues, +>({ + currencies, + regions, + pricePreferences, + 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) => { + return getFieldName(context, currency) + }, + type: "number", + header: () => ( +
+ + {translatedCurrencyName} + + +
+ ), + cell: (context) => { + 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) => { + return getFieldName(context, region.id) + }, + type: "number", + header: () => ( +
+ + {translatedRegionName} + + +
+ ), + cell: (context) => { + const currency = currencies?.find((c) => c === region.currency_code) + if (!currency) { + return null + } + + return ( + + ) + }, + }) + }) ?? []), + ] +} diff --git a/packages/admin/dashboard/src/routes/locations/common/schema.ts b/packages/admin/dashboard/src/routes/locations/common/schema.ts new file mode 100644 index 0000000000000..3548d75774e0f --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/schema.ts @@ -0,0 +1,90 @@ +import { t } from "i18next" +import { z } from "zod" +import { castNumber } from "../../../lib/cast-number" + +export const ConditionalPriceSchema = z + .object({ + amount: z.union([z.string(), z.number()]), + gte: z.union([z.string(), z.number()]).nullish(), + lte: z.union([z.string(), z.number()]).nullish(), + lt: z.number().nullish(), + 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 = + data.eq !== undefined || data.lt !== undefined || data.gt !== undefined + + // The rule has operators that can only be managed using the API, so we should not validate this. + if (hasEqLtGt) { + return true + } + + return ( + (data.gte !== undefined && data.gte !== "") || + (data.lte !== undefined && data.lte !== "") + ) + }, + { + message: t( + "stockLocations.shippingOptions.conditionalPrices.errors.minOrMaxRequired" + ), + path: ["gte"], + } + ) + .refine( + (data) => { + if ( + data.gte != null && + data.gte !== "" && + data.lte != null && + data.lte !== "" + ) { + const gte = castNumber(data.gte) + const lte = castNumber(data.lte) + return gte <= lte + } + return true + }, + { + message: t( + "stockLocations.shippingOptions.conditionalPrices.errors.minGreaterThanMax" + ), + path: ["gte"], + } + ) + +export type ConditionalPrice = z.infer + +export const UpdateConditionalPriceSchema = ConditionalPriceSchema.and( + z.object({ + id: z.string().optional(), + }) +) + +export type UpdateConditionalPrice = z.infer< + typeof UpdateConditionalPriceSchema +> + +export const CondtionalPriceRuleSchema = z.object({ + prices: z.array(ConditionalPriceSchema), +}) + +export type CondtionalPriceRuleSchemaType = z.infer< + typeof CondtionalPriceRuleSchema +> + +export const UpdateConditionalPriceRuleSchema = z.object({ + prices: z.array(UpdateConditionalPriceSchema), +}) + +export type UpdateConditionalPriceRuleSchemaType = z.infer< + typeof UpdateConditionalPriceRuleSchema +> 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..354c72b099d31 --- /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 = + | `conditional_region_prices.${string}` + | `conditional_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..1b2232fb8b5cc --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/common/utils/get-custom-shipping-option-price-field-info.ts @@ -0,0 +1,19 @@ +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" + ? "conditional_region_prices" + : "conditional_currency_prices" + + const name = field.replace( + prefix, + customPrefix + ) as ConditionalShippingOptionPriceAccessor + + return name +} 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-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 1cf638d2248ea..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 { @@ -51,6 +52,8 @@ export function CreateShippingOptionsForm({ provider_id: "", region_prices: {}, currency_prices: {}, + conditional_region_prices: {}, + conditional_currency_prices: {}, }, resolver: zodResolver(CreateShippingOptionSchema), }) @@ -63,7 +66,7 @@ export function CreateShippingOptionsForm({ const handleSubmit = form.handleSubmit(async (data) => { const currencyPrices = Object.entries(data.currency_prices) .map(([code, value]) => { - if (value === "" || value === undefined) { + if (!value) { return undefined } @@ -72,11 +75,11 @@ export function CreateShippingOptionsForm({ amount: castNumber(value), } }) - .filter((o) => !!o) as { currency_code: string; amount: number }[] + .filter((p): p is { currency_code: string; amount: number } => !!p) const regionPrices = Object.entries(data.region_prices) .map(([region_id, value]) => { - if (value === "" || value === undefined) { + if (!value) { return undefined } @@ -85,7 +88,40 @@ export function CreateShippingOptionsForm({ amount: castNumber(value), } }) - .filter((o) => !!o) as { region_id: string; amount: number }[] + .filter((p): p is { region_id: string; amount: number } => !!p) + + const conditionalRegionPrices = Object.entries( + data.conditional_region_prices + ).flatMap(([region_id, value]) => { + const prices: HttpTypes.AdminCreateShippingOptionPriceWithRegion[] = + value?.map((rule) => ({ + region_id: region_id, + amount: castNumber(rule.amount), + rules: buildShippingOptionPriceRules(rule), + })) || [] + + return prices?.filter(Boolean) + }) + + const conditionalCurrencyPrices = Object.entries( + data.conditional_currency_prices + ).flatMap(([currency_code, value]) => { + const prices: HttpTypes.AdminCreateShippingOptionPriceWithCurrency[] = + value?.map((rule) => ({ + currency_code, + amount: castNumber(rule.amount), + rules: buildShippingOptionPriceRules(rule), + })) || [] + + return prices?.filter(Boolean) + }) + + const allPrices = [ + ...currencyPrices, + ...conditionalCurrencyPrices, + ...regionPrices, + ...conditionalRegionPrices, + ] await mutateAsync( { @@ -94,17 +130,17 @@ export function CreateShippingOptionsForm({ service_zone_id: zone.id, shipping_profile_id: data.shipping_profile_id, provider_id: data.provider_id, - prices: [...currencyPrices, ...regionPrices], + prices: allPrices, rules: [ { // eslint-disable-next-line - value: isReturn ? '"true"' : '"false"', // we want JSONB saved as string + value: isReturn ? '"true"' : '"false"', attribute: "is_return", operator: "eq", }, { // eslint-disable-next-line - value: data.enabled_in_store ? '"true"' : '"false"', // we want JSONB saved as string + value: data.enabled_in_store ? '"true"' : '"false"', attribute: "enabled_in_store", operator: "eq", }, @@ -123,12 +159,9 @@ export function CreateShippingOptionsForm({ `stockLocations.shippingOptions.create.${ isReturn ? "returns" : "shipping" }.successToast`, - { - name: shipping_option.name, - } + { name: shipping_option.name } ) ) - handleSuccess(`/settings/locations/${locationId}`) }, onError: (e) => { @@ -193,12 +226,38 @@ export function CreateShippingOptionsForm({ return ( - onTabChange(tab as Tab)} + { + const isEnterKey = e.key === "Enter" + const isModifierPressed = e.metaKey || e.ctrlKey + const shouldContinueToPricing = + activeTab !== Tab.PRICING && !isCalculatedPriceType + + if (!isEnterKey) { + return + } + e.preventDefault() + + if (!isModifierPressed) { + return + } + + if (shouldContinueToPricing) { + e.stopPropagation() + onTabChange(Tab.PRICING) + return + } + + handleSubmit() + }} > - + onTabChange(tab as Tab)} + > - - + + ) } 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..6d00a42c422df 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,20 @@ -import { useMemo } from "react" -import { UseFormReturn } from "react-hook-form" +import { useMemo, useState } from "react" +import { UseFormReturn, useWatch } from "react-hook-form" import { DataGrid } from "../../../../../components/data-grid" -import { useRouteModal } from "../../../../../components/modals" +import { + StackedFocusModal, + 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 { 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 { ConditionalPriceInfo } from "../../../common/types" import { CreateShippingOptionSchema } from "./schema" type PricingPricesFormProps = { @@ -16,6 +24,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, @@ -42,7 +64,10 @@ export const CreateShippingOptionsPricesForm = ({ const { setCloseOnEscape } = useRouteModal() + const name = useWatch({ control: form.control, name: "name" }) + const columns = useShippingOptionPriceColumns({ + name, currencies, regions, pricePreferences, @@ -64,14 +89,32 @@ export const CreateShippingOptionsPricesForm = ({ } return ( -
- setCloseOnEscape(!editing)} - /> -
+ { + if (!open) { + setSelectedPrice(null) + } + }} + > + +
+ 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 a9553e504ee1a..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,5 +1,6 @@ import { z } from "zod" import { ShippingOptionPriceType } from "../../../common/constants" +import { ConditionalPriceSchema } from "../../../common/schema" export type CreateShippingOptionSchema = z.infer< typeof CreateShippingOptionSchema @@ -13,9 +14,29 @@ export const CreateShippingOptionDetailsSchema = z.object({ provider_id: z.string().min(1), }) +export const ShippingOptionConditionalPriceSchema = z.object({ + conditional_region_prices: z.record( + z.string(), + z.array(ConditionalPriceSchema).optional() + ), + conditional_currency_prices: z.record( + z.string(), + z.array(ConditionalPriceSchema).optional() + ), +}) + +export type ShippingOptionConditionalPriceSchemaType = z.infer< + typeof ShippingOptionConditionalPriceSchema +> + export const CreateShippingOptionSchema = z .object({ region_prices: z.record(z.string(), z.string().optional()), currency_prices: z.record(z.string(), z.string().optional()), }) .merge(CreateShippingOptionDetailsSchema) + .merge(ShippingOptionConditionalPriceSchema) + +export type CreateShippingOptionSchemaType = z.infer< + typeof CreateShippingOptionSchema +> 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..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 @@ -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,35 +20,20 @@ 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 { ConditionalPriceForm } from "../../../common/components/conditional-price-form" +import { ShippingOptionPriceProvider } from "../../../common/components/shipping-option-price-provider" +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" - -const getInitialCurrencyPrices = ( - prices: HttpTypes.AdminShippingOptionPrice[] -) => { - 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 -} +import { + UpdateConditionalPrice, + UpdateConditionalPriceSchema, +} from "../../../common/schema" +import { ConditionalPriceInfo } from "../../../common/types" +import { buildShippingOptionPriceRules } from "../../../common/utils/price-rule-helpers" type PriceRecord = { id?: string @@ -64,6 +51,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 = { @@ -75,12 +70,22 @@ 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: { - region_prices: getInitialRegionPrices(shippingOption.prices), - currency_prices: getInitialCurrencyPrices(shippingOption.prices), - }, + defaultValues: getDefaultValues(shippingOption.prices), resolver: zodResolver(EditShippingOptionPricingSchema), }) @@ -127,80 +132,80 @@ export function EditShippingOptionsPricingForm({ 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: buildShippingOptionPriceRules(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: buildShippingOptionPriceRules(rule), + })) + ) + + const allPrices = [ + ...currencyPrices, + ...conditionalCurrencyPrices, + ...regionPrices, + ...conditionalRegionPrices, + ] await mutateAsync( - { - prices: [...currencyPrices, ...regionPrices], - }, + { prices: allPrices }, { onSuccess: () => { toast.success(t("general.success")) @@ -233,15 +238,35 @@ export function EditShippingOptionsPricingForm({ -
- setCloseOnEscape(!editing)} - /> -
+ { + if (!open) { + setSelectedPrice(null) + } + }} + > + +
+ setCloseOnEscape(!editing)} + disableInteractions={getIsOpen( + CONDITIONAL_PRICES_STACKED_MODAL_ID + )} + /> +
+ {selectedPrice && ( + + )} +
+
@@ -265,3 +290,89 @@ export function EditShippingOptionsPricingForm({ ) } + +const findRuleValue = ( + rules: HttpTypes.AdminShippingOptionPriceRule[], + operator: string +) => { + const fallbackValue = ["eq", "gt", "lt"].includes(operator) ? undefined : null + + return ( + rules?.find( + (r) => r.attribute === ITEM_TOTAL_ATTRIBUTE && 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, [ITEM_TOTAL_ATTRIBUTE], [REGION_ID_ATTRIBUTE])) { + 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_ATTRIBUTE], [ITEM_TOTAL_ATTRIBUTE])) { + const regionId = price.price_rules[0].value + region_prices[regionId] = price.amount + return + } + + 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] = [] + } + conditional_region_prices[regionId].push(mapToConditionalPrice(price)) + } + }) + + return { + currency_prices, + conditional_currency_prices, + region_prices, + conditional_region_prices, + } +} 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 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