feat(dashboard,core,modules): free shipping promotion in dashboard (#13263)

* feat(dashboard,core,modules): free shipping promotion in dashboard

* self-review

* adapt for edit to work

* changeset

* integration tests

* across for each

* remove only from tests

* remove console log

* revert to across

* update wording for shipping promotions

* modify changeset

* suggestion frane

* fix i18n schema
This commit is contained in:
William Bouchard
2025-08-22 16:30:30 -04:00
committed by GitHub
parent 92e3b2b432
commit 486621383a
22 changed files with 474 additions and 331 deletions
@@ -28,7 +28,13 @@ export const EditRulesForm = ({
const [rulesToRemove, setRulesToRemove] = useState([])
const form = useForm<EditRulesType>({
defaultValues: { rules: [], type: promotion.type },
defaultValues: {
rules: [],
type: promotion.type,
application_method: {
target_type: promotion.application_method?.target_type,
},
},
resolver: zodResolver(EditRules),
})
@@ -24,6 +24,9 @@ export const EditRules = z.object({
field_type: z.string().optional(),
})
),
application_method: z.object({
target_type: z.enum(["order", "shipping_methods", "items"]),
}),
})
export type EditRulesType = z.infer<typeof EditRules>
@@ -1,9 +1,13 @@
import { HttpTypes } from "@medusajs/types"
import {
ApplicationMethodTargetTypeValues,
HttpTypes,
RuleTypeValues,
} from "@medusajs/types"
import { Input } from "@medusajs/ui"
import { useWatch } from "react-hook-form"
import { Form } from "../../../../../../components/common/form"
import { Combobox } from "../../../../../../components/inputs/combobox"
import { useStore } from "../../../../../../hooks/api/store"
import { useStore } from "../../../../../../hooks/api"
import { useComboboxData } from "../../../../../../hooks/use-combobox-data"
import { sdk } from "../../../../../../lib/client"
@@ -18,7 +22,8 @@ type RuleValueFormFieldType = {
operator: string
fieldRule: any
attributes: HttpTypes.AdminRuleAttributeOption[]
ruleType: "rules" | "target-rules" | "buy-rules"
ruleType: RuleTypeValues
applicationMethodTargetType: ApplicationMethodTargetTypeValues | undefined
}
const buildFilters = (attribute?: string, store?: HttpTypes.AdminStore) => {
@@ -44,6 +49,7 @@ export const RuleValueFormField = ({
fieldRule,
attributes,
ruleType,
applicationMethodTargetType,
}: RuleValueFormFieldType) => {
const attribute = attributes?.find(
(attr) => attr.value === fieldRule.attribute
@@ -59,6 +65,7 @@ export const RuleValueFormField = ({
{
...params,
...buildFilters(attribute?.id, store!),
application_method_target_type: applicationMethodTargetType,
}
)
},
@@ -41,7 +41,11 @@ export const RulesFormField = ({
}: RulesFormFieldType) => {
const { t } = useTranslation()
const formData = form.getValues()
const { attributes } = usePromotionRuleAttributes(ruleType, formData.type)
const { attributes } = usePromotionRuleAttributes(
ruleType,
formData.type,
formData.application_method?.target_type
)
const { fields, append, remove, update, replace } = useFieldArray({
control: form.control,
@@ -61,10 +65,17 @@ export const RulesFormField = ({
defaultValue: promotion?.application_method?.type,
})
const applicationMethodTargetType = useWatch({
control: form.control,
name: "application_method.target_type",
defaultValue: promotion?.application_method?.target_type,
})
const query: Record<string, string> = promotionType
? {
promotion_type: promotionType,
application_method_type: applicationMethodType,
application_method_target_type: applicationMethodTargetType,
}
: {}
@@ -121,11 +132,19 @@ export const RulesFormField = ({
return (
<div className="flex flex-col">
<Heading level="h2" className="mb-2">
{t(`promotions.fields.conditions.${ruleType}.title`)}
{t(
ruleType === "target-rules"
? `promotions.fields.conditions.${ruleType}.${applicationMethodTargetType}.title`
: `promotions.fields.conditions.${ruleType}.title`
)}
</Heading>
<Text className="text-ui-fg-subtle txt-small mb-6">
{t(`promotions.fields.conditions.${ruleType}.description`)}
{t(
ruleType === "target-rules"
? `promotions.fields.conditions.${ruleType}.${applicationMethodTargetType}.description`
: `promotions.fields.conditions.${ruleType}.description`
)}
</Text>
{fields.map((fieldRule, index) => {
@@ -307,6 +326,7 @@ export const RulesFormField = ({
fieldRule={fieldRule}
attributes={attributes}
ruleType={ruleType}
applicationMethodTargetType={applicationMethodTargetType}
/>
</div>
</div>
@@ -678,60 +678,62 @@ export const CreatePromotionForm = () => {
<RulesFormField form={form} ruleType={"rules"} />
<Divider />
{!currentTemplate?.hiddenFields?.includes(
"application_method.type"
) && (
<Form.Field
control={form.control}
name="application_method.type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.value_type")}
</Form.Label>
<Form.Control>
<RadioGroup
className="flex gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"fixed"}
label={t(
"promotions.form.value_type.fixed.title"
)}
description={t(
"promotions.form.value_type.fixed.description"
)}
className={clx("basis-1/2")}
/>
<>
<Divider />
<Form.Field
control={form.control}
name="application_method.type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.value_type")}
</Form.Label>
<Form.Control>
<RadioGroup
className="flex gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"fixed"}
label={t(
"promotions.form.value_type.fixed.title"
)}
description={t(
"promotions.form.value_type.fixed.description"
)}
className={clx("basis-1/2")}
/>
<RadioGroup.ChoiceBox
value={"percentage"}
label={t(
"promotions.form.value_type.percentage.title"
)}
description={t(
"promotions.form.value_type.percentage.description"
)}
className={clx("basis-1/2")}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<RadioGroup.ChoiceBox
value={"percentage"}
label={t(
"promotions.form.value_type.percentage.title"
)}
description={t(
"promotions.form.value_type.percentage.description"
)}
className={clx("basis-1/2")}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</>
)}
<div className="flex gap-x-2 gap-y-4">
{!currentTemplate?.hiddenFields?.includes(
"application_method.value"
) && (
{!currentTemplate?.hiddenFields?.includes(
"application_method.value"
) && (
<>
<Divider />
<Form.Field
control={form.control}
name="application_method.value"
@@ -806,48 +808,48 @@ export const CreatePromotionForm = () => {
)
}}
/>
)}
</>
)}
{isTypeStandard && watchAllocation === "each" && (
<Form.Field
control={form.control}
name="application_method.max_quantity"
render={({ field }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.max_quantity.title")}
</Form.Label>
{isTypeStandard && watchAllocation === "each" && (
<Form.Field
control={form.control}
name="application_method.max_quantity"
render={({ field }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.max_quantity.title")}
</Form.Label>
<Form.Control>
<Input
{...form.register(
"application_method.max_quantity",
{ valueAsNumber: true }
)}
type="number"
min={1}
placeholder="3"
/>
</Form.Control>
<Form.Control>
<Input
{...form.register(
"application_method.max_quantity",
{ valueAsNumber: true }
)}
type="number"
min={1}
placeholder="3"
/>
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.max_quantity.description"
components={[<br key="break" />]}
/>
</Text>
</Form.Item>
)
}}
/>
)}
</div>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.max_quantity.description"
components={[<br key="break" />]}
/>
</Text>
</Form.Item>
)
}}
/>
)}
{isTypeStandard &&
!currentTemplate?.hiddenFields?.includes(
@@ -901,6 +903,7 @@ export const CreatePromotionForm = () => {
{!isTypeStandard && (
<>
<Divider />
<RulesFormField
form={form}
ruleType={"buy-rules"}
@@ -22,6 +22,12 @@ const buyGetHiddenFields = [
"is_tax_inclusive",
]
const freeShippingHiddenFields = [
...commonHiddenFields,
"application_method.value",
"is_tax_inclusive",
]
export const templates = [
{
id: "amount_off_products",
@@ -104,4 +110,21 @@ export const templates = [
},
},
},
{
id: "shipping_discount",
type: "standard",
title: "Free shipping",
description: "Applies a 100% discount to shipping fees",
hiddenFields: freeShippingHiddenFields,
defaults: {
is_automatic: "false",
type: "standard",
application_method: {
allocation: "across",
target_type: "shipping_methods",
type: "percentage",
value: 100,
},
},
},
]
@@ -1,5 +1,5 @@
import { PencilSquare } from "@medusajs/icons"
import { HttpTypes, PromotionRuleTypes } from "@medusajs/types"
import { ApplicationMethodTargetTypeValues, HttpTypes, PromotionRuleTypes, } from "@medusajs/types"
import { Badge, Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
@@ -44,11 +44,13 @@ function RuleBlock({ rule }: RuleProps) {
type PromotionConditionsSectionProps = {
rules: HttpTypes.AdminPromotionRule[]
ruleType: PromotionRuleTypes
applicationMethodTargetType: ApplicationMethodTargetTypeValues
}
export const PromotionConditionsSection = ({
rules,
ruleType,
applicationMethodTargetType,
}: PromotionConditionsSectionProps) => {
const { t } = useTranslation()
@@ -57,7 +59,11 @@ export const PromotionConditionsSection = ({
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-col">
<Heading>
{t(`promotions.fields.conditions.${ruleType}.title`)}
{t(
ruleType === "target-rules"
? `promotions.fields.conditions.${ruleType}.${applicationMethodTargetType}.title`
: `promotions.fields.conditions.${ruleType}.title`
)}
</Heading>
</div>
@@ -52,11 +52,15 @@ export const PromotionDetail = () => {
<PromotionConditionsSection
rules={targetRules || []}
ruleType={"target-rules"}
applicationMethodTargetType={
promotion.application_method.target_type || "items"
}
/>
{promotion.type === "buyget" && (
<PromotionConditionsSection
rules={buyRules || []}
ruleType={"buy-rules"}
applicationMethodTargetType={"items"}
/>
)}
</TwoColumnPage.Main>
@@ -1,13 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminPromotion } from "@medusajs/types"
import {
Button,
CurrencyInput,
Input,
RadioGroup,
Switch,
Text,
} from "@medusajs/ui"
import { Button, CurrencyInput, Input, RadioGroup, Text } from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { useEffect } from "react"
@@ -33,6 +26,7 @@ const EditPromotionSchema = zod.object({
value_type: zod.enum(["fixed", "percentage"]),
value: zod.number(),
allocation: zod.enum(["each", "across"]),
target_type: zod.enum(["order", "shipping_methods", "items"]),
})
export const EditPromotionDetailsForm = ({
@@ -50,6 +44,7 @@ export const EditPromotionDetailsForm = ({
value: promotion.application_method!.value,
allocation: promotion.application_method!.allocation,
value_type: promotion.application_method!.type,
target_type: promotion.application_method!.target_type,
},
resolver: zodResolver(EditPromotionSchema),
})
@@ -223,128 +218,139 @@ export const EditPromotionDetailsForm = ({
</Text>
</div>
<Form.Field
control={form.control}
name="value_type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("promotions.fields.value_type")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"fixed"}
label={t("promotions.form.value_type.fixed.title")}
description={t(
"promotions.form.value_type.fixed.description"
)}
/>
{promotion.application_method?.target_type !==
"shipping_methods" && (
<>
<Form.Field
control={form.control}
name="value_type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.value_type")}
</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"fixed"}
label={t(
"promotions.form.value_type.fixed.title"
)}
description={t(
"promotions.form.value_type.fixed.description"
)}
/>
<RadioGroup.ChoiceBox
value={"percentage"}
label={t(
"promotions.form.value_type.percentage.title"
)}
description={t(
"promotions.form.value_type.percentage.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<RadioGroup.ChoiceBox
value={"percentage"}
label={t(
"promotions.form.value_type.percentage.title"
)}
description={t(
"promotions.form.value_type.percentage.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="value"
render={({ field: { onChange, ...field } }) => {
const currencyCode =
promotion.application_method?.currency_code ?? "USD"
<Form.Field
control={form.control}
name="value"
render={({ field: { onChange, ...field } }) => {
const currencyCode =
promotion.application_method?.currency_code ?? "USD"
return (
<Form.Item>
<Form.Label>
{isFixedValueType
? t("fields.amount")
: t("fields.percentage")}
</Form.Label>
<Form.Control>
{isFixedValueType ? (
<CurrencyInput
min={0}
onValueChange={(val) =>
onChange(val ? parseInt(val) : null)
}
code={currencyCode}
symbol={getCurrencySymbol(currencyCode)}
{...field}
value={field.value}
/>
) : (
<DeprecatedPercentageInput
key="amount"
min={0}
max={100}
{...field}
value={field.value || ""}
onChange={(e) => {
onChange(
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="allocation"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("promotions.fields.allocation")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"each"}
label={t("promotions.form.allocation.each.title")}
description={t(
"promotions.form.allocation.each.description"
return (
<Form.Item>
<Form.Label>
{isFixedValueType
? t("fields.amount")
: t("fields.percentage")}
</Form.Label>
<Form.Control>
{isFixedValueType ? (
<CurrencyInput
min={0}
onValueChange={(val) =>
onChange(val ? parseInt(val) : null)
}
code={currencyCode}
symbol={getCurrencySymbol(currencyCode)}
{...field}
value={field.value}
/>
) : (
<DeprecatedPercentageInput
key="amount"
min={0}
max={100}
{...field}
value={field.value || ""}
onChange={(e) => {
onChange(
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="allocation"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.allocation")}
</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"each"}
label={t("promotions.form.allocation.each.title")}
description={t(
"promotions.form.allocation.each.description"
)}
/>
<RadioGroup.ChoiceBox
value={"across"}
label={t("promotions.form.allocation.across.title")}
description={t(
"promotions.form.allocation.across.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<RadioGroup.ChoiceBox
value={"across"}
label={t(
"promotions.form.allocation.across.title"
)}
description={t(
"promotions.form.allocation.across.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</>
)}
</div>
</RouteDrawer.Body>