feat(dashboard): SO cart item total rules UI (#10386)
This commit is contained in:
1 parent
9e797dc3d2
commit
a1a1e0e789
33 files changed
+1881
-124
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/dashboard": patch
|
||||||
|
"@medusajs/types": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(dashboard,types): Add UI to manage conditional SO prices
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BuildingTax } from "@medusajs/icons"
|
import { TaxExclusive, TaxInclusive } from "@medusajs/icons"
|
||||||
import { Tooltip, clx } from "@medusajs/ui"
|
import { Tooltip } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
type IncludesTaxTooltipProps = {
|
type IncludesTaxTooltipProps = {
|
||||||
@@ -20,9 +20,11 @@ export const IncludesTaxTooltip = ({
|
|||||||
: t("general.excludesTaxTooltip")
|
: t("general.excludesTaxTooltip")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<BuildingTax
|
{includesTax ? (
|
||||||
className={clx("shrink-0", { "text-ui-fg-muted": !includesTax })}
|
<TaxInclusive className="text-ui-fg-muted shrink-0" />
|
||||||
/>
|
) : (
|
||||||
|
<TaxExclusive className="text-ui-fg-muted shrink-0" />
|
||||||
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+5
-1
@@ -19,11 +19,13 @@ export const DataGridCellContainer = ({
|
|||||||
children,
|
children,
|
||||||
errors,
|
errors,
|
||||||
rowErrors,
|
rowErrors,
|
||||||
|
outerComponent,
|
||||||
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
|
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
|
||||||
const error = get(errors, field)
|
const error = get(errors, field)
|
||||||
const hasError = !!error
|
const hasError = !!error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="group/container relative size-full">
|
||||||
<div
|
<div
|
||||||
className={clx(
|
className={clx(
|
||||||
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
|
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
|
||||||
@@ -62,10 +64,12 @@ export const DataGridCellContainer = ({
|
|||||||
<div
|
<div
|
||||||
{...overlayProps}
|
{...overlayProps}
|
||||||
data-cell-overlay="true"
|
data-cell-overlay="true"
|
||||||
className="absolute inset-0 z-[2] size-full"
|
className="absolute inset-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{outerComponent}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface DataGridRootProps<
|
|||||||
state: UseFormReturn<TFieldValues>
|
state: UseFormReturn<TFieldValues>
|
||||||
getSubRows?: (row: TData) => TData[] | undefined
|
getSubRows?: (row: TData) => TData[] | undefined
|
||||||
onEditingChange?: (isEditing: boolean) => void
|
onEditingChange?: (isEditing: boolean) => void
|
||||||
|
disableInteractions?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 40
|
const ROW_HEIGHT = 40
|
||||||
@@ -102,6 +103,7 @@ export const DataGridRoot = <
|
|||||||
state,
|
state,
|
||||||
getSubRows,
|
getSubRows,
|
||||||
onEditingChange,
|
onEditingChange,
|
||||||
|
disableInteractions,
|
||||||
}: DataGridRootProps<TData, TFieldValues>) => {
|
}: DataGridRootProps<TData, TFieldValues>) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -114,7 +116,9 @@ export const DataGridRoot = <
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = state
|
} = state
|
||||||
|
|
||||||
const [trapActive, setTrapActive] = useState(true)
|
const [internalTrapActive, setTrapActive] = useState(true)
|
||||||
|
|
||||||
|
const trapActive = !disableInteractions && internalTrapActive
|
||||||
|
|
||||||
const [anchor, setAnchor] = useState<DataGridCoordinates | null>(null)
|
const [anchor, setAnchor] = useState<DataGridCoordinates | null>(null)
|
||||||
const [rangeEnd, setRangeEnd] = useState<DataGridCoordinates | null>(null)
|
const [rangeEnd, setRangeEnd] = useState<DataGridCoordinates | null>(null)
|
||||||
@@ -533,7 +537,7 @@ export const DataGridRoot = <
|
|||||||
queryTool?.getContainer(anchor)?.focus()
|
queryTool?.getContainer(anchor)?.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [anchor, trapActive, queryTool])
|
}, [anchor, trapActive, setSingleRange, scrollToCoordinates, queryTool])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridContext.Provider value={values}>
|
<DataGridContext.Provider value={values}>
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"]
|
|||||||
|
|
||||||
export const useDataGridKeydownEvent = <
|
export const useDataGridKeydownEvent = <
|
||||||
TData,
|
TData,
|
||||||
TFieldValues extends FieldValues,
|
TFieldValues extends FieldValues
|
||||||
>({
|
>({
|
||||||
containerRef,
|
containerRef,
|
||||||
matrix,
|
matrix,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface DataGridCellContainerProps extends PropsWithChildren<{}> {
|
|||||||
isDragSelected: boolean
|
isDragSelected: boolean
|
||||||
placeholder?: ReactNode
|
placeholder?: ReactNode
|
||||||
showOverlay: boolean
|
showOverlay: boolean
|
||||||
|
outerComponent?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataGridCellSnapshot<
|
export type DataGridCellSnapshot<
|
||||||
|
|||||||
+15
-5
@@ -13,12 +13,20 @@ type StackedFocusModalProps = PropsWithChildren<{
|
|||||||
* when multiple stacked modals are registered to the same parent modal.
|
* when multiple stacked modals are registered to the same parent modal.
|
||||||
*/
|
*/
|
||||||
id: string
|
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.
|
* 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()
|
const { register, unregister, getIsOpen, setIsOpen } = useStackedModal()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,11 +36,13 @@ export const Root = ({ id, children }: StackedFocusModalProps) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setIsOpen(id, open)
|
||||||
|
onOpenChangeCallback?.(open)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusModal
|
<FocusModal open={getIsOpen(id)} onOpenChange={handleOpenChange}>
|
||||||
open={getIsOpen(id)}
|
|
||||||
onOpenChange={(open) => setIsOpen(id, open)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</FocusModal>
|
</FocusModal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5544,6 +5544,144 @@
|
|||||||
"required": ["action"],
|
"required": ["action"],
|
||||||
"additionalProperties": false
|
"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": {
|
"fields": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5644,7 +5782,14 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["create", "delete", "edit", "pricing", "fields"],
|
"required": [
|
||||||
|
"create",
|
||||||
|
"delete",
|
||||||
|
"edit",
|
||||||
|
"pricing",
|
||||||
|
"conditionalPrices",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"serviceZones": {
|
"serviceZones": {
|
||||||
|
|||||||
@@ -1463,6 +1463,39 @@
|
|||||||
"pricing": {
|
"pricing": {
|
||||||
"action": "Edit prices"
|
"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}}</0> is between <1>{{gte}}</1> and <2>{{lte}}</2>",
|
||||||
|
"greaterThan": "If <0>{{attribute}}</0> ≥ <1>{{gte}}</1>",
|
||||||
|
"lessThan": "If <0>{{attribute}}</0> ≤ <1>{{lte}}</1>"
|
||||||
|
},
|
||||||
|
"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": {
|
"fields": {
|
||||||
"count": {
|
"count": {
|
||||||
"shipping_one": "{{count}} shipping option",
|
"shipping_one": "{{count}} shipping option",
|
||||||
|
|||||||
+696
@@ -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<CreateShippingOptionSchemaType>()
|
||||||
|
const { onCloseConditionalPricesModal } = useShippingOptionPrice()
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string[]>([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<HTMLFormElement>) => {
|
||||||
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||||
|
console.log("Fired")
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
handleOnSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...conditionalPriceForm}>
|
||||||
|
<KeyboundForm
|
||||||
|
onSubmit={handleOnSubmit}
|
||||||
|
onKeyDown={handleOnKeyDown}
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
>
|
||||||
|
<StackedFocusModal.Content>
|
||||||
|
<StackedFocusModal.Header />
|
||||||
|
<StackedFocusModal.Body className="size-full overflow-hidden">
|
||||||
|
<div className="flex size-full flex-1 flex-col items-center overflow-y-auto">
|
||||||
|
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-6 py-16">
|
||||||
|
<div className="flex w-full flex-col gap-y-6">
|
||||||
|
<div>
|
||||||
|
<StackedFocusModal.Title asChild>
|
||||||
|
<Heading>
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.header",
|
||||||
|
{
|
||||||
|
name: header,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
|
</StackedFocusModal.Title>
|
||||||
|
<StackedFocusModal.Description asChild>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.description"
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</StackedFocusModal.Description>
|
||||||
|
</div>
|
||||||
|
<ConditionalPriceList value={value} onValueChange={setValue}>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<ConditionalPriceItem
|
||||||
|
key={field.id}
|
||||||
|
index={index}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
currency={currency}
|
||||||
|
control={conditionalPriceForm.control}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ConditionalPriceList>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.actions.addPrice"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StackedFocusModal.Body>
|
||||||
|
<StackedFocusModal.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<StackedFocusModal.Close asChild>
|
||||||
|
<Button variant="secondary" size="small" type="button">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</StackedFocusModal.Close>
|
||||||
|
<Button size="small" type="button" onClick={handleOnSubmit}>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StackedFocusModal.Footer>
|
||||||
|
</StackedFocusModal.Content>
|
||||||
|
</KeyboundForm>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConditionalPriceListProps {
|
||||||
|
children?: ReactNode
|
||||||
|
value: string[]
|
||||||
|
onValueChange: (value: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConditionalPriceList = ({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: ConditionalPriceListProps) => {
|
||||||
|
return (
|
||||||
|
<Accordion.Root
|
||||||
|
type="multiple"
|
||||||
|
defaultValue={[getRuleValue(0)]}
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
className="flex flex-col gap-y-3"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Accordion.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConditionalPriceItemProps {
|
||||||
|
index: number
|
||||||
|
currency: CurrencyInfo
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
control: Control<CondtionalPriceRuleSchemaType>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConditionalPriceItem = ({
|
||||||
|
index,
|
||||||
|
currency,
|
||||||
|
onRemove,
|
||||||
|
control,
|
||||||
|
}: ConditionalPriceItemProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item
|
||||||
|
value={getRuleValue(index)}
|
||||||
|
className="bg-ui-bg-component shadow-elevation-card-rest rounded-lg"
|
||||||
|
>
|
||||||
|
<Accordion.Trigger asChild>
|
||||||
|
<div className="group/trigger flex w-full cursor-pointer items-start justify-between gap-x-2 p-3">
|
||||||
|
<div className="flex flex-1 flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex h-7 items-center">
|
||||||
|
<AmountDisplay
|
||||||
|
index={index}
|
||||||
|
currency={currency}
|
||||||
|
control={control}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-7 items-center">
|
||||||
|
<ConditionDisplay
|
||||||
|
index={index}
|
||||||
|
currency={currency}
|
||||||
|
control={control}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
variant="transparent"
|
||||||
|
className="text-ui-fg-muted hover:text-ui-fg-subtle focus-visible:text-ui-fg-subtle"
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
variant="transparent"
|
||||||
|
className="text-ui-fg-muted hover:text-ui-fg-subtle focus-visible:text-ui-fg-subtle"
|
||||||
|
>
|
||||||
|
<TriangleDownMini className="transition-transform group-data-[state=open]/trigger:rotate-180" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
<Accordion.Content className="text-ui-fg-subtle">
|
||||||
|
<Divider variant="dashed" />
|
||||||
|
<Form.Field
|
||||||
|
control={control}
|
||||||
|
name={`prices.${index}.amount`}
|
||||||
|
render={({ field: { value, onChange, ...props } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<div className="grid grid-cols-2 items-start gap-x-2 p-3">
|
||||||
|
<div className="flex h-8 items-center">
|
||||||
|
<Form.Label>
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.rules.amount"
|
||||||
|
)}
|
||||||
|
</Form.Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
<Form.Control>
|
||||||
|
<CurrencyInput
|
||||||
|
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover focus-visible:bg-ui-bg-field-component-hover"
|
||||||
|
placeholder={formatValue({
|
||||||
|
value: "0",
|
||||||
|
decimalScale: currency.decimal_digits,
|
||||||
|
})}
|
||||||
|
decimalScale={currency.decimal_digits}
|
||||||
|
symbol={currency.symbol_native}
|
||||||
|
code={currency.code}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(_value, _name, values) =>
|
||||||
|
onChange(values?.value ? values?.value : "")
|
||||||
|
}
|
||||||
|
autoFocus
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Divider variant="dashed" />
|
||||||
|
<Form.Field
|
||||||
|
control={control}
|
||||||
|
name={`prices.${index}.gte`}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<OperatorInput
|
||||||
|
field={field}
|
||||||
|
label={t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.rules.gte"
|
||||||
|
)}
|
||||||
|
currency={currency}
|
||||||
|
placeholder="1000"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Divider variant="dashed" />
|
||||||
|
<Form.Field
|
||||||
|
control={control}
|
||||||
|
name={`prices.${index}.lte`}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<OperatorInput
|
||||||
|
field={field}
|
||||||
|
label={t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.rules.lte"
|
||||||
|
)}
|
||||||
|
currency={currency}
|
||||||
|
placeholder="1000"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReadOnlyConditions
|
||||||
|
index={index}
|
||||||
|
control={control}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLInputElement>(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 (
|
||||||
|
<Form.Item>
|
||||||
|
<div className="grid grid-cols-2 items-start gap-x-2 p-3">
|
||||||
|
<div className="flex h-8 items-center gap-x-1">
|
||||||
|
<IconButton size="2xsmall" variant="transparent" onClick={action}>
|
||||||
|
{isNull ? <Plus /> : <XMark />}
|
||||||
|
</IconButton>
|
||||||
|
<Form.Label>{label}</Form.Label>
|
||||||
|
</div>
|
||||||
|
{!isNull && (
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
<Form.Control>
|
||||||
|
<CurrencyInput
|
||||||
|
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover focus-visible:bg-ui-bg-field-component-hover"
|
||||||
|
placeholder={formatValue({
|
||||||
|
value: placeholder,
|
||||||
|
decimalScale: currency.decimal_digits,
|
||||||
|
})}
|
||||||
|
decimalScale={currency.decimal_digits}
|
||||||
|
symbol={currency.symbol_native}
|
||||||
|
code={currency.code}
|
||||||
|
value={value}
|
||||||
|
ref={refs}
|
||||||
|
onValueChange={(_value, _name, values) =>
|
||||||
|
onChange(values?.value ? values?.value : "")
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadOnlyConditions = ({
|
||||||
|
index,
|
||||||
|
control,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
index: number
|
||||||
|
control: Control<CondtionalPriceRuleSchemaType>
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<Divider variant="dashed" />
|
||||||
|
<div className="flex items-center gap-x-1 px-3 pt-3">
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.customRules.label"
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Tooltip
|
||||||
|
content={t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.customRules.tooltip"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<InformationCircleSolid className="text-ui-fg-muted" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{item.eq != null && (
|
||||||
|
<div className="grid grid-cols-2 items-start gap-x-2 p-3">
|
||||||
|
<div className="flex h-8 items-center">
|
||||||
|
<Label weight="plus" size="small">
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.customRules.eq"
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<CurrencyInput
|
||||||
|
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover focus-visible:bg-ui-bg-field-component-hover"
|
||||||
|
symbol={currency.symbol_native}
|
||||||
|
code={currency.code}
|
||||||
|
value={item.eq}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.gt != null && (
|
||||||
|
<Fragment>
|
||||||
|
<Divider variant="dashed" />
|
||||||
|
<div className="grid grid-cols-2 items-start gap-x-2 p-3">
|
||||||
|
<div className="flex h-8 items-center">
|
||||||
|
<Label weight="plus" size="small">
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.customRules.gt"
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<CurrencyInput
|
||||||
|
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover focus-visible:bg-ui-bg-field-component-hover"
|
||||||
|
symbol={currency.symbol_native}
|
||||||
|
code={currency.code}
|
||||||
|
value={item.gt}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
{item.lt != null && (
|
||||||
|
<Fragment>
|
||||||
|
<Divider variant="dashed" />
|
||||||
|
<div className="grid grid-cols-2 items-start gap-x-2 p-3">
|
||||||
|
<div className="flex h-8 items-center">
|
||||||
|
<Label weight="plus" size="small">
|
||||||
|
{t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.customRules.lt"
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<CurrencyInput
|
||||||
|
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover focus-visible:bg-ui-bg-field-component-hover"
|
||||||
|
symbol={currency.symbol_native}
|
||||||
|
code={currency.code}
|
||||||
|
value={item.lt}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AmountDisplay = ({
|
||||||
|
index,
|
||||||
|
currency,
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
index: number
|
||||||
|
currency: CurrencyInfo
|
||||||
|
control: Control<CondtionalPriceRuleSchemaType>
|
||||||
|
}) => {
|
||||||
|
const amount = useWatch({
|
||||||
|
control,
|
||||||
|
name: `prices.${index}.amount`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (amount === "" || amount === undefined) {
|
||||||
|
return (
|
||||||
|
<Text size="small" weight="plus">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const castAmount = castNumber(amount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="small" weight="plus">
|
||||||
|
{getLocaleAmount(castAmount, currency.code)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConditionContainer = ({ children }: { children: ReactNode }) => (
|
||||||
|
<div className="text-ui-fg-subtle txt-small flex flex-wrap items-center gap-1.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ConditionDisplay = ({
|
||||||
|
index,
|
||||||
|
currency,
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
index: number
|
||||||
|
currency: CurrencyInfo
|
||||||
|
control: Control<CondtionalPriceRuleSchemaType>
|
||||||
|
}) => {
|
||||||
|
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 (
|
||||||
|
<ConditionContainer>
|
||||||
|
<Trans
|
||||||
|
i18n={i18n}
|
||||||
|
i18nKey="stockLocations.shippingOptions.conditionalPrices.summaries.greaterThan"
|
||||||
|
components={[
|
||||||
|
<Badge size="2xsmall" key="attribute" />,
|
||||||
|
<Badge size="2xsmall" key="gte" />,
|
||||||
|
]}
|
||||||
|
values={{
|
||||||
|
attribute: t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.attributes.cartItemTotal"
|
||||||
|
),
|
||||||
|
gte: getLocaleAmount(castGte, currency.code),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ConditionContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!castGte && castLte) {
|
||||||
|
return (
|
||||||
|
<ConditionContainer>
|
||||||
|
<Trans
|
||||||
|
i18n={i18n}
|
||||||
|
i18nKey="stockLocations.shippingOptions.conditionalPrices.summaries.lessThan"
|
||||||
|
components={[
|
||||||
|
<Badge size="2xsmall" key="attribute" />,
|
||||||
|
<Badge size="2xsmall" key="lte" />,
|
||||||
|
]}
|
||||||
|
values={{
|
||||||
|
attribute: t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.attributes.cartItemTotal"
|
||||||
|
),
|
||||||
|
lte: getLocaleAmount(castLte, currency.code),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ConditionContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (castGte && castLte) {
|
||||||
|
return (
|
||||||
|
<ConditionContainer>
|
||||||
|
<Trans
|
||||||
|
i18n={i18n}
|
||||||
|
i18nKey="stockLocations.shippingOptions.conditionalPrices.summaries.range"
|
||||||
|
components={[
|
||||||
|
<Badge size="2xsmall" key="attribute" />,
|
||||||
|
<Badge size="2xsmall" key="gte" />,
|
||||||
|
<Badge size="2xsmall" key="lte" />,
|
||||||
|
]}
|
||||||
|
values={{
|
||||||
|
attribute: t(
|
||||||
|
"stockLocations.shippingOptions.conditionalPrices.attributes.cartItemTotal"
|
||||||
|
),
|
||||||
|
gte: getLocaleAmount(castGte, currency.code),
|
||||||
|
lte: getLocaleAmount(castLte, currency.code),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ConditionContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderCondition()
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./conditional-price-form"
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./shipping-option-price-cell"
|
||||||
+261
@@ -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<TData, TValue = any>
|
||||||
|
extends DataGridCellProps<TData, TValue> {
|
||||||
|
code: string
|
||||||
|
header: string
|
||||||
|
type: "currency" | "region"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShippingOptionPriceCell = <TData, TValue = any>({
|
||||||
|
context,
|
||||||
|
code,
|
||||||
|
header,
|
||||||
|
type,
|
||||||
|
}: ShippingOptionPriceCellProps<TData, TValue>) => {
|
||||||
|
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 (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={field}
|
||||||
|
render={({ field: props }) => {
|
||||||
|
return (
|
||||||
|
<DataGridCellContainer
|
||||||
|
{...container}
|
||||||
|
{...errorProps}
|
||||||
|
outerComponent={
|
||||||
|
<OuterComponent
|
||||||
|
header={header}
|
||||||
|
isAnchor={isAnchor}
|
||||||
|
field={field}
|
||||||
|
control={control}
|
||||||
|
symbolWidth={symbolWidth}
|
||||||
|
type={type}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Inner
|
||||||
|
field={props}
|
||||||
|
inputProps={input}
|
||||||
|
currencyInfo={currency}
|
||||||
|
onMeasureSymbol={measuredRef}
|
||||||
|
/>
|
||||||
|
</DataGridCellContainer>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const OuterComponent = ({
|
||||||
|
isAnchor,
|
||||||
|
header,
|
||||||
|
field,
|
||||||
|
control,
|
||||||
|
symbolWidth,
|
||||||
|
type,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
isAnchor: boolean
|
||||||
|
header: string
|
||||||
|
field: string
|
||||||
|
control: Control<any>
|
||||||
|
symbolWidth: number
|
||||||
|
type: "currency" | "region"
|
||||||
|
currency: CurrencyInfo
|
||||||
|
}) => {
|
||||||
|
const { onOpenConditionalPricesModal } = useShippingOptionPrice()
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 z-[2] flex w-fit items-center justify-center"
|
||||||
|
style={{
|
||||||
|
left: symbolWidth ? `${symbolWidth + 16 + 4}px` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{price?.length > 0 && !isAnchor && (
|
||||||
|
<div className="flex size-[15px] items-center justify-center group-hover/container:hidden">
|
||||||
|
<CircleSliders className="text-ui-fg-interactive" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
className={clx(
|
||||||
|
"hover:text-ui-fg-subtle text-ui-fg-muted transition-fg hidden size-[15px] items-center justify-center rounded-md bg-transparent group-hover/container:flex",
|
||||||
|
{ flex: isAnchor }
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
onOpenConditionalPricesModal({
|
||||||
|
type,
|
||||||
|
field,
|
||||||
|
currency,
|
||||||
|
name: header,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowsPointingOut />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Inner = ({
|
||||||
|
field,
|
||||||
|
onMeasureSymbol,
|
||||||
|
inputProps,
|
||||||
|
currencyInfo,
|
||||||
|
}: {
|
||||||
|
field: ControllerRenderProps<any, string>
|
||||||
|
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<string | number>(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 (
|
||||||
|
<div className="relative flex size-full items-center">
|
||||||
|
<span
|
||||||
|
className="txt-compact-small text-ui-fg-muted pointer-events-none absolute left-0 w-fit min-w-4"
|
||||||
|
aria-hidden
|
||||||
|
ref={onMeasureSymbol}
|
||||||
|
>
|
||||||
|
{currencyInfo.symbol_native}
|
||||||
|
</span>
|
||||||
|
<CurrencyInput
|
||||||
|
{...rest}
|
||||||
|
{...attributes}
|
||||||
|
ref={combinedRed}
|
||||||
|
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent pl-[60px] text-right outline-none"
|
||||||
|
value={localValue || undefined}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
formatValueOnBlur
|
||||||
|
onBlur={() => {
|
||||||
|
onBlur()
|
||||||
|
onInputBlur()
|
||||||
|
|
||||||
|
onChange(localValue, value)
|
||||||
|
}}
|
||||||
|
onFocus={onFocus}
|
||||||
|
decimalScale={currencyInfo.decimal_digits}
|
||||||
|
decimalsLimit={currencyInfo.decimal_digits}
|
||||||
|
autoComplete="off"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./shipping-option-price-provider"
|
||||||
|
export * from "./use-shipping-option-price"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import { createContext } from "react"
|
||||||
|
import { ConditionalPriceInfo } from "../../types"
|
||||||
|
|
||||||
|
type ShippingOptionPriceContextType = {
|
||||||
|
onOpenConditionalPricesModal: (info: ConditionalPriceInfo) => void
|
||||||
|
onCloseConditionalPricesModal: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShippingOptionPriceContext =
|
||||||
|
createContext<ShippingOptionPriceContextType | null>(null)
|
||||||
+23
@@ -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 (
|
||||||
|
<ShippingOptionPriceContext.Provider
|
||||||
|
value={{ onOpenConditionalPricesModal, onCloseConditionalPricesModal }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ShippingOptionPriceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
+14
@@ -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
|
||||||
|
}
|
||||||
@@ -9,3 +9,8 @@ export enum ShippingOptionPriceType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GEO_ZONE_STACKED_MODAL_ID = "geo-zone"
|
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"
|
||||||
+122
-1
@@ -1,11 +1,16 @@
|
|||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { TFunction } from "i18next"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
|
import { FieldPath, FieldValues } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { IncludesTaxTooltip } from "../../../../components/common/tax-badge/tax-badge"
|
||||||
import {
|
import {
|
||||||
createDataGridHelper,
|
createDataGridHelper,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
} from "../../../../components/data-grid"
|
} 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()
|
const columnHelper = createDataGridHelper()
|
||||||
|
|
||||||
@@ -26,6 +31,8 @@ export const useShippingOptionPriceColumns = ({
|
|||||||
return [
|
return [
|
||||||
columnHelper.column({
|
columnHelper.column({
|
||||||
id: "name",
|
id: "name",
|
||||||
|
name: t("fields.name"),
|
||||||
|
disableHiding: true,
|
||||||
header: t("fields.name"),
|
header: t("fields.name"),
|
||||||
cell: (context) => {
|
cell: (context) => {
|
||||||
return (
|
return (
|
||||||
@@ -51,3 +58,117 @@ export const useShippingOptionPriceColumns = ({
|
|||||||
]
|
]
|
||||||
}, [t, currencies, regions, pricePreferences, name])
|
}, [t, currencies, regions, pricePreferences, name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateDataGridPriceColumnsProps<
|
||||||
|
TData,
|
||||||
|
TFieldValues extends FieldValues,
|
||||||
|
> = {
|
||||||
|
currencies?: string[]
|
||||||
|
regions?: HttpTypes.AdminRegion[]
|
||||||
|
pricePreferences?: HttpTypes.AdminPricePreference[]
|
||||||
|
getFieldName: (
|
||||||
|
context: FieldContext<TData>,
|
||||||
|
value: string
|
||||||
|
) => FieldPath<TFieldValues> | null
|
||||||
|
t: TFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDataGridPriceColumns = <
|
||||||
|
TData,
|
||||||
|
TFieldValues extends FieldValues,
|
||||||
|
>({
|
||||||
|
currencies,
|
||||||
|
regions,
|
||||||
|
pricePreferences,
|
||||||
|
getFieldName,
|
||||||
|
t,
|
||||||
|
}: CreateDataGridPriceColumnsProps<TData, TFieldValues>): ColumnDef<
|
||||||
|
TData,
|
||||||
|
unknown
|
||||||
|
>[] => {
|
||||||
|
const columnHelper = createDataGridHelper<TData, TFieldValues>()
|
||||||
|
|
||||||
|
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: () => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="truncate" title={translatedCurrencyName}>
|
||||||
|
{translatedCurrencyName}
|
||||||
|
</span>
|
||||||
|
<IncludesTaxTooltip includesTax={preference?.is_tax_inclusive} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: (context) => {
|
||||||
|
return (
|
||||||
|
<ShippingOptionPriceCell
|
||||||
|
type="currency"
|
||||||
|
header={translatedCurrencyName}
|
||||||
|
code={currency}
|
||||||
|
context={context}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}) ?? []),
|
||||||
|
...(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: () => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="truncate" title={translatedRegionName}>
|
||||||
|
{translatedRegionName}
|
||||||
|
</span>
|
||||||
|
<IncludesTaxTooltip includesTax={preference?.is_tax_inclusive} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: (context) => {
|
||||||
|
const currency = currencies?.find((c) => c === region.currency_code)
|
||||||
|
if (!currency) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShippingOptionPriceCell
|
||||||
|
type="region"
|
||||||
|
header={translatedRegionName}
|
||||||
|
code={region.currency_code}
|
||||||
|
context={context}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}) ?? []),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<typeof ConditionalPriceSchema>
|
||||||
|
|
||||||
|
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
|
||||||
|
>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+19
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
import { HandTruck, PencilSquare } from "@medusajs/icons"
|
import { HandTruck, PencilSquare } from "@medusajs/icons"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import { Container, Heading } from "@medusajs/ui"
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
|
import { Fragment } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
@@ -50,7 +51,7 @@ function LocationsFulfillmentProvidersSection({
|
|||||||
<div className="grid grid-cols-[28px_1fr] items-center gap-x-3 gap-y-3">
|
<div className="grid grid-cols-[28px_1fr] items-center gap-x-3 gap-y-3">
|
||||||
{fulfillment_providers?.map((fulfillmentProvider) => {
|
{fulfillment_providers?.map((fulfillmentProvider) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={fulfillmentProvider.id}>
|
||||||
<IconAvatar>
|
<IconAvatar>
|
||||||
<HandTruck className="text-ui-fg-subtle" />
|
<HandTruck className="text-ui-fg-subtle" />
|
||||||
</IconAvatar>
|
</IconAvatar>
|
||||||
@@ -58,7 +59,7 @@ function LocationsFulfillmentProvidersSection({
|
|||||||
<div className="txt-compact-small">
|
<div className="txt-compact-small">
|
||||||
{formatProvider(fulfillmentProvider.id)}
|
{formatProvider(fulfillmentProvider.id)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-3
@@ -1,7 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import { Button, Heading, Input, toast } from "@medusajs/ui"
|
import { Button, Heading, Input, toast } from "@medusajs/ui"
|
||||||
import { useState } from "react"
|
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -42,8 +41,6 @@ export function CreateServiceZoneForm({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { handleSuccess } = useRouteModal()
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof CreateServiceZoneSchema>>({
|
const form = useForm<z.infer<typeof CreateServiceZoneSchema>>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ export const CreateShippingOptionDetailsForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
||||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
|
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-6 py-16">
|
||||||
<div>
|
<div>
|
||||||
<Heading>
|
<Heading>
|
||||||
{t(
|
{t(
|
||||||
|
|||||||
+72
-13
@@ -13,6 +13,7 @@ import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
|||||||
import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options"
|
import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options"
|
||||||
import { castNumber } from "../../../../../lib/cast-number"
|
import { castNumber } from "../../../../../lib/cast-number"
|
||||||
import { ShippingOptionPriceType } from "../../../common/constants"
|
import { ShippingOptionPriceType } from "../../../common/constants"
|
||||||
|
import { buildShippingOptionPriceRules } from "../../../common/utils/price-rule-helpers"
|
||||||
import { CreateShippingOptionDetailsForm } from "./create-shipping-option-details-form"
|
import { CreateShippingOptionDetailsForm } from "./create-shipping-option-details-form"
|
||||||
import { CreateShippingOptionsPricesForm } from "./create-shipping-options-prices-form"
|
import { CreateShippingOptionsPricesForm } from "./create-shipping-options-prices-form"
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,8 @@ export function CreateShippingOptionsForm({
|
|||||||
provider_id: "",
|
provider_id: "",
|
||||||
region_prices: {},
|
region_prices: {},
|
||||||
currency_prices: {},
|
currency_prices: {},
|
||||||
|
conditional_region_prices: {},
|
||||||
|
conditional_currency_prices: {},
|
||||||
},
|
},
|
||||||
resolver: zodResolver(CreateShippingOptionSchema),
|
resolver: zodResolver(CreateShippingOptionSchema),
|
||||||
})
|
})
|
||||||
@@ -63,7 +66,7 @@ export function CreateShippingOptionsForm({
|
|||||||
const handleSubmit = form.handleSubmit(async (data) => {
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
const currencyPrices = Object.entries(data.currency_prices)
|
const currencyPrices = Object.entries(data.currency_prices)
|
||||||
.map(([code, value]) => {
|
.map(([code, value]) => {
|
||||||
if (value === "" || value === undefined) {
|
if (!value) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +75,11 @@ export function CreateShippingOptionsForm({
|
|||||||
amount: castNumber(value),
|
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)
|
const regionPrices = Object.entries(data.region_prices)
|
||||||
.map(([region_id, value]) => {
|
.map(([region_id, value]) => {
|
||||||
if (value === "" || value === undefined) {
|
if (!value) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +88,40 @@ export function CreateShippingOptionsForm({
|
|||||||
amount: castNumber(value),
|
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(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
@@ -94,17 +130,17 @@ export function CreateShippingOptionsForm({
|
|||||||
service_zone_id: zone.id,
|
service_zone_id: zone.id,
|
||||||
shipping_profile_id: data.shipping_profile_id,
|
shipping_profile_id: data.shipping_profile_id,
|
||||||
provider_id: data.provider_id,
|
provider_id: data.provider_id,
|
||||||
prices: [...currencyPrices, ...regionPrices],
|
prices: allPrices,
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
value: isReturn ? '"true"' : '"false"', // we want JSONB saved as string
|
value: isReturn ? '"true"' : '"false"',
|
||||||
attribute: "is_return",
|
attribute: "is_return",
|
||||||
operator: "eq",
|
operator: "eq",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line
|
// 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",
|
attribute: "enabled_in_store",
|
||||||
operator: "eq",
|
operator: "eq",
|
||||||
},
|
},
|
||||||
@@ -123,12 +159,9 @@ export function CreateShippingOptionsForm({
|
|||||||
`stockLocations.shippingOptions.create.${
|
`stockLocations.shippingOptions.create.${
|
||||||
isReturn ? "returns" : "shipping"
|
isReturn ? "returns" : "shipping"
|
||||||
}.successToast`,
|
}.successToast`,
|
||||||
{
|
{ name: shipping_option.name }
|
||||||
name: shipping_option.name,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
handleSuccess(`/settings/locations/${locationId}`)
|
handleSuccess(`/settings/locations/${locationId}`)
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
@@ -193,12 +226,38 @@ export function CreateShippingOptionsForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteFocusModal.Form form={form}>
|
<RouteFocusModal.Form form={form}>
|
||||||
|
<KeyboundForm
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
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()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ProgressTabs
|
<ProgressTabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
className="flex h-full flex-col overflow-hidden"
|
className="flex h-full flex-col overflow-hidden"
|
||||||
onValueChange={(tab) => onTabChange(tab as Tab)}
|
onValueChange={(tab) => onTabChange(tab as Tab)}
|
||||||
>
|
>
|
||||||
<KeyboundForm className="flex h-full flex-col" onSubmit={handleSubmit}>
|
|
||||||
<RouteFocusModal.Header>
|
<RouteFocusModal.Header>
|
||||||
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
|
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
|
||||||
<ProgressTabs.Trigger
|
<ProgressTabs.Trigger
|
||||||
@@ -271,8 +330,8 @@ export function CreateShippingOptionsForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</RouteFocusModal.Footer>
|
</RouteFocusModal.Footer>
|
||||||
</KeyboundForm>
|
|
||||||
</ProgressTabs>
|
</ProgressTabs>
|
||||||
|
</KeyboundForm>
|
||||||
</RouteFocusModal.Form>
|
</RouteFocusModal.Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+46
-3
@@ -1,12 +1,20 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { UseFormReturn } from "react-hook-form"
|
import { UseFormReturn, useWatch } from "react-hook-form"
|
||||||
|
|
||||||
import { DataGrid } from "../../../../../components/data-grid"
|
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 { usePricePreferences } from "../../../../../hooks/api/price-preferences"
|
||||||
import { useRegions } from "../../../../../hooks/api/regions"
|
import { useRegions } from "../../../../../hooks/api/regions"
|
||||||
import { useStore } from "../../../../../hooks/api/store"
|
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 { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns"
|
||||||
|
import { ConditionalPriceInfo } from "../../../common/types"
|
||||||
import { CreateShippingOptionSchema } from "./schema"
|
import { CreateShippingOptionSchema } from "./schema"
|
||||||
|
|
||||||
type PricingPricesFormProps = {
|
type PricingPricesFormProps = {
|
||||||
@@ -16,6 +24,20 @@ type PricingPricesFormProps = {
|
|||||||
export const CreateShippingOptionsPricesForm = ({
|
export const CreateShippingOptionsPricesForm = ({
|
||||||
form,
|
form,
|
||||||
}: PricingPricesFormProps) => {
|
}: PricingPricesFormProps) => {
|
||||||
|
const { getIsOpen, setIsOpen } = useStackedModal()
|
||||||
|
const [selectedPrice, setSelectedPrice] =
|
||||||
|
useState<ConditionalPriceInfo | null>(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 {
|
const {
|
||||||
store,
|
store,
|
||||||
isLoading: isStoreLoading,
|
isLoading: isStoreLoading,
|
||||||
@@ -42,7 +64,10 @@ export const CreateShippingOptionsPricesForm = ({
|
|||||||
|
|
||||||
const { setCloseOnEscape } = useRouteModal()
|
const { setCloseOnEscape } = useRouteModal()
|
||||||
|
|
||||||
|
const name = useWatch({ control: form.control, name: "name" })
|
||||||
|
|
||||||
const columns = useShippingOptionPriceColumns({
|
const columns = useShippingOptionPriceColumns({
|
||||||
|
name,
|
||||||
currencies,
|
currencies,
|
||||||
regions,
|
regions,
|
||||||
pricePreferences,
|
pricePreferences,
|
||||||
@@ -64,6 +89,18 @@ export const CreateShippingOptionsPricesForm = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<StackedFocusModal
|
||||||
|
id={CONDITIONAL_PRICES_STACKED_MODAL_ID}
|
||||||
|
onOpenChangeCallback={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedPrice(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShippingOptionPriceProvider
|
||||||
|
onOpenConditionalPricesModal={onOpenConditionalPricesModal}
|
||||||
|
onCloseConditionalPricesModal={onCloseConditionalPricesModal}
|
||||||
|
>
|
||||||
<div className="flex size-full flex-col divide-y overflow-hidden">
|
<div className="flex size-full flex-col divide-y overflow-hidden">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -71,7 +108,13 @@ export const CreateShippingOptionsPricesForm = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
state={form}
|
state={form}
|
||||||
onEditingChange={(editing) => setCloseOnEscape(!editing)}
|
onEditingChange={(editing) => setCloseOnEscape(!editing)}
|
||||||
|
disableInteractions={getIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID)}
|
||||||
/>
|
/>
|
||||||
|
{selectedPrice && (
|
||||||
|
<ConditionalPriceForm info={selectedPrice} variant="create" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</ShippingOptionPriceProvider>
|
||||||
|
</StackedFocusModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+21
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { ShippingOptionPriceType } from "../../../common/constants"
|
import { ShippingOptionPriceType } from "../../../common/constants"
|
||||||
|
import { ConditionalPriceSchema } from "../../../common/schema"
|
||||||
|
|
||||||
export type CreateShippingOptionSchema = z.infer<
|
export type CreateShippingOptionSchema = z.infer<
|
||||||
typeof CreateShippingOptionSchema
|
typeof CreateShippingOptionSchema
|
||||||
@@ -13,9 +14,29 @@ export const CreateShippingOptionDetailsSchema = z.object({
|
|||||||
provider_id: z.string().min(1),
|
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
|
export const CreateShippingOptionSchema = z
|
||||||
.object({
|
.object({
|
||||||
region_prices: z.record(z.string(), z.string().optional()),
|
region_prices: z.record(z.string(), z.string().optional()),
|
||||||
currency_prices: z.record(z.string(), z.string().optional()),
|
currency_prices: z.record(z.string(), z.string().optional()),
|
||||||
})
|
})
|
||||||
.merge(CreateShippingOptionDetailsSchema)
|
.merge(CreateShippingOptionDetailsSchema)
|
||||||
|
.merge(ShippingOptionConditionalPriceSchema)
|
||||||
|
|
||||||
|
export type CreateShippingOptionSchemaType = z.infer<
|
||||||
|
typeof CreateShippingOptionSchema
|
||||||
|
>
|
||||||
+190
-79
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import * as zod from "zod"
|
import * as zod from "zod"
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { DataGrid } from "../../../../../components/data-grid"
|
import { DataGrid } from "../../../../../components/data-grid"
|
||||||
import {
|
import {
|
||||||
RouteFocusModal,
|
RouteFocusModal,
|
||||||
|
StackedFocusModal,
|
||||||
useRouteModal,
|
useRouteModal,
|
||||||
|
useStackedModal,
|
||||||
} from "../../../../../components/modals/index"
|
} from "../../../../../components/modals/index"
|
||||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||||
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
|
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 { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options"
|
||||||
import { useStore } from "../../../../../hooks/api/store"
|
import { useStore } from "../../../../../hooks/api/store"
|
||||||
import { castNumber } from "../../../../../lib/cast-number"
|
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"
|
import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns"
|
||||||
|
import {
|
||||||
const getInitialCurrencyPrices = (
|
UpdateConditionalPrice,
|
||||||
prices: HttpTypes.AdminShippingOptionPrice[]
|
UpdateConditionalPriceSchema,
|
||||||
) => {
|
} from "../../../common/schema"
|
||||||
const ret: Record<string, number> = {}
|
import { ConditionalPriceInfo } from "../../../common/types"
|
||||||
prices.forEach((p) => {
|
import { buildShippingOptionPriceRules } from "../../../common/utils/price-rule-helpers"
|
||||||
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<string, number> = {}
|
|
||||||
prices.forEach((p) => {
|
|
||||||
if (p.price_rules!.length) {
|
|
||||||
const regionId = p.price_rules![0].value
|
|
||||||
ret[regionId] = p.amount
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
type PriceRecord = {
|
type PriceRecord = {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -64,6 +51,14 @@ const EditShippingOptionPricingSchema = zod.object({
|
|||||||
zod.string(),
|
zod.string(),
|
||||||
zod.string().or(zod.number()).optional()
|
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 = {
|
type EditShippingOptionPricingFormProps = {
|
||||||
@@ -75,12 +70,22 @@ export function EditShippingOptionsPricingForm({
|
|||||||
}: EditShippingOptionPricingFormProps) {
|
}: EditShippingOptionPricingFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { handleSuccess } = useRouteModal()
|
const { handleSuccess } = useRouteModal()
|
||||||
|
const { getIsOpen, setIsOpen } = useStackedModal()
|
||||||
|
const [selectedPrice, setSelectedPrice] =
|
||||||
|
useState<ConditionalPriceInfo | null>(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<zod.infer<typeof EditShippingOptionPricingSchema>>({
|
const form = useForm<zod.infer<typeof EditShippingOptionPricingSchema>>({
|
||||||
defaultValues: {
|
defaultValues: getDefaultValues(shippingOption.prices),
|
||||||
region_prices: getInitialRegionPrices(shippingOption.prices),
|
|
||||||
currency_prices: getInitialCurrencyPrices(shippingOption.prices),
|
|
||||||
},
|
|
||||||
resolver: zodResolver(EditShippingOptionPricingSchema),
|
resolver: zodResolver(EditShippingOptionPricingSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,80 +132,80 @@ export function EditShippingOptionsPricingForm({
|
|||||||
const handleSubmit = form.handleSubmit(async (data) => {
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
const currencyPrices = Object.entries(data.currency_prices)
|
const currencyPrices = Object.entries(data.currency_prices)
|
||||||
.map(([code, value]) => {
|
.map(([code, value]) => {
|
||||||
if (value === "" || value === undefined) {
|
if (
|
||||||
|
!value ||
|
||||||
|
!currencies.some((c) => c.toLowerCase() === code.toLowerCase())
|
||||||
|
) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const currencyExists = currencies.some(
|
|
||||||
(currencyCode) => currencyCode.toLowerCase() == code.toLowerCase()
|
|
||||||
)
|
|
||||||
if (!currencyExists) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = castNumber(value)
|
|
||||||
|
|
||||||
const priceRecord: PriceRecord = {
|
const priceRecord: PriceRecord = {
|
||||||
currency_code: code,
|
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
|
(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 (existingPrice) {
|
||||||
if (price) {
|
priceRecord.id = existingPrice.id
|
||||||
priceRecord["id"] = price.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return priceRecord
|
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)
|
const regionPrices = Object.entries(data.region_prices)
|
||||||
.map(([region_id, value]) => {
|
.map(([region_id, value]) => {
|
||||||
if (value === "" || value === undefined) {
|
if (!value || !regions?.some((region) => region.id === region_id)) {
|
||||||
return 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) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = castNumber(value)
|
|
||||||
|
|
||||||
const priceRecord: PriceRecord = {
|
const priceRecord: PriceRecord = {
|
||||||
region_id,
|
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
|
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(
|
await mutateAsync(
|
||||||
{
|
{ prices: allPrices },
|
||||||
prices: [...currencyPrices, ...regionPrices],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("general.success"))
|
toast.success(t("general.success"))
|
||||||
@@ -233,6 +238,18 @@ export function EditShippingOptionsPricingForm({
|
|||||||
<RouteFocusModal.Header />
|
<RouteFocusModal.Header />
|
||||||
|
|
||||||
<RouteFocusModal.Body>
|
<RouteFocusModal.Body>
|
||||||
|
<StackedFocusModal
|
||||||
|
id={CONDITIONAL_PRICES_STACKED_MODAL_ID}
|
||||||
|
onOpenChangeCallback={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedPrice(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShippingOptionPriceProvider
|
||||||
|
onOpenConditionalPricesModal={onOpenConditionalPricesModal}
|
||||||
|
onCloseConditionalPricesModal={onCloseConditionalPricesModal}
|
||||||
|
>
|
||||||
<div className="flex size-full flex-col divide-y overflow-hidden">
|
<div className="flex size-full flex-col divide-y overflow-hidden">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -240,8 +257,16 @@ export function EditShippingOptionsPricingForm({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
state={form}
|
state={form}
|
||||||
onEditingChange={(editing) => setCloseOnEscape(!editing)}
|
onEditingChange={(editing) => setCloseOnEscape(!editing)}
|
||||||
|
disableInteractions={getIsOpen(
|
||||||
|
CONDITIONAL_PRICES_STACKED_MODAL_ID
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedPrice && (
|
||||||
|
<ConditionalPriceForm info={selectedPrice} variant="update" />
|
||||||
|
)}
|
||||||
|
</ShippingOptionPriceProvider>
|
||||||
|
</StackedFocusModal>
|
||||||
</RouteFocusModal.Body>
|
</RouteFocusModal.Body>
|
||||||
<RouteFocusModal.Footer>
|
<RouteFocusModal.Footer>
|
||||||
<div className="flex items-center justify-end gap-x-2">
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
@@ -265,3 +290,89 @@ export function EditShippingOptionsPricingForm({
|
|||||||
</RouteFocusModal.Form>
|
</RouteFocusModal.Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, number> = {}
|
||||||
|
const conditional_currency_prices: Record<string, UpdateConditionalPrice[]> =
|
||||||
|
{}
|
||||||
|
const region_prices: Record<string, number> = {}
|
||||||
|
const conditional_region_prices: Record<string, UpdateConditionalPrice[]> = {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -14,8 +14,11 @@ export function LocationServiceZoneShippingOptionPricing() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { shipping_option: shippingOption, isError, error } =
|
const {
|
||||||
useShippingOption(so_id, {
|
shipping_option: shippingOption,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useShippingOption(so_id, {
|
||||||
fields: "*prices,*prices.price_rules",
|
fields: "*prices,*prices.price_rules",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// TODO: This type is complete, but it's not clear what the `rules` field is supposed to return in all cases.
|
||||||
export interface AdminShippingOptionPriceRule {
|
export interface AdminShippingOptionPriceRule {
|
||||||
id: string
|
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 {
|
export interface AdminShippingOptionPrice extends AdminPrice {
|
||||||
|
|||||||
@@ -13,12 +13,24 @@ export interface AdminCreateShippingOptionType {
|
|||||||
code: string
|
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
|
currency_code: string
|
||||||
amount: number
|
amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminCreateShippingOptionPriceWithRegion {
|
export interface AdminCreateShippingOptionPriceWithRegion
|
||||||
|
extends AdminShippingOptionPriceWithRules {
|
||||||
region_id: string
|
region_id: string
|
||||||
amount: number
|
amount: number
|
||||||
}
|
}
|
||||||
@@ -43,13 +55,15 @@ export interface AdminUpdateShippingOptionRule
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUpdateShippingOptionPriceWithCurrency {
|
export interface AdminUpdateShippingOptionPriceWithCurrency
|
||||||
|
extends AdminShippingOptionPriceWithRules {
|
||||||
id?: string
|
id?: string
|
||||||
currency_code?: string
|
currency_code?: string
|
||||||
amount?: number
|
amount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUpdateShippingOptionPriceWithRegion {
|
export interface AdminUpdateShippingOptionPriceWithRegion
|
||||||
|
extends AdminShippingOptionPriceWithRules {
|
||||||
id?: string
|
id?: string
|
||||||
region_id?: string
|
region_id?: string
|
||||||
amount?: number
|
amount?: number
|
||||||
|
|||||||
Reference in new issue
Block a user