feat(dashboard): SO cart item total rules UI (#10386)
This commit is contained in:
committed by
GitHub
parent
9e797dc3d2
commit
a1a1e0e789
6
.changeset/dry-cheetahs-wait.md
Normal file
6
.changeset/dry-cheetahs-wait.md
Normal file
@@ -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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface DataGridCellContainerProps extends PropsWithChildren<{}> {
|
||||
isDragSelected: boolean
|
||||
placeholder?: ReactNode
|
||||
showOverlay: boolean
|
||||
outerComponent?: ReactNode
|
||||
}
|
||||
|
||||
export type DataGridCellSnapshot<
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./conditional-price-form"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./shipping-option-price-cell"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./shipping-option-price-provider"
|
||||
export * from "./use-shipping-option-price"
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 CONDITIONAL_PRICES_STACKED_MODAL_ID = "conditional-prices"
|
||||
|
||||
export const ITEM_TOTAL_ATTRIBUTE = "item_total"
|
||||
export const REGION_ID_ATTRIBUTE = "region_id"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}) ?? []),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user