feat(dashboard): SO cart item total rules UI (#10386)

This commit is contained in:
Kasper Fabricius Kristensen
2024-12-09 09:44:11 +01:00
committed by GitHub
parent 9e797dc3d2
commit a1a1e0e789
33 changed files with 1947 additions and 190 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/dashboard": patch
"@medusajs/types": patch
---
feat(dashboard,types): Add UI to manage conditional SO prices

View File

@@ -1,5 +1,5 @@
import { BuildingTax } from "@medusajs/icons"
import { Tooltip, clx } from "@medusajs/ui"
import { TaxExclusive, TaxInclusive } from "@medusajs/icons"
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type IncludesTaxTooltipProps = {
@@ -20,9 +20,11 @@ export const IncludesTaxTooltip = ({
: t("general.excludesTaxTooltip")
}
>
<BuildingTax
className={clx("shrink-0", { "text-ui-fg-muted": !includesTax })}
/>
{includesTax ? (
<TaxInclusive className="text-ui-fg-muted shrink-0" />
) : (
<TaxExclusive className="text-ui-fg-muted shrink-0" />
)}
</Tooltip>
)
}

View File

@@ -19,52 +19,56 @@ export const DataGridCellContainer = ({
children,
errors,
rowErrors,
outerComponent,
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
const error = get(errors, field)
const hasError = !!error
return (
<div
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-tag-red-bg text-ui-tag-red-text":
hasError && !isAnchor && !isSelected && !isDragSelected,
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
"bg-ui-bg-highlight [&:has([data-field]:focus)]:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
}
)}
tabIndex={-1}
{...innerProps}
>
<ErrorMessage
name={field}
errors={errors}
render={({ message }) => {
return (
<div className="flex items-center justify-center">
<Tooltip content={message} delayDuration={0}>
<ExclamationCircle className="text-ui-tag-red-icon z-[3]" />
</Tooltip>
</div>
)
}}
/>
<div className="relative z-[1] flex size-full items-center justify-center">
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>
</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
{showOverlay && (
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0 z-[2] size-full"
<div className="group/container relative size-full">
<div
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-tag-red-bg text-ui-tag-red-text":
hasError && !isAnchor && !isSelected && !isDragSelected,
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
"bg-ui-bg-highlight [&:has([data-field]:focus)]:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
}
)}
tabIndex={-1}
{...innerProps}
>
<ErrorMessage
name={field}
errors={errors}
render={({ message }) => {
return (
<div className="flex items-center justify-center">
<Tooltip content={message} delayDuration={0}>
<ExclamationCircle className="text-ui-tag-red-icon z-[3]" />
</Tooltip>
</div>
)
}}
/>
)}
<div className="relative z-[1] flex size-full items-center justify-center">
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>
</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
{showOverlay && (
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0"
/>
)}
</div>
{outerComponent}
</div>
)
}

View File

@@ -57,6 +57,7 @@ export interface DataGridRootProps<
state: UseFormReturn<TFieldValues>
getSubRows?: (row: TData) => TData[] | undefined
onEditingChange?: (isEditing: boolean) => void
disableInteractions?: boolean
}
const ROW_HEIGHT = 40
@@ -102,6 +103,7 @@ export const DataGridRoot = <
state,
getSubRows,
onEditingChange,
disableInteractions,
}: DataGridRootProps<TData, TFieldValues>) => {
const containerRef = useRef<HTMLDivElement>(null)
@@ -114,7 +116,9 @@ export const DataGridRoot = <
formState: { errors },
} = state
const [trapActive, setTrapActive] = useState(true)
const [internalTrapActive, setTrapActive] = useState(true)
const trapActive = !disableInteractions && internalTrapActive
const [anchor, setAnchor] = useState<DataGridCoordinates | null>(null)
const [rangeEnd, setRangeEnd] = useState<DataGridCoordinates | null>(null)
@@ -533,7 +537,7 @@ export const DataGridRoot = <
queryTool?.getContainer(anchor)?.focus()
})
}
}, [anchor, trapActive, queryTool])
}, [anchor, trapActive, setSingleRange, scrollToCoordinates, queryTool])
return (
<DataGridContext.Provider value={values}>

View File

@@ -46,7 +46,7 @@ const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"]
export const useDataGridKeydownEvent = <
TData,
TFieldValues extends FieldValues,
TFieldValues extends FieldValues
>({
containerRef,
matrix,
@@ -108,8 +108,8 @@ export const useDataGridKeydownEvent = <
direction === "horizontal"
? setSingleRange
: e.shiftKey
? setRangeEnd
: setSingleRange
? setRangeEnd
: setSingleRange
if (!basis) {
return

View File

@@ -96,6 +96,7 @@ export interface DataGridCellContainerProps extends PropsWithChildren<{}> {
isDragSelected: boolean
placeholder?: ReactNode
showOverlay: boolean
outerComponent?: ReactNode
}
export type DataGridCellSnapshot<

View File

@@ -13,12 +13,20 @@ type StackedFocusModalProps = PropsWithChildren<{
* when multiple stacked modals are registered to the same parent modal.
*/
id: string
/**
* An optional callback that is called when the modal is opened or closed.
*/
onOpenChangeCallback?: (open: boolean) => void
}>
/**
* A stacked modal that can be rendered above a parent modal.
*/
export const Root = ({ id, children }: StackedFocusModalProps) => {
export const Root = ({
id,
onOpenChangeCallback,
children,
}: StackedFocusModalProps) => {
const { register, unregister, getIsOpen, setIsOpen } = useStackedModal()
useEffect(() => {
@@ -28,11 +36,13 @@ export const Root = ({ id, children }: StackedFocusModalProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleOpenChange = (open: boolean) => {
setIsOpen(id, open)
onOpenChangeCallback?.(open)
}
return (
<FocusModal
open={getIsOpen(id)}
onOpenChange={(open) => setIsOpen(id, open)}
>
<FocusModal open={getIsOpen(id)} onOpenChange={handleOpenChange}>
{children}
</FocusModal>
)

View File

@@ -5544,6 +5544,144 @@
"required": ["action"],
"additionalProperties": false
},
"conditionalPrices": {
"type": "object",
"properties": {
"header": {
"type": "string"
},
"description": {
"type": "string"
},
"attributes": {
"type": "object",
"properties": {
"cartItemTotal": {
"type": "string"
}
},
"required": [
"cartItemTotal"
],
"additionalProperties": false
},
"summaries": {
"type": "object",
"properties": {
"range": {
"type": "string"
},
"greaterThan": {
"type": "string"
},
"lessThan": {
"type": "string"
}
},
"required": [
"range",
"greaterThan",
"lessThan"
],
"additionalProperties": false
},
"actions": {
"type": "object",
"properties": {
"addPrice": {
"type": "string"
},
"manageConditionalPrices": {
"type": "string"
}
},
"required": [
"addPrice",
"manageConditionalPrices"
],
"additionalProperties": false
},
"rules": {
"type": "object",
"properties": {
"amount": {
"type": "string"
},
"gte": {
"type": "string"
},
"lte": {
"type": "string"
}
},
"required": [
"amount",
"gte",
"lte"
],
"additionalProperties": false
},
"customRules": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"tooltip": {
"type": "string"
},
"eq": {
"type": "string"
},
"gt": {
"type": "string"
},
"lt": {
"type": "string"
}
},
"required": [
"label",
"tooltip",
"eq",
"gt",
"lt"
],
"additionalProperties": false
},
"errors": {
"type": "object",
"properties": {
"amountRequired": {
"type": "string"
},
"minOrMaxRequired": {
"type": "string"
},
"minGreaterThanMax": {
"type": "string"
}
},
"required": [
"amountRequired",
"minOrMaxRequired",
"minGreaterThanMax"
],
"additionalProperties": false
}
},
"required": [
"header",
"description",
"attributes",
"summaries",
"actions",
"rules",
"customRules",
"errors"
],
"additionalProperties": false
},
"fields": {
"type": "object",
"properties": {
@@ -5644,7 +5782,14 @@
"additionalProperties": false
}
},
"required": ["create", "delete", "edit", "pricing", "fields"],
"required": [
"create",
"delete",
"edit",
"pricing",
"conditionalPrices",
"fields"
],
"additionalProperties": false
},
"serviceZones": {

View File

@@ -1463,6 +1463,39 @@
"pricing": {
"action": "Edit prices"
},
"conditionalPrices": {
"header": "Conditional Prices for {{name}}",
"description": "Manage the conditional prices for this shipping option based on the cart item total.",
"attributes": {
"cartItemTotal": "Cart item total"
},
"summaries": {
"range": "If <0>{{attribute}}</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": {
"count": {
"shipping_one": "{{count}} shipping option",

View File

@@ -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()
}

View File

@@ -0,0 +1 @@
export * from "./conditional-price-form"

View File

@@ -0,0 +1 @@
export * from "./shipping-option-price-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1,2 @@
export * from "./shipping-option-price-provider"
export * from "./use-shipping-option-price"

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -9,3 +9,8 @@ export enum ShippingOptionPriceType {
}
export const GEO_ZONE_STACKED_MODAL_ID = "geo-zone"
export const CONDITIONAL_PRICES_STACKED_MODAL_ID = "conditional-prices"
export const ITEM_TOTAL_ATTRIBUTE = "item_total"
export const REGION_ID_ATTRIBUTE = "region_id"

View File

@@ -1,11 +1,16 @@
import { HttpTypes } from "@medusajs/types"
import { ColumnDef } from "@tanstack/react-table"
import { TFunction } from "i18next"
import { useMemo } from "react"
import { FieldPath, FieldValues } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { IncludesTaxTooltip } from "../../../../components/common/tax-badge/tax-badge"
import {
createDataGridHelper,
DataGrid,
} from "../../../../components/data-grid"
import { createDataGridPriceColumns } from "../../../../components/data-grid/helpers/create-data-grid-price-columns"
import { FieldContext } from "../../../../components/data-grid/types"
import { ShippingOptionPriceCell } from "../components/shipping-option-price-cell"
const columnHelper = createDataGridHelper()
@@ -26,6 +31,8 @@ export const useShippingOptionPriceColumns = ({
return [
columnHelper.column({
id: "name",
name: t("fields.name"),
disableHiding: true,
header: t("fields.name"),
cell: (context) => {
return (
@@ -51,3 +58,117 @@ export const useShippingOptionPriceColumns = ({
]
}, [t, currencies, regions, pricePreferences, name])
}
type CreateDataGridPriceColumnsProps<
TData,
TFieldValues extends FieldValues,
> = {
currencies?: string[]
regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[]
getFieldName: (
context: FieldContext<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}
/>
)
},
})
}) ?? []),
]
}

View File

@@ -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
>

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
)
}

View File

@@ -1,6 +1,7 @@
import { HandTruck, PencilSquare } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { Fragment } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
@@ -50,7 +51,7 @@ function LocationsFulfillmentProvidersSection({
<div className="grid grid-cols-[28px_1fr] items-center gap-x-3 gap-y-3">
{fulfillment_providers?.map((fulfillmentProvider) => {
return (
<>
<Fragment key={fulfillmentProvider.id}>
<IconAvatar>
<HandTruck className="text-ui-fg-subtle" />
</IconAvatar>
@@ -58,7 +59,7 @@ function LocationsFulfillmentProvidersSection({
<div className="txt-compact-small">
{formatProvider(fulfillmentProvider.id)}
</div>
</>
</Fragment>
)
})}
</div>

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, Input, toast } from "@medusajs/ui"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
@@ -42,8 +41,6 @@ export function CreateServiceZoneForm({
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [open, setOpen] = useState(false)
const form = useForm<z.infer<typeof CreateServiceZoneSchema>>({
defaultValues: {
name: "",

View File

@@ -54,7 +54,7 @@ export const CreateShippingOptionDetailsForm = ({
return (
<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>
<Heading>
{t(

View File

@@ -13,6 +13,7 @@ import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options"
import { castNumber } from "../../../../../lib/cast-number"
import { ShippingOptionPriceType } from "../../../common/constants"
import { buildShippingOptionPriceRules } from "../../../common/utils/price-rule-helpers"
import { CreateShippingOptionDetailsForm } from "./create-shipping-option-details-form"
import { CreateShippingOptionsPricesForm } from "./create-shipping-options-prices-form"
import {
@@ -51,6 +52,8 @@ export function CreateShippingOptionsForm({
provider_id: "",
region_prices: {},
currency_prices: {},
conditional_region_prices: {},
conditional_currency_prices: {},
},
resolver: zodResolver(CreateShippingOptionSchema),
})
@@ -63,7 +66,7 @@ export function CreateShippingOptionsForm({
const handleSubmit = form.handleSubmit(async (data) => {
const currencyPrices = Object.entries(data.currency_prices)
.map(([code, value]) => {
if (value === "" || value === undefined) {
if (!value) {
return undefined
}
@@ -72,11 +75,11 @@ export function CreateShippingOptionsForm({
amount: castNumber(value),
}
})
.filter((o) => !!o) as { currency_code: string; amount: number }[]
.filter((p): p is { currency_code: string; amount: number } => !!p)
const regionPrices = Object.entries(data.region_prices)
.map(([region_id, value]) => {
if (value === "" || value === undefined) {
if (!value) {
return undefined
}
@@ -85,7 +88,40 @@ export function CreateShippingOptionsForm({
amount: castNumber(value),
}
})
.filter((o) => !!o) as { region_id: string; amount: number }[]
.filter((p): p is { region_id: string; amount: number } => !!p)
const conditionalRegionPrices = Object.entries(
data.conditional_region_prices
).flatMap(([region_id, value]) => {
const prices: HttpTypes.AdminCreateShippingOptionPriceWithRegion[] =
value?.map((rule) => ({
region_id: region_id,
amount: castNumber(rule.amount),
rules: buildShippingOptionPriceRules(rule),
})) || []
return prices?.filter(Boolean)
})
const conditionalCurrencyPrices = Object.entries(
data.conditional_currency_prices
).flatMap(([currency_code, value]) => {
const prices: HttpTypes.AdminCreateShippingOptionPriceWithCurrency[] =
value?.map((rule) => ({
currency_code,
amount: castNumber(rule.amount),
rules: buildShippingOptionPriceRules(rule),
})) || []
return prices?.filter(Boolean)
})
const allPrices = [
...currencyPrices,
...conditionalCurrencyPrices,
...regionPrices,
...conditionalRegionPrices,
]
await mutateAsync(
{
@@ -94,17 +130,17 @@ export function CreateShippingOptionsForm({
service_zone_id: zone.id,
shipping_profile_id: data.shipping_profile_id,
provider_id: data.provider_id,
prices: [...currencyPrices, ...regionPrices],
prices: allPrices,
rules: [
{
// eslint-disable-next-line
value: isReturn ? '"true"' : '"false"', // we want JSONB saved as string
value: isReturn ? '"true"' : '"false"',
attribute: "is_return",
operator: "eq",
},
{
// eslint-disable-next-line
value: data.enabled_in_store ? '"true"' : '"false"', // we want JSONB saved as string
value: data.enabled_in_store ? '"true"' : '"false"',
attribute: "enabled_in_store",
operator: "eq",
},
@@ -123,12 +159,9 @@ export function CreateShippingOptionsForm({
`stockLocations.shippingOptions.create.${
isReturn ? "returns" : "shipping"
}.successToast`,
{
name: shipping_option.name,
}
{ name: shipping_option.name }
)
)
handleSuccess(`/settings/locations/${locationId}`)
},
onError: (e) => {
@@ -193,12 +226,38 @@ export function CreateShippingOptionsForm({
return (
<RouteFocusModal.Form form={form}>
<ProgressTabs
value={activeTab}
className="flex h-full flex-col overflow-hidden"
onValueChange={(tab) => onTabChange(tab as Tab)}
<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()
}}
>
<KeyboundForm className="flex h-full flex-col" onSubmit={handleSubmit}>
<ProgressTabs
value={activeTab}
className="flex h-full flex-col overflow-hidden"
onValueChange={(tab) => onTabChange(tab as Tab)}
>
<RouteFocusModal.Header>
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
<ProgressTabs.Trigger
@@ -271,8 +330,8 @@ export function CreateShippingOptionsForm({
)}
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
</ProgressTabs>
</ProgressTabs>
</KeyboundForm>
</RouteFocusModal.Form>
)
}

View File

@@ -1,12 +1,20 @@
import { useMemo } from "react"
import { UseFormReturn } from "react-hook-form"
import { useMemo, useState } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { DataGrid } from "../../../../../components/data-grid"
import { useRouteModal } from "../../../../../components/modals"
import {
StackedFocusModal,
useRouteModal,
useStackedModal,
} from "../../../../../components/modals"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { useRegions } from "../../../../../hooks/api/regions"
import { useStore } from "../../../../../hooks/api/store"
import { ConditionalPriceForm } from "../../../common/components/conditional-price-form"
import { ShippingOptionPriceProvider } from "../../../common/components/shipping-option-price-provider"
import { CONDITIONAL_PRICES_STACKED_MODAL_ID } from "../../../common/constants"
import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns"
import { ConditionalPriceInfo } from "../../../common/types"
import { CreateShippingOptionSchema } from "./schema"
type PricingPricesFormProps = {
@@ -16,6 +24,20 @@ type PricingPricesFormProps = {
export const CreateShippingOptionsPricesForm = ({
form,
}: PricingPricesFormProps) => {
const { getIsOpen, setIsOpen } = useStackedModal()
const [selectedPrice, setSelectedPrice] =
useState<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 {
store,
isLoading: isStoreLoading,
@@ -42,7 +64,10 @@ export const CreateShippingOptionsPricesForm = ({
const { setCloseOnEscape } = useRouteModal()
const name = useWatch({ control: form.control, name: "name" })
const columns = useShippingOptionPriceColumns({
name,
currencies,
regions,
pricePreferences,
@@ -64,14 +89,32 @@ export const CreateShippingOptionsPricesForm = ({
}
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
<DataGrid
isLoading={isLoading}
data={data}
columns={columns}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
</div>
<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">
<DataGrid
isLoading={isLoading}
data={data}
columns={columns}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
disableInteractions={getIsOpen(CONDITIONAL_PRICES_STACKED_MODAL_ID)}
/>
{selectedPrice && (
<ConditionalPriceForm info={selectedPrice} variant="create" />
)}
</div>
</ShippingOptionPriceProvider>
</StackedFocusModal>
)
}

View File

@@ -1,5 +1,6 @@
import { z } from "zod"
import { ShippingOptionPriceType } from "../../../common/constants"
import { ConditionalPriceSchema } from "../../../common/schema"
export type CreateShippingOptionSchema = z.infer<
typeof CreateShippingOptionSchema
@@ -13,9 +14,29 @@ export const CreateShippingOptionDetailsSchema = z.object({
provider_id: z.string().min(1),
})
export const ShippingOptionConditionalPriceSchema = z.object({
conditional_region_prices: z.record(
z.string(),
z.array(ConditionalPriceSchema).optional()
),
conditional_currency_prices: z.record(
z.string(),
z.array(ConditionalPriceSchema).optional()
),
})
export type ShippingOptionConditionalPriceSchemaType = z.infer<
typeof ShippingOptionConditionalPriceSchema
>
export const CreateShippingOptionSchema = z
.object({
region_prices: z.record(z.string(), z.string().optional()),
currency_prices: z.record(z.string(), z.string().optional()),
})
.merge(CreateShippingOptionDetailsSchema)
.merge(ShippingOptionConditionalPriceSchema)
export type CreateShippingOptionSchemaType = z.infer<
typeof CreateShippingOptionSchema
>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import * as zod from "zod"
@@ -10,7 +10,9 @@ import { useTranslation } from "react-i18next"
import { DataGrid } from "../../../../../components/data-grid"
import {
RouteFocusModal,
StackedFocusModal,
useRouteModal,
useStackedModal,
} from "../../../../../components/modals/index"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
@@ -18,35 +20,20 @@ import { useRegions } from "../../../../../hooks/api/regions"
import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options"
import { useStore } from "../../../../../hooks/api/store"
import { castNumber } from "../../../../../lib/cast-number"
import { ConditionalPriceForm } from "../../../common/components/conditional-price-form"
import { ShippingOptionPriceProvider } from "../../../common/components/shipping-option-price-provider"
import {
CONDITIONAL_PRICES_STACKED_MODAL_ID,
ITEM_TOTAL_ATTRIBUTE,
REGION_ID_ATTRIBUTE,
} from "../../../common/constants"
import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns"
const getInitialCurrencyPrices = (
prices: HttpTypes.AdminShippingOptionPrice[]
) => {
const ret: Record<string, number> = {}
prices.forEach((p) => {
if (p.price_rules!.length) {
// this is a region price
return
}
ret[p.currency_code!] = p.amount
})
return ret
}
const getInitialRegionPrices = (
prices: HttpTypes.AdminShippingOptionPrice[]
) => {
const ret: Record<string, number> = {}
prices.forEach((p) => {
if (p.price_rules!.length) {
const regionId = p.price_rules![0].value
ret[regionId] = p.amount
}
})
return ret
}
import {
UpdateConditionalPrice,
UpdateConditionalPriceSchema,
} from "../../../common/schema"
import { ConditionalPriceInfo } from "../../../common/types"
import { buildShippingOptionPriceRules } from "../../../common/utils/price-rule-helpers"
type PriceRecord = {
id?: string
@@ -64,6 +51,14 @@ const EditShippingOptionPricingSchema = zod.object({
zod.string(),
zod.string().or(zod.number()).optional()
),
conditional_region_prices: zod.record(
zod.string(),
zod.array(UpdateConditionalPriceSchema)
),
conditional_currency_prices: zod.record(
zod.string(),
zod.array(UpdateConditionalPriceSchema)
),
})
type EditShippingOptionPricingFormProps = {
@@ -75,12 +70,22 @@ export function EditShippingOptionsPricingForm({
}: EditShippingOptionPricingFormProps) {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { getIsOpen, setIsOpen } = useStackedModal()
const [selectedPrice, setSelectedPrice] =
useState<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>>({
defaultValues: {
region_prices: getInitialRegionPrices(shippingOption.prices),
currency_prices: getInitialCurrencyPrices(shippingOption.prices),
},
defaultValues: getDefaultValues(shippingOption.prices),
resolver: zodResolver(EditShippingOptionPricingSchema),
})
@@ -127,80 +132,80 @@ export function EditShippingOptionsPricingForm({
const handleSubmit = form.handleSubmit(async (data) => {
const currencyPrices = Object.entries(data.currency_prices)
.map(([code, value]) => {
if (value === "" || value === undefined) {
if (
!value ||
!currencies.some((c) => c.toLowerCase() === code.toLowerCase())
) {
return undefined
}
const currencyExists = currencies.some(
(currencyCode) => currencyCode.toLowerCase() == code.toLowerCase()
)
if (!currencyExists) {
return undefined
}
const amount = castNumber(value)
const priceRecord: PriceRecord = {
currency_code: code,
amount: amount,
amount: castNumber(value),
}
const price = shippingOption.prices.find(
const existingPrice = shippingOption.prices.find(
(p) => p.currency_code === code && !p.price_rules!.length
)
// if that currency price is already defined for the SO, we will do an update
if (price) {
priceRecord["id"] = price.id
if (existingPrice) {
priceRecord.id = existingPrice.id
}
return priceRecord
})
.filter((p) => !!p) as PriceRecord[]
.filter((p): p is PriceRecord => !!p)
const conditionalCurrencyPrices = Object.entries(
data.conditional_currency_prices
).flatMap(([currency_code, value]) =>
value?.map((rule) => ({
id: rule.id,
currency_code,
amount: castNumber(rule.amount),
rules: buildShippingOptionPriceRules(rule),
}))
)
/**
* TODO: If we try to update an existing region price the API throws an error.
* Instead we re-create region prices.
*/
const regionPrices = Object.entries(data.region_prices)
.map(([region_id, value]) => {
if (value === "" || value === undefined) {
if (!value || !regions?.some((region) => region.id === region_id)) {
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 = {
region_id,
amount: amount,
amount: castNumber(value),
}
/**
* HACK - when trying to update prices which already have a region price
* we get error: `Price rule with price_id: , rule_type_id: already exist`,
* so for now, we recreate region prices.
*/
// const price = shippingOption.prices.find(
// (p) => p.price_rules?.[0]?.value === region_id
// )
// if (price) {
// priceRecord["id"] = price.id
// }
return priceRecord
})
.filter((p) => !!p) as PriceRecord[]
.filter((p): p is PriceRecord => !!p)
const conditionalRegionPrices = Object.entries(
data.conditional_region_prices
).flatMap(([region_id, value]) =>
value?.map((rule) => ({
id: rule.id,
region_id,
amount: castNumber(rule.amount),
rules: buildShippingOptionPriceRules(rule),
}))
)
const allPrices = [
...currencyPrices,
...conditionalCurrencyPrices,
...regionPrices,
...conditionalRegionPrices,
]
await mutateAsync(
{
prices: [...currencyPrices, ...regionPrices],
},
{ prices: allPrices },
{
onSuccess: () => {
toast.success(t("general.success"))
@@ -233,15 +238,35 @@ export function EditShippingOptionsPricingForm({
<RouteFocusModal.Header />
<RouteFocusModal.Body>
<div className="flex size-full flex-col divide-y overflow-hidden">
<DataGrid
isLoading={isLoading}
data={data}
columns={columns}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
</div>
<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">
<DataGrid
isLoading={isLoading}
data={data}
columns={columns}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
disableInteractions={getIsOpen(
CONDITIONAL_PRICES_STACKED_MODAL_ID
)}
/>
</div>
{selectedPrice && (
<ConditionalPriceForm info={selectedPrice} variant="update" />
)}
</ShippingOptionPriceProvider>
</StackedFocusModal>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-x-2">
@@ -265,3 +290,89 @@ export function EditShippingOptionsPricingForm({
</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,
}
}

View File

@@ -14,10 +14,13 @@ export function LocationServiceZoneShippingOptionPricing() {
})
}
const { shipping_option: shippingOption, isError, error } =
useShippingOption(so_id, {
fields: "*prices,*prices.price_rules",
})
const {
shipping_option: shippingOption,
isError,
error,
} = useShippingOption(so_id, {
fields: "*prices,*prices.price_rules",
})
if (isError) {
throw error

View File

@@ -30,7 +30,14 @@ export interface AdminShippingOptionRule {
// TODO: This type is complete, but it's not clear what the `rules` field is supposed to return in all cases.
export interface AdminShippingOptionPriceRule {
id: string
value: string
value: string | number
operator: RuleOperatorType
attribute: string
price_id: string
priority: number
created_at: string
updated_at: string
deleted_at: string | null
}
export interface AdminShippingOptionPrice extends AdminPrice {

View File

@@ -13,12 +13,24 @@ export interface AdminCreateShippingOptionType {
code: string
}
export interface AdminCreateShippingOptionPriceWithCurrency {
interface AdminShippingOptionPriceRulePayload {
operator: string
attribute: string
value: string | string[] | number
}
interface AdminShippingOptionPriceWithRules {
rules?: AdminShippingOptionPriceRulePayload[]
}
export interface AdminCreateShippingOptionPriceWithCurrency
extends AdminShippingOptionPriceWithRules {
currency_code: string
amount: number
}
export interface AdminCreateShippingOptionPriceWithRegion {
export interface AdminCreateShippingOptionPriceWithRegion
extends AdminShippingOptionPriceWithRules {
region_id: string
amount: number
}
@@ -43,13 +55,15 @@ export interface AdminUpdateShippingOptionRule
id: string
}
export interface AdminUpdateShippingOptionPriceWithCurrency {
export interface AdminUpdateShippingOptionPriceWithCurrency
extends AdminShippingOptionPriceWithRules {
id?: string
currency_code?: string
amount?: number
}
export interface AdminUpdateShippingOptionPriceWithRegion {
export interface AdminUpdateShippingOptionPriceWithRegion
extends AdminShippingOptionPriceWithRules {
id?: string
region_id?: string
amount?: number