feat(dashboard,types,promotion,medusa): hide fields on promotions depending on templates (#7746)

**what:**

- hides different fields depending on the chosen template
- remove operator values API
- fixes to edit promotion rules
- make currency optional for promotion

RESOLVES CORE-2297
This commit is contained in:
Riqwan Thamir
2024-06-18 18:47:42 +02:00
committed by GitHub
parent 1451112f08
commit 0d04c548f5
24 changed files with 523 additions and 389 deletions

View File

@@ -2,7 +2,6 @@ import { AdminGetPromotionsParams } from "@medusajs/medusa"
import {
AdminPromotionRuleListResponse,
AdminRuleAttributeOptionsListResponse,
AdminRuleOperatorOptionsListResponse,
AdminRuleValueOptionsListResponse,
} from "@medusajs/types"
import {
@@ -49,7 +48,6 @@ export const promotionsQueryKeys = {
ruleValue,
query,
],
listRuleOperators: () => [PROMOTIONS_QUERY_KEY],
}
export const usePromotion = (
@@ -107,26 +105,6 @@ export const usePromotions = (
return { ...data, ...rest }
}
export const usePromotionRuleOperators = (
options?: Omit<
UseQueryOptions<
AdminRuleOperatorOptionsListResponse,
Error,
AdminRuleOperatorOptionsListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: promotionsQueryKeys.listRuleOperators(),
queryFn: async () => client.promotions.listRuleOperators(),
...options,
})
return { ...data, ...rest }
}
export const usePromotionRuleAttributes = (
ruleType: string,
promotionType?: string,

View File

@@ -1151,15 +1151,15 @@
"description": "The code your customers will enter during checkout."
},
"value": {
"title": "Value"
"title": "Promotion Value"
},
"value_type": {
"fixed": {
"title": "Fixed amount",
"title": "Promotion Value",
"description": "eg. 100"
},
"percentage": {
"title": "Percentage",
"title": "Promotion Value",
"description": "eg. 8%"
}
}

View File

@@ -15,7 +15,6 @@ import {
PromotionListRes,
PromotionRes,
PromotionRuleAttributesListRes,
PromotionRuleOperatorsListRes,
} from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common"
@@ -112,12 +111,6 @@ async function listPromotionRuleValues(
)
}
async function listPromotionRuleOperators() {
return getRequest<PromotionRuleOperatorsListRes>(
`/admin/promotions/rule-operator-options`
)
}
export const promotions = {
retrieve: retrievePromotion,
list: listPromotions,
@@ -129,6 +122,5 @@ export const promotions = {
updateRules: updatePromotionRules,
listRules: listPromotionRules,
listRuleAttributes: listPromotionRuleAttributes,
listRuleOperators: listPromotionRuleOperators,
listRuleValues: listPromotionRuleValues,
}

View File

@@ -1,14 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PromotionDTO, PromotionRuleDTO } from "@medusajs/types"
import { Button } from "@medusajs/ui"
import i18n from "i18next"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { RouteDrawer } from "../../../../../../components/route-modal"
import { RuleTypeValues } from "../../edit-rules"
import { RulesFormField } from "../rules-form-field"
import { EditRules, EditRulesType } from "./form-schema"
type EditPromotionFormProps = {
promotion: PromotionDTO
@@ -18,31 +17,6 @@ type EditPromotionFormProps = {
isSubmitting: boolean
}
const EditRules = zod.object({
type: zod.string().optional(),
rules: zod.array(
zod.object({
id: zod.string().optional(),
attribute: zod
.string()
.min(1, { message: i18n.t("promotions.form.required") }),
operator: zod
.string()
.min(1, { message: i18n.t("promotions.form.required") }),
values: zod.union([
zod.number().min(1, { message: i18n.t("promotions.form.required") }),
zod.string().min(1, { message: i18n.t("promotions.form.required") }),
zod
.array(zod.string())
.min(1, { message: i18n.t("promotions.form.required") }),
]),
required: zod.boolean().optional(),
disguised: zod.boolean().optional(),
field_type: zod.string().optional(),
})
),
})
export const EditRulesForm = ({
promotion,
ruleType,
@@ -52,7 +26,7 @@ export const EditRulesForm = ({
const { t } = useTranslation()
const [rulesToRemove, setRulesToRemove] = useState([])
const form = useForm<zod.infer<typeof EditRules>>({
const form = useForm<EditRulesType>({
defaultValues: { rules: [], type: promotion.type },
resolver: zodResolver(EditRules),
})
@@ -64,11 +38,11 @@ export const EditRulesForm = ({
<form onSubmit={handleFormSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<RulesFormField
form={form}
form={form as any}
ruleType={ruleType}
setRulesToRemove={setRulesToRemove}
rulesToRemove={rulesToRemove}
promotionId={promotion.id}
promotion={promotion}
/>
</RouteDrawer.Body>

View File

@@ -0,0 +1,29 @@
import i18n from "i18next"
import { z } from "zod"
export const EditRules = z.object({
type: z.string().optional(),
rules: z.array(
z.object({
id: z.string().optional(),
attribute: z
.string()
.min(1, { message: i18n.t("promotions.form.required") }),
operator: z
.string()
.min(1, { message: i18n.t("promotions.form.required") }),
values: z.union([
z.number().min(1, { message: i18n.t("promotions.form.required") }),
z.string().min(1, { message: i18n.t("promotions.form.required") }),
z
.array(z.string())
.min(1, { message: i18n.t("promotions.form.required") }),
]),
required: z.boolean().optional(),
disguised: z.boolean().optional(),
field_type: z.string().optional(),
})
),
})
export type EditRulesType = z.infer<typeof EditRules>

View File

@@ -0,0 +1,17 @@
import { PromotionRuleResponse } from "@medusajs/types"
export const generateRuleAttributes = (rules?: PromotionRuleResponse[]) =>
(rules || []).map((rule) => ({
id: rule.id,
required: rule.required,
field_type: rule.field_type,
disguised: rule.disguised,
attribute: rule.attribute!,
operator: rule.operator!,
values:
rule.field_type === "number" || rule.operator === "eq"
? typeof rule.values === "object"
? rule.values[0]?.value
: rule.values
: rule?.values?.map((v: { value: string }) => v.value!),
}))

View File

@@ -42,11 +42,15 @@ export const EditRulesWrapper = ({
const { mutateAsync: updatePromotionRules, isPending } =
usePromotionUpdateRules(promotion.id, ruleType)
const handleSubmit = (rulesToRemove?: { id: string }[]) => {
const handleSubmit = (
rulesToRemove?: { id: string; disguised: boolean; attribute: string }[]
) => {
return async function (data: { rules: PromotionRuleResponse[] }) {
const applicationMethodData: Record<any, any> = {}
const { rules: allRules = [] } = data
const disguisedRules = allRules.filter((rule) => rule.disguised)
const disguisedRulesToRemove =
rulesToRemove?.filter((r) => r.disguised) || []
// For all the rules that were disguised, convert them to actual values in the
// database, they are currently all under application_method. If more of these are coming
@@ -55,6 +59,10 @@ export const EditRulesWrapper = ({
applicationMethodData[rule.attribute] = getRuleValue(rule)
}
for (const rule of disguisedRulesToRemove) {
applicationMethodData[rule.attribute] = null
}
// This variable will contain the rules that are actual rule objects, without the disguised
// objects
const rulesData = allRules.filter((rule) => !rule.disguised)
@@ -77,14 +85,14 @@ export const EditRulesWrapper = ({
return {
attribute: rule.attribute,
operator: rule.operator,
values: rule.operator === "eq" ? rule.values[0] : rule.values,
values: rule.values,
} as any
}),
}))
rulesToRemove?.length &&
(await removePromotionRules({
rule_ids: rulesToRemove.map((r) => r.id!),
rule_ids: rulesToRemove.map((r) => r.id).filter(Boolean),
}))
rulesToUpdate.length &&

View File

@@ -5,9 +5,5 @@ export const getRuleValue = (rule: PromotionRuleResponse) => {
return parseInt(rule.values as unknown as string)
}
if (rule.field_type === "select") {
return rule.values[0]
}
return rule.values
}

View File

@@ -1,5 +1,5 @@
import { XMarkMini } from "@medusajs/icons"
import { PromotionRuleResponse } from "@medusajs/types"
import { PromotionDTO } from "@medusajs/types"
import { Badge, Button, Heading, Select, Text } from "@medusajs/ui"
import { Fragment, useEffect } from "react"
import { useFieldArray, UseFormReturn, useWatch } from "react-hook-form"
@@ -7,15 +7,15 @@ import { useTranslation } from "react-i18next"
import { Form } from "../../../../../../components/common/form"
import {
usePromotionRuleAttributes,
usePromotionRuleOperators,
usePromotionRules,
} from "../../../../../../hooks/api/promotions"
import { CreatePromotionSchemaType } from "../../../../promotion-create/components/create-promotion-form/form-schema"
import { generateRuleAttributes } from "../edit-rules-form/utils"
import { RuleValueFormField } from "../rule-value-form-field"
import { requiredProductRule } from "./constants"
type RulesFormFieldType = {
promotionId?: string
promotion?: PromotionDTO
form: UseFormReturn<CreatePromotionSchemaType>
ruleType: "rules" | "target-rules" | "buy-rules"
setRulesToRemove?: any
@@ -26,32 +26,17 @@ type RulesFormFieldType = {
| "application_method.target_rules"
}
const generateRuleAttributes = (rules?: PromotionRuleResponse[]) =>
(rules || []).map((rule) => ({
id: rule.id,
required: rule.required,
field_type: rule.field_type,
disguised: rule.disguised,
attribute: rule.attribute!,
operator: rule.operator!,
values:
rule.field_type === "number"
? rule.values
: rule?.values?.map((v: { value: string }) => v.value!),
}))
export const RulesFormField = ({
form,
ruleType,
setRulesToRemove,
rulesToRemove,
scope = "rules",
promotionId,
promotion,
}: RulesFormFieldType) => {
const { t } = useTranslation()
const formData = form.getValues()
const { attributes } = usePromotionRuleAttributes(ruleType, formData.type)
const { operators } = usePromotionRuleOperators()
const { fields, append, remove, update, replace } = useFieldArray({
control: form.control,
@@ -59,21 +44,31 @@ export const RulesFormField = ({
keyName: scope,
})
const promotionType: string = useWatch({
const promotionType = useWatch({
control: form.control,
name: "type",
defaultValue: promotion?.type,
})
const applicationMethodType = useWatch({
control: form.control,
name: "application_method.type",
defaultValue: promotion?.application_method?.type,
})
const query: Record<string, string> = promotionType
? { promotion_type: promotionType }
? {
promotion_type: promotionType,
application_method_type: applicationMethodType,
}
: {}
const { rules, isLoading } = usePromotionRules(
promotionId || null,
promotion?.id || null,
ruleType,
query,
{
enabled: !!promotionType,
enabled: !!promotion?.id || (!!promotionType && !!applicationMethodType),
}
)
@@ -104,12 +99,18 @@ export const RulesFormField = ({
promotionType === "buyget"
) {
form.resetField(scope)
const rulesToAppend = promotionId
const rulesToAppend = promotion?.id
? rules
: [...rules, requiredProductRule]
replace(generateRuleAttributes(rulesToAppend) as any)
return
}
form.resetField(scope)
replace(generateRuleAttributes(rules) as any)
}, [promotionType, isLoading])
return (
@@ -165,10 +166,18 @@ export const RulesFormField = ({
<Select
{...field}
onValueChange={(e) => {
update(index, { ...fieldRule, values: [] })
const currentAttributeOption =
attributeOptions.find((ao) => ao.id === e)
update(index, {
...fieldRule,
values: [],
disguised:
currentAttributeOption?.disguised || false,
})
onChange(e)
}}
disabled={fieldRule.disguised}
disabled={fieldRule.required}
>
<Select.Trigger
ref={attributeRef}
@@ -206,14 +215,14 @@ export const RulesFormField = ({
key={`${identifier}.${scope}.${operatorsField.name}`}
{...operatorsField}
render={({ field: { onChange, ref, ...field } }) => {
const currentAttributeOption = attributes.find(
(attr) => attr.value === fieldRule.attribute
)
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Select
{...field}
onValueChange={onChange}
disabled={fieldRule.disguised}
>
<Select {...field} onValueChange={onChange}>
<Select.Trigger
ref={operatorRef}
className="bg-ui-bg-base"
@@ -222,16 +231,18 @@ export const RulesFormField = ({
</Select.Trigger>
<Select.Content>
{operators?.map((c, i) => (
<Select.Item
key={`${identifier}-operator-option-${i}`}
value={c.value}
>
<span className="text-ui-fg-subtle">
{c.label}
</span>
</Select.Item>
))}
{currentAttributeOption?.operators?.map(
(c, i) => (
<Select.Item
key={`${identifier}-operator-option-${i}`}
value={c.value}
>
<span className="text-ui-fg-subtle">
{c.label}
</span>
</Select.Item>
)
)}
</Select.Content>
</Select>
</Form.Control>
@@ -262,8 +273,7 @@ export const RulesFormField = ({
}`}
onClick={() => {
if (!fieldRule.required) {
fieldRule.id &&
setRulesToRemove &&
setRulesToRemove &&
setRulesToRemove([...rulesToRemove, fieldRule])
remove(index)

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Alert,
Badge,
Button,
clx,
CurrencyInput,
@@ -15,7 +16,13 @@ import { useForm, useWatch } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { z } from "zod"
import { PromotionRuleOperatorValues } from "@medusajs/types"
import {
ApplicationMethodAllocationValues,
ApplicationMethodTargetTypeValues,
ApplicationMethodTypeValues,
PromotionRuleOperatorValues,
PromotionTypeValues,
} from "@medusajs/types"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
@@ -33,6 +40,25 @@ import { Tab } from "./constants"
import { CreatePromotionSchema } from "./form-schema"
import { templates } from "./templates"
const defaultValues = {
campaign_id: undefined,
template_id: templates[0].id!,
campaign_choice: "none" as "none",
is_automatic: "false",
code: "",
type: "standard" as PromotionTypeValues,
rules: [],
application_method: {
allocation: "each" as ApplicationMethodAllocationValues,
type: "fixed" as ApplicationMethodTypeValues,
target_type: "items" as ApplicationMethodTargetTypeValues,
max_quantity: 1,
target_rules: [],
buy_rules: [],
},
campaign: undefined,
}
export const CreatePromotionForm = () => {
const [tab, setTab] = useState<Tab>(Tab.TYPE)
const [detailsValidated, setDetailsValidated] = useState(false)
@@ -41,24 +67,7 @@ export const CreatePromotionForm = () => {
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof CreatePromotionSchema>>({
defaultValues: {
campaign_id: undefined,
template_id: templates[0].id!,
campaign_choice: "none",
is_automatic: "false",
code: "",
type: "standard",
rules: [],
application_method: {
allocation: "each",
type: "fixed",
target_type: "items",
max_quantity: 1,
target_rules: [],
buy_rules: [],
},
campaign: undefined,
},
defaultValues,
resolver: zodResolver(CreatePromotionSchema),
})
@@ -172,7 +181,7 @@ export const CreatePromotionForm = () => {
name: "template_id",
})
useMemo(() => {
const currentTemplate = useMemo(() => {
const currentTemplate = templates.find(
(template) => template.id === watchTemplateId
)
@@ -181,6 +190,8 @@ export const CreatePromotionForm = () => {
return
}
form.reset({ ...defaultValues, template_id: watchTemplateId })
for (const [key, value] of Object.entries(currentTemplate.defaults)) {
if (typeof value === "object") {
for (const [subKey, subValue] of Object.entries(value)) {
@@ -190,6 +201,8 @@ export const CreatePromotionForm = () => {
form.setValue(key, value)
}
}
return currentTemplate
}, [watchTemplateId])
const watchValueType = useWatch({
@@ -215,6 +228,14 @@ export const CreatePromotionForm = () => {
})
const isTypeStandard = watchType === "standard"
const targetType = useWatch({
control: form.control,
name: "application_method.target_type",
})
const isTargetTypeOrder = targetType === "order"
const formData = form.getValues()
let campaignQuery: object = {}
@@ -385,9 +406,22 @@ export const CreatePromotionForm = () => {
<ProgressTabs.Content
value={Tab.PROMOTION}
className="flex flex-1 flex-col gap-10"
className="flex flex-1 flex-col gap-8"
>
<Heading level="h2">{t(`promotions.sections.details`)}</Heading>
<Heading level="h1" className="text-fg-base">
{t(`promotions.sections.details`)}
{currentTemplate?.title && (
<Badge
className="ml-2 align-middle"
color="grey"
size="2xsmall"
rounded="full"
>
{currentTemplate?.title}
</Badge>
)}
</Heading>
{form.formState.errors.root && (
<Alert
@@ -471,167 +505,14 @@ export const CreatePromotionForm = () => {
/>
</div>
<Form.Field
control={form.control}
name="type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("promotions.fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"standard"}
label={t("promotions.form.type.standard.title")}
description={t(
"promotions.form.type.standard.description"
)}
className={clx("basis-1/2")}
/>
<RadioGroup.ChoiceBox
value={"buyget"}
label={t("promotions.form.type.buyget.title")}
description={t(
"promotions.form.type.buyget.description"
)}
className={clx("basis-1/2")}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Divider />
<RulesFormField form={form} ruleType={"rules"} />
<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>
)
}}
/>
<div className="flex gap-y-4">
{!currentTemplate?.hiddenFields?.includes("type") && (
<Form.Field
control={form.control}
name="application_method.value"
render={({ field: { onChange, value, ...field } }) => {
const currencyCode =
form.getValues().application_method.currency_code
return (
<Form.Item className="basis-1/2">
<Form.Label
tooltip={
currencyCode || !isFixedValueType
? undefined
: t("promotions.fields.amount.tooltip")
}
>
{isFixedValueType
? t("fields.amount")
: t("fields.percentage")}
</Form.Label>
<Form.Control>
{isFixedValueType ? (
<CurrencyInput
{...field}
min={0}
onValueChange={(value) => {
onChange(value ? parseInt(value) : "")
}}
code={currencyCode}
symbol={
currencyCode
? getCurrencySymbol(currencyCode)
: ""
}
value={value}
disabled={!currencyCode}
/>
) : (
<PercentageInput
key="amount"
className="text-right"
min={0}
max={100}
{...field}
value={value}
onChange={(e) => {
onChange(
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
{isTypeStandard && (
<Form.Field
control={form.control}
name="application_method.allocation"
name="type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.allocation")}
</Form.Label>
<Form.Label>{t("promotions.fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex gap-y-3"
@@ -639,21 +520,19 @@ export const CreatePromotionForm = () => {
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"each"}
label={t("promotions.form.allocation.each.title")}
value={"standard"}
label={t("promotions.form.type.standard.title")}
description={t(
"promotions.form.allocation.each.description"
"promotions.form.type.standard.description"
)}
className={clx("basis-1/2")}
/>
<RadioGroup.ChoiceBox
value={"across"}
label={t(
"promotions.form.allocation.across.title"
)}
value={"buyget"}
label={t("promotions.form.type.buyget.title")}
description={t(
"promotions.form.allocation.across.description"
"promotions.form.type.buyget.description"
)}
className={clx("basis-1/2")}
/>
@@ -666,8 +545,141 @@ export const CreatePromotionForm = () => {
/>
)}
{isTypeStandard && watchAllocation === "each" && (
<div className="flex gap-y-4">
<Divider />
<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")}
/>
<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-y-4 gap-x-2">
{!currentTemplate?.hiddenFields?.includes(
"application_method.value"
) && (
<Form.Field
control={form.control}
name="application_method.value"
render={({ field: { onChange, value, ...field } }) => {
const currencyCode =
form.getValues().application_method.currency_code
return (
<Form.Item className="basis-1/2">
<Form.Label
tooltip={
currencyCode || !isFixedValueType
? undefined
: t("promotions.fields.amount.tooltip")
}
>
{t("promotions.form.value.title")}
</Form.Label>
<Form.Control>
{isFixedValueType ? (
<CurrencyInput
{...field}
min={0}
onValueChange={(value) => {
onChange(value ? parseInt(value) : "")
}}
code={currencyCode}
symbol={
currencyCode
? getCurrencySymbol(currencyCode)
: ""
}
value={value}
disabled={!currencyCode}
/>
) : (
<PercentageInput
key="amount"
className="text-right"
min={0}
max={100}
{...field}
value={value}
onChange={(e) => {
onChange(
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
)}
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey={
isFixedValueType
? "promotions.form.value_type.fixed.description"
: "promotions.form.value_type.percentage.description"
}
components={[<br key="break" />]}
/>
</Text>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
{isTypeStandard && watchAllocation === "each" && (
<Form.Field
control={form.control}
name="application_method.max_quantity"
@@ -705,10 +717,58 @@ export const CreatePromotionForm = () => {
)
}}
/>
</div>
)}
)}
</div>
<Divider />
{isTypeStandard &&
!currentTemplate?.hiddenFields?.includes(
"application_method.allocation"
) && (
<Form.Field
control={form.control}
name="application_method.allocation"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.allocation")}
</Form.Label>
<Form.Control>
<RadioGroup
className="flex 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"
)}
className={clx("basis-1/2")}
/>
<RadioGroup.ChoiceBox
value={"across"}
label={t(
"promotions.form.allocation.across.title"
)}
description={t(
"promotions.form.allocation.across.description"
)}
className={clx("basis-1/2")}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
{!isTypeStandard && (
<>
@@ -717,16 +777,19 @@ export const CreatePromotionForm = () => {
ruleType={"buy-rules"}
scope="application_method.buy_rules"
/>
<Divider />
</>
)}
<RulesFormField
form={form}
ruleType={"target-rules"}
scope="application_method.target_rules"
/>
{!isTargetTypeOrder && (
<>
<Divider />
<RulesFormField
form={form}
ruleType={"target-rules"}
scope="application_method.target_rules"
/>
</>
)}
</ProgressTabs.Content>
<ProgressTabs.Content

View File

@@ -29,7 +29,7 @@ export const CreatePromotionSchema = z
application_method: z.object({
allocation: z.enum(["each", "across"]),
value: z.number().min(0),
currency_code: z.string(),
currency_code: z.string().optional(),
max_quantity: z.number().optional().nullable(),
target_rules: RuleSchema,
buy_rules: RuleSchema,
@@ -54,3 +54,5 @@ export const CreatePromotionSchema = z
message: `required field`,
}
)
export type CreatePromotionSchemaType = z.infer<typeof CreatePromotionSchema>

View File

@@ -1,9 +1,16 @@
const commonHiddenFields = [
"type",
"application_method.type",
"application_method.allocation",
]
export const templates = [
{
id: "amount_off_products",
type: "standard",
title: "Amount off products",
description: "Discount specific products or collection of products",
hiddenFields: [...commonHiddenFields],
defaults: {
is_automatic: "false",
type: "standard",
@@ -19,6 +26,7 @@ export const templates = [
type: "standard",
title: "Amount off order",
description: "Discounts the total order amount",
hiddenFields: [...commonHiddenFields],
defaults: {
is_automatic: "false",
type: "standard",
@@ -34,6 +42,7 @@ export const templates = [
type: "standard",
title: "Percentage off product",
description: "Discounts a percentage off selected products",
hiddenFields: [...commonHiddenFields],
defaults: {
is_automatic: "false",
type: "standard",
@@ -49,12 +58,13 @@ export const templates = [
type: "standard",
title: "Percentage off order",
description: "Discounts a percentage of the total order amount",
hiddenFields: [...commonHiddenFields],
defaults: {
is_automatic: "false",
type: "standard",
application_method: {
allocation: "across",
target_type: "items",
target_type: "order",
type: "percentage",
},
},
@@ -64,6 +74,7 @@ export const templates = [
type: "buy_get",
title: "Buy X Get Y",
description: "Buy X product(s), get Y product(s)",
hiddenFields: [...commonHiddenFields, "application_method.value"],
defaults: {
is_automatic: "false",
type: "buyget",

View File

@@ -82,7 +82,7 @@ export const PromotionConditionsSection = ({
className="h-[180px]"
title="No records yet."
message="Please check back later or add a target condition today"
action={{ to: "/promotions", label: "Add condition" }}
action={{ to: `${ruleType}/edit`, label: "Add condition" }}
buttonVariant="transparentIconLeft"
/>
)}

View File

@@ -5,6 +5,11 @@ export interface RuleAttributeOptionsResponse {
field_type: string
required: boolean
disguised: boolean
operators: {
id: string
value: string
label: string
}[]
}
export interface AdminRuleAttributeOptionsListResponse {

View File

@@ -124,7 +124,7 @@ export interface CreateApplicationMethodDTO {
/**
* Currency of the value to apply.
*/
currency_code: string
currency_code?: string
/**
* The max quantity allowed in the cart for the associated promotion to be applied.

View File

@@ -33,9 +33,11 @@ export const GET = async (
})
const [promotion] = await remoteQuery(queryObject)
const ruleAttributes = getRuleAttributesMap(
promotion?.type || req.query.promotion_type
)[ruleType]
const ruleAttributes = getRuleAttributesMap({
promotionType: promotion?.type || req.query.promotion_type,
applicationMethodType:
promotion?.application_method?.type || req.query.application_method_type,
})[ruleType]
const promotionRules: any[] = []
if (dasherizedRuleType === RuleType.RULES) {
@@ -48,7 +50,6 @@ export const GET = async (
const transformedRules: AdminGetPromotionRulesRes = []
const disguisedRules = ruleAttributes.filter((attr) => !!attr.disguised)
const requiredRules = ruleAttributes.filter((attr) => !!attr.required)
for (const disguisedRule of disguisedRules) {
const getValues = () => {
@@ -65,18 +66,22 @@ export const GET = async (
return []
}
transformedRules.push({
id: undefined,
attribute: disguisedRule.id,
attribute_label: disguisedRule.label,
field_type: disguisedRule.field_type,
hydrate: disguisedRule.hydrate || false,
operator: RuleOperator.EQ,
operator_label: operatorsMap[RuleOperator.EQ].label,
values: getValues(),
disguised: true,
required: true,
})
const required = disguisedRule.required ?? true
const applicationMethod = promotion?.application_method
const recordValue = applicationMethod?.[disguisedRule.id]
if (required || recordValue) {
transformedRules.push({
...disguisedRule,
id: undefined,
attribute: disguisedRule.id,
attribute_label: disguisedRule.label,
operator: RuleOperator.EQ,
operator_label: operatorsMap[RuleOperator.EQ].label,
value: undefined,
values: getValues(),
})
}
continue
}
@@ -125,36 +130,15 @@ export const GET = async (
if (!currentRuleAttribute.hydrate) {
transformedRules.push({
...currentRuleAttribute,
...promotionRule,
attribute_label: currentRuleAttribute.label,
field_type: currentRuleAttribute.field_type,
operator_label:
operatorsMap[promotionRule.operator]?.label || promotionRule.operator,
disguised: false,
required: currentRuleAttribute.required || false,
})
}
}
if (requiredRules.length && !transformedRules.length) {
for (const requiredRule of requiredRules) {
transformedRules.push({
id: undefined,
attribute: requiredRule.value,
attribute_label: requiredRule.label,
operator: RuleOperator.EQ,
field_type: requiredRule.field_type,
operator_label: operatorsMap[RuleOperator.EQ].label,
values: [],
disguised: true,
required: true,
hydrate: false,
})
continue
}
}
res.json({
rules: transformedRules,
})

View File

@@ -14,7 +14,10 @@ export const GET = async (
validateRuleType(ruleType)
const attributes =
getRuleAttributesMap(req.query.promotion_type as string)[ruleType] || []
getRuleAttributesMap({
promotionType: req.query.promotion_type as string,
applicationMethodType: req.query.application_method_type as string,
})[ruleType] || []
res.json({
attributes,

View File

@@ -1,14 +0,0 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
import { operatorsMap } from "../utils"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
res.json({
operators: Object.values(operatorsMap),
})
}

View File

@@ -13,6 +13,13 @@ import {
} from "../../../utils"
import { AdminGetPromotionRuleParamsType } from "../../../validators"
/*
This endpoint returns all the potential values for rules (promotion rules, target rules and buy rules)
given an attribute of a rule. The response for different rule_attributes are returned uniformly
as an array of labels and values.
Eg. If the rule_attribute requested is "currency_code" for "rules" rule type, we return currencies
from the currency module.
*/
export const GET = async (
req: AuthenticatedMedusaRequest<AdminGetPromotionRuleParamsType>,
res: MedusaResponse
@@ -21,6 +28,7 @@ export const GET = async (
rule_type: ruleType,
rule_attribute_id: ruleAttributeId,
promotion_type: promotionType,
application_method_type: applicationMethodType,
} = req.params
const queryConfig = ruleQueryConfigurations[ruleAttributeId]
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
@@ -33,7 +41,12 @@ export const GET = async (
}
validateRuleType(ruleType)
validateRuleAttribute(promotionType, ruleType, ruleAttributeId)
validateRuleAttribute({
promotionType,
ruleType,
ruleAttributeId,
applicationMethodType,
})
const { rows } = await remoteQuery(
remoteQueryObjectFromString({

View File

@@ -1,4 +1,9 @@
import { PromotionType } from "@medusajs/utils"
import {
ApplicationMethodType,
PromotionType,
RuleOperator,
} from "@medusajs/utils"
import { operatorsMap } from "./operators-map"
export enum DisguisedRule {
APPLY_TO_QUANTITY = "apply_to_quantity",
@@ -7,21 +12,13 @@ export enum DisguisedRule {
}
const ruleAttributes = [
{
id: DisguisedRule.CURRENCY_CODE,
value: DisguisedRule.CURRENCY_CODE,
label: "Currency Code",
field_type: "select",
required: true,
disguised: true,
hydrate: true,
},
{
id: "customer_group",
value: "customer.groups.id",
label: "Customer Group",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
{
id: "region",
@@ -29,6 +26,7 @@ const ruleAttributes = [
label: "Region",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
{
id: "country",
@@ -36,6 +34,7 @@ const ruleAttributes = [
label: "Country",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
{
id: "sales_channel",
@@ -43,6 +42,7 @@ const ruleAttributes = [
label: "Sales Channel",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
]
@@ -53,6 +53,7 @@ const commonAttributes = [
label: "Product",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
{
id: "product_category",
@@ -60,6 +61,7 @@ const commonAttributes = [
label: "Product Category",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
{
id: "product_collection",
@@ -67,6 +69,7 @@ const commonAttributes = [
label: "Product Collection",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
{
id: "product_type",
@@ -74,6 +77,7 @@ const commonAttributes = [
label: "Product Type",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
{
id: "product_tag",
@@ -81,9 +85,21 @@ const commonAttributes = [
label: "Product Tag",
required: false,
field_type: "multiselect",
operators: Object.values(operatorsMap),
},
]
const currencyRule = {
id: DisguisedRule.CURRENCY_CODE,
value: DisguisedRule.CURRENCY_CODE,
label: "Currency Code",
field_type: "select",
required: true,
disguised: true,
hydrate: true,
operators: [operatorsMap[RuleOperator.EQ]],
}
const buyGetBuyRules = [
{
id: DisguisedRule.BUY_RULES_MIN_QUANTITY,
@@ -92,6 +108,7 @@ const buyGetBuyRules = [
field_type: "number",
required: true,
disguised: true,
operators: [operatorsMap[RuleOperator.EQ]],
},
]
@@ -103,16 +120,29 @@ const buyGetTargetRules = [
field_type: "number",
required: true,
disguised: true,
operators: [operatorsMap[RuleOperator.EQ]],
},
]
export const getRuleAttributesMap = (promotionType?: string) => {
export const getRuleAttributesMap = ({
promotionType,
applicationMethodType,
}: {
promotionType?: string
applicationMethodType?: string
}) => {
const map = {
rules: [...ruleAttributes],
"target-rules": [...commonAttributes],
"buy-rules": [...commonAttributes],
}
if (applicationMethodType === ApplicationMethodType.FIXED) {
map["rules"].push({ ...currencyRule })
} else {
map["rules"].push({ ...currencyRule, required: false })
}
if (promotionType === PromotionType.BUYGET) {
map["buy-rules"].push(...buyGetBuyRules)
map["target-rules"].push(...buyGetTargetRules)

View File

@@ -1,12 +1,21 @@
import { MedusaError } from "@medusajs/utils"
import { getRuleAttributesMap } from "./rule-attributes-map"
export function validateRuleAttribute(
promotionType: string | undefined,
ruleType: string,
export function validateRuleAttribute(attributes: {
promotionType: string | undefined
ruleType: string
ruleAttributeId: string
) {
const ruleAttributes = getRuleAttributesMap(promotionType)[ruleType] || []
applicationMethodType?: string
}) {
const { promotionType, ruleType, ruleAttributeId, applicationMethodType } =
attributes
const ruleAttributes =
getRuleAttributesMap({
promotionType,
applicationMethodType,
})[ruleType] || []
const ruleAttribute = ruleAttributes.find((obj) => obj.id === ruleAttributeId)
if (!ruleAttribute) {

View File

@@ -49,6 +49,7 @@ export type AdminGetPromotionRuleParamsType = z.infer<
>
export const AdminGetPromotionRuleParams = z.object({
promotion_type: z.string().optional(),
application_method_type: z.string().optional(),
})
export type AdminGetPromotionRuleTypeParamsType = z.infer<
@@ -57,6 +58,7 @@ export type AdminGetPromotionRuleTypeParamsType = z.infer<
export const AdminGetPromotionRuleTypeParams = createSelectParams().merge(
z.object({
promotion_type: z.string().optional(),
application_method_type: z.string().optional(),
})
)
@@ -105,7 +107,7 @@ export const AdminCreateApplicationMethod = z
.object({
description: z.string().optional(),
value: z.number(),
currency_code: z.string(),
currency_code: z.string().optional().nullable(),
max_quantity: z.number().optional().nullable(),
type: z.nativeEnum(ApplicationMethodType),
target_type: z.nativeEnum(ApplicationMethodTargetType),
@@ -125,7 +127,7 @@ export const AdminUpdateApplicationMethod = z
description: z.string().optional(),
value: z.number().optional(),
max_quantity: z.number().optional().nullable(),
currency_code: z.string().optional(),
currency_code: z.string().optional().nullable(),
type: z.nativeEnum(ApplicationMethodType).optional(),
target_type: z.nativeEnum(ApplicationMethodTargetType).optional(),
allocation: z.nativeEnum(ApplicationMethodAllocation).optional(),

View File

@@ -0,0 +1,22 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240617102917 extends Migration {
async up(): Promise<void> {
this.addSql(
'alter table "promotion_application_method" alter column "currency_code" type text using ("currency_code"::text);'
)
this.addSql(
'alter table "promotion_application_method" alter column "currency_code" drop not null;'
)
}
async down(): Promise<void> {
this.addSql(
'alter table "promotion_application_method" alter column "currency_code" type text using ("currency_code"::text);'
)
this.addSql(
'alter table "promotion_application_method" alter column "currency_code" set not null;'
)
}
}

View File

@@ -13,7 +13,7 @@ export interface CreateApplicationMethodDTO {
target_type: ApplicationMethodTargetTypeValues
allocation?: ApplicationMethodAllocationValues
value?: BigNumberInput
currency_code: string
currency_code?: string | null
promotion: Promotion | string | PromotionDTO
max_quantity?: BigNumberInput | null
buy_rules_min_quantity?: BigNumberInput | null
@@ -26,7 +26,7 @@ export interface UpdateApplicationMethodDTO {
target_type?: ApplicationMethodTargetTypeValues
allocation?: ApplicationMethodAllocationValues
value?: BigNumberInput
currency_code?: string
currency_code?: string | null
promotion?: Promotion | string | PromotionDTO
max_quantity?: BigNumberInput | null
buy_rules_min_quantity?: BigNumberInput | null