feat(dashboard,medusa): Promotion Campaign fixes (#7337)

* chore(medusa): strict zod versions in workspace

* feat(dashboard): add campaign create to promotion UI

* wip

* fix(medusa): Missing middlewares export (#7289)

* fix(docblock-generator): fix how type names created from Zod objects are inferred (#7292)

* feat(api-ref): show schema of a tag (#7297)

* feat: Add support for sendgrid and logger notification providers (#7290)

* feat: Add support for sendgrid and logger notification providers

* fix: changes based on PR review

* chore: add action to automatically label docs (#7284)

* chore: add action to automatically label docs

* removes the paths param

* docs: preparations for preview (#7267)

* configured base paths + added development banner

* fix typelist site url

* added navbar and sidebar badges

* configure algolia filters

* remove AI assistant

* remove unused imports

* change navbar text and badge

* lint fixes

* fix build error

* add to api reference rewrites

* fix build error

* fix build errors in user-guide

* fix feedback component

* add parent title to pagination

* added breadcrumbs component

* remove user-guide links

* resolve todos

* fix details about authentication

* change documentation title

* lint content

* chore: fix bug with form reset

* chore: address reviews

* chore: fix specs

* chore: loads of FE fixes + BE adds

* chore: add more polishes + reorg files

* chore: fixes to promotions modal

* chore: cleanup

* chore: cleanup

* chore: fix build

* chore: fkix cart spec

* chore: fix module tests

* chore: fix moar tests

* wip

* chore: templates + fixes + migrate currency

* chore: fix build, add validation for max_quantity

* chore: allow removing campaigns

* chore: fix specs

* chore: scope campaigns based on currency

* remove console logs

* chore: add translations + update keys

* chore: move over filesfrom v2 to routes

* chore(dashboard): Delete old translation files (#7423)

* feat(dashboard,admin-sdk,admin-shared,admin-vite-plugin): Add support for UI extensions (#7383)

* intial work

* update lock

* add routes and fix HMR of configs

* cleanup

* rm imports

* rm debug from plugin

* address feedback

* address feedback

* temp skip specs

---------

Co-authored-by: Adrien de Peretti <adrien.deperetti@gmail.com>
Co-authored-by: Shahed Nasser <shahednasser@gmail.com>
Co-authored-by: Stevche Radevski <sradevski@live.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2024-05-23 15:28:00 +02:00
committed by GitHub
co-authored by Adrien de Peretti Shahed Nasser Stevche Radevski Oli Juhl Kasper Fabricius Kristensen
parent 4a10821bfe
commit d1d23f1e8d
72 changed files with 5380 additions and 3473 deletions
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import { AdminGetPromotionsParams } from "@medusajs/medusa"
import { AdminRuleValueOptionsListResponse } from "@medusajs/types"
import {
QueryKey,
useMutation,
@@ -23,7 +24,6 @@ import {
PromotionRuleAttributesListRes,
PromotionRuleOperatorsListRes,
PromotionRulesListRes,
PromotionRuleValuesListRes,
} from "../../types/api-responses"
import { campaignsQueryKeys } from "./campaigns"
@@ -36,10 +36,11 @@ export const promotionsQueryKeys = {
ruleType,
],
listRuleAttributes: (ruleType: string) => [PROMOTIONS_QUERY_KEY, ruleType],
listRuleValues: (ruleType: string, ruleValue: string) => [
listRuleValues: (ruleType: string, ruleValue: string, query: object) => [
PROMOTIONS_QUERY_KEY,
ruleType,
ruleValue,
query,
],
listRuleOperators: () => [PROMOTIONS_QUERY_KEY],
}
@@ -142,19 +143,25 @@ export const usePromotionRuleAttributes = (
export const usePromotionRuleValues = (
ruleType: string,
ruleValue: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
PromotionListRes,
AdminRuleValueOptionsListResponse,
Error,
PromotionRuleValuesListRes,
AdminRuleValueOptionsListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: promotionsQueryKeys.listRuleValues(ruleType, ruleValue),
queryFn: async () => client.promotions.listRuleValues(ruleType, ruleValue),
queryKey: promotionsQueryKeys.listRuleValues(
ruleType,
ruleValue,
query || {}
),
queryFn: async () =>
client.promotions.listRuleValues(ruleType, ruleValue, query),
...options,
})
@@ -936,6 +936,9 @@
},
"promotions": {
"domain": "Promotions",
"sections": {
"details": "Promotion Details"
},
"fields": {
"method": "Method",
"type": "Type",
@@ -945,6 +948,9 @@
"allocation": "Allocation",
"addCondition": "Add condition",
"clearAll": "Clear all",
"amount": {
"tooltip": "Select the currency code to enable setting the amount"
},
"conditions": {
"rules": {
"title": "Who can use this code?",
@@ -979,6 +985,9 @@
"addToCampaign": {
"title": "Add Promotion To Campaign"
},
"campaign_currency": {
"tooltip": "Currency is carried over from the promotion. Change it on the promotions tab."
},
"form": {
"required": "Required",
"and": "AND",
@@ -1083,8 +1092,12 @@
"identifier": "Identifier",
"start_date": "Start date",
"end_date": "End date",
"total_spend": "Total spend",
"budget_limit": "Budget limit"
"total_spend": "Budget spent",
"total_used": "Budget used",
"budget_limit": "Budget limit",
"campaign_id": {
"hint": "A list of campaigns with the same currency code as the promotion"
}
},
"budget": {
"create": {
@@ -1115,6 +1128,8 @@
"description": "You are about to remove {{count}} promotion(s) from the campaign. This action cannot be undone."
},
"alreadyAdded": "This promotion has already been added to the campaign.",
"alreadyAddedDiffCampaign": "This promotion has already been added to a different campaign ({{name}}).",
"currencyMismatch": "Currency of the promotion and campaign doesn't match",
"toast": {
"success": "Successfully added {{count}} promotion(s) to campaign"
}
@@ -1,5 +1,8 @@
import { AdminGetPromotionsParams } from "@medusajs/medusa"
import {
AdminGetPromotionsParams,
AdminGetPromotionsRuleValueParams,
} from "@medusajs/medusa"
import { AdminRuleValueOptionsListResponse } from "@medusajs/types"
import {
BatchAddPromotionRulesReq,
BatchRemovePromotionRulesReq,
@@ -13,7 +16,6 @@ import {
PromotionRes,
PromotionRuleAttributesListRes,
PromotionRuleOperatorsListRes,
PromotionRuleValuesListRes,
} from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common"
@@ -79,7 +81,7 @@ async function removePromotionRules(
)
}
async function listPromotionRules(id: string, ruleType: string) {
async function listPromotionRules(id: string | null, ruleType: string) {
return getRequest<PromotionRuleAttributesListRes>(
`/admin/promotions/${id}/${ruleType}`
)
@@ -91,9 +93,14 @@ async function listPromotionRuleAttributes(ruleType: string) {
)
}
async function listPromotionRuleValues(ruleType: string, ruleValue: string) {
return getRequest<PromotionRuleValuesListRes>(
`/admin/promotions/rule-value-options/${ruleType}/${ruleValue}`
async function listPromotionRuleValues(
ruleType: string,
ruleValue: string,
query?: AdminGetPromotionsRuleValueParams
) {
return getRequest<AdminRuleValueOptionsListResponse>(
`/admin/promotions/rule-value-options/${ruleType}/${ruleValue}`,
query
)
}
@@ -67,12 +67,7 @@ export const AddCampaignPromotionsForm = ({
promotions,
count,
isPending: isLoading,
isError,
error,
} = usePromotions(
{ ...searchParams, campaign_id: "null" },
{ placeholderData: keepPreviousData }
)
} = usePromotions({ ...searchParams }, { placeholderData: keepPreviousData })
const columns = useColumns()
const filters = usePromotionTableFilters()
@@ -91,7 +86,11 @@ export const AddCampaignPromotionsForm = ({
state: rowSelection,
updater,
},
meta: { campaignId: campaign.id },
meta: {
campaignId: campaign.id,
currencyCode: campaign?.budget?.currency_code,
budgetType: campaign?.budget?.type,
},
})
const handleSubmit = form.handleSubmit(async (values) => {
@@ -177,24 +176,33 @@ const useColumns = () => {
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
/>
)
},
cell: ({ row, table }) => {
const { campaignId } = table.options.meta as {
const { campaignId, currencyCode, budgetType } = table.options
.meta as {
campaignId: string
currencyCode: string
budgetType: string
}
const isTypeSpend = budgetType === "spend"
const isAdded = row.original.campaign_id === campaignId
const isAddedToADiffCampaign =
!!row.original.campaign_id &&
row.original.campaign_id !== campaignId
const currencyMismatch =
isTypeSpend &&
row.original.application_method?.currency_code !== currencyCode
const isSelected = row.getIsSelected() || isAdded
const isIndeterminate = currencyMismatch || isAddedToADiffCampaign
const Component = (
<Checkbox
checked={isSelected}
disabled={isAdded}
checked={isIndeterminate ? "indeterminate" : isSelected}
disabled={isAdded || isAddedToADiffCampaign || currencyMismatch}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
@@ -202,6 +210,30 @@ const useColumns = () => {
/>
)
if (isAddedToADiffCampaign) {
return (
<Tooltip
content={t("campaigns.promotions.alreadyAddedDiffCampaign", {
name: row.original?.campaign?.name!,
})}
side="right"
>
{Component}
</Tooltip>
)
}
if (currencyMismatch) {
return (
<Tooltip
content={t("campaigns.promotions.currencyMismatch")}
side="right"
>
{Component}
</Tooltip>
)
}
if (isAdded) {
return (
<Tooltip
@@ -1,14 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { CampaignResponse } from "@medusajs/types"
import {
Button,
clx,
CurrencyInput,
Input,
RadioGroup,
toast,
} from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { Button, CurrencyInput, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
@@ -24,8 +17,7 @@ type EditCampaignBudgetFormProps = {
}
const EditCampaignSchema = zod.object({
limit: zod.number().min(0),
type: zod.enum(["spend", "usage"]).optional(),
limit: zod.number().min(0).optional().nullable(),
})
export const EditCampaignBudgetForm = ({
@@ -36,8 +28,7 @@ export const EditCampaignBudgetForm = ({
const form = useForm<zod.infer<typeof EditCampaignSchema>>({
defaultValues: {
limit: campaign?.budget?.limit,
type: campaign?.budget?.type || "spend",
limit: campaign?.budget?.limit || undefined,
},
resolver: zodResolver(EditCampaignSchema),
})
@@ -49,8 +40,7 @@ export const EditCampaignBudgetForm = ({
{
id: campaign.id,
budget: {
limit: data.limit,
type: data.type,
limit: data.limit ? data.limit : null,
},
},
{
@@ -74,63 +64,11 @@ export const EditCampaignBudgetForm = ({
)
})
const watchValueType = useWatch({
control: form.control,
name: "type",
})
const isTypeSpend = watchValueType === "spend"
return (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
<RouteDrawer.Body>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.budget.fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"spend" === field.value,
})}
value={"spend"}
label={t("campaigns.budget.type.spend.title")}
description={t(
"campaigns.budget.type.spend.description"
)}
/>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"usage" === field.value,
})}
value={"usage"}
label={t("campaigns.budget.type.usage.title")}
description={t(
"campaigns.budget.type.usage.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="limit"
@@ -142,16 +80,22 @@ export const EditCampaignBudgetForm = ({
</Form.Label>
<Form.Control>
{isTypeSpend ? (
{campaign.budget?.type === "spend" ? (
<CurrencyInput
min={0}
onValueChange={(value) =>
onChange(value ? parseInt(value) : "")
onChange(value ? parseInt(value) : null)
}
code={campaign.budget?.currency_code}
symbol={
campaign.budget?.currency_code
? getCurrencySymbol(
campaign.budget?.currency_code
)
: ""
}
code={campaign.currency}
symbol={getCurrencySymbol(campaign.currency)}
{...field}
value={value}
value={value || undefined}
/>
) : (
<Input
@@ -15,26 +15,29 @@ import { CreateCampaignFormFields } from "../../../common/components/create-camp
export const CreateCampaignSchema = zod.object({
name: zod.string().min(1),
description: zod.string().optional(),
currency: zod.string().min(1),
campaign_identifier: zod.string().min(1),
starts_at: zod.date().optional(),
ends_at: zod.date().optional(),
budget: zod.object({
limit: zod.number().min(0),
type: zod.enum(["spend", "usage"]),
}),
budget: zod
.object({
limit: zod.number().min(0).optional().nullable(),
type: zod.enum(["spend", "usage"]),
currency_code: zod.string().optional().nullable(),
})
.refine((data) => data.type !== "spend" || data.currency_code, {
path: ["currency_code"],
message: `required field`,
}),
})
export const defaultCampaignValues = {
name: "",
description: "",
currency: "",
campaign_identifier: "",
starts_at: undefined,
ends_at: undefined,
budget: {
type: "spend" as CampaignBudgetTypeValues,
limit: undefined,
currency_code: null,
limit: null,
},
}
@@ -53,13 +56,13 @@ export const CreateCampaignForm = () => {
{
name: data.name,
description: data.description,
currency: data.currency,
campaign_identifier: data.campaign_identifier,
starts_at: data.starts_at,
ends_at: data.ends_at,
budget: {
type: data.budget.type,
limit: data.budget.limit,
limit: data.budget.limit ? data.budget.limit : undefined,
currency_code: data.budget.currency_code,
},
},
{
@@ -53,9 +53,11 @@ export const CampaignBudget = ({ campaign }: CampaignBudgetProps) => {
<Trans
i18nKey="campaigns.totalSpend"
values={{
amount: campaign?.budget?.limit || 0,
amount: campaign?.budget?.limit || "no limit",
currency:
campaign?.budget?.type === "spend" ? campaign.currency : "",
campaign?.budget?.type === "spend" && campaign?.budget.limit
? campaign.budget?.currency_code
: "",
}}
components={[
<span
@@ -30,7 +30,6 @@ export const CampaignGeneralSection = ({
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const { mutateAsync } = useDeleteCampaign(campaign.id)
const handleDelete = async () => {
@@ -124,18 +123,20 @@ export const CampaignGeneralSection = ({
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.currency")}
</Text>
<div>
<Badge size="xsmall">{campaign.currency}</Badge>
<Text className="inline pl-3" size="small" leading="compact">
{currencies[campaign.currency]?.name}
{campaign?.budget && campaign.budget.type === "spend" && (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.currency")}
</Text>
<div>
<Badge size="xsmall">{campaign?.budget.currency_code}</Badge>
<Text className="inline pl-3" size="small" leading="compact">
{currencies[campaign?.budget.currency_code?.toUpperCase()]?.name}
</Text>
</div>
</div>
</div>
)}
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
@@ -20,7 +20,9 @@ export const CampaignSpend = ({ campaign }: CampaignSpendProps) => {
</div>
<Heading level="h3" className="font-normal text-ui-fg-subtle">
{t("campaigns.fields.total_spend")}
{campaign.budget?.type === "spend"
? t("campaigns.fields.total_spend")
: t("campaigns.fields.total_used")}
</Heading>
</div>
@@ -35,7 +37,9 @@ export const CampaignSpend = ({ campaign }: CampaignSpendProps) => {
values={{
amount: campaign?.budget?.used || 0,
currency:
campaign?.budget?.type === "spend" ? campaign.currency : "",
campaign?.budget?.type === "spend"
? campaign?.budget?.currency_code
: "",
}}
components={[
<span
@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { CampaignResponse } from "@medusajs/types"
import { Button, DatePicker, Input, Select, toast } from "@medusajs/ui"
import { Button, DatePicker, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
@@ -10,7 +10,6 @@ import {
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdateCampaign } from "../../../../../hooks/api/campaigns"
import { currencies } from "../../../../../lib/currencies"
type EditCampaignFormProps = {
campaign: CampaignResponse
@@ -19,7 +18,6 @@ type EditCampaignFormProps = {
const EditCampaignSchema = zod.object({
name: zod.string(),
description: zod.string().optional(),
currency: zod.string().optional(),
campaign_identifier: zod.string().optional(),
starts_at: zod.date().optional(),
ends_at: zod.date().optional(),
@@ -33,7 +31,6 @@ export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
defaultValues: {
name: campaign.name || "",
description: campaign.description || "",
currency: campaign.currency || "",
campaign_identifier: campaign.campaign_identifier || "",
starts_at: campaign.starts_at ? new Date(campaign.starts_at) : undefined,
ends_at: campaign.ends_at ? new Date(campaign.ends_at) : undefined,
@@ -49,7 +46,6 @@ export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
id: campaign.id,
name: data.name,
description: data.description,
currency: data.currency,
campaign_identifier: data.campaign_identifier,
starts_at: data.starts_at,
ends_at: data.ends_at,
@@ -134,37 +130,6 @@ export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
}}
/>
<Form.Field
control={form.control}
name="currency"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.currency")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{Object.values(currencies).map((currency) => (
<Select.Item
value={currency.code}
key={currency.code}
>
{currency.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="starts_at"
@@ -12,10 +12,12 @@ import { useEffect } from "react"
import { useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { useStore } from "../../../../../hooks/api/store"
import { currencies, getCurrencySymbol } from "../../../../../lib/currencies"
export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
const { t } = useTranslation()
const { store } = useStore()
const watchValueType = useWatch({
control: form.control,
@@ -26,13 +28,38 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
const currencyValue = useWatch({
control: form.control,
name: `${fieldScope}currency`,
name: `${fieldScope}budget.currency_code`,
})
const watchPromotionCurrencyCode = useWatch({
control: form.control,
name: "application_method.currency_code",
})
useEffect(() => {
form.setValue(`${fieldScope}budget.limit`, undefined)
form.setValue(`${fieldScope}budget.limit`, null)
if (watchValueType === "spend") {
form.setValue(`campaign.budget.currency_code`, watchPromotionCurrencyCode)
}
if (watchValueType === "usage") {
form.setValue(`campaign.budget.currency_code`, null)
}
}, [watchValueType])
if (watchPromotionCurrencyCode) {
const formCampaignBudget = form.getValues().campaign?.budget
const formCampaignCurrency = formCampaignBudget?.currency_code
if (
formCampaignBudget?.type === "spend" &&
formCampaignCurrency !== watchPromotionCurrencyCode
) {
form.setValue("campaign.budget.currency_code", watchPromotionCurrencyCode)
}
}
return (
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
@@ -98,33 +125,7 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
}}
/>
<Form.Field
control={form.control}
name={`${fieldScope}currency`}
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.currency")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{Object.values(currencies).map((currency) => (
<Select.Item value={currency.code} key={currency.code}>
{currency.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div></div>
<Form.Field
control={form.control}
@@ -197,8 +198,8 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"spend" === field.value,
})}
value={"spend"}
@@ -207,8 +208,8 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
/>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"usage" === field.value,
})}
value={"usage"}
@@ -224,13 +225,72 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{isTypeSpend && (
<Form.Field
control={form.control}
name={`${fieldScope}budget.currency_code`}
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label
tooltip={
fieldScope.length
? t("promotions.campaign_currency.tooltip")
: undefined
}
>
{t("fields.currency")}
</Form.Label>
<Form.Control>
<Select
{...field}
onValueChange={onChange}
disabled={!!fieldScope.length}
>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{Object.values(currencies)
.filter((currency) =>
store?.supported_currency_codes?.includes(
currency.code.toLocaleLowerCase()
)
)
.map((currency) => (
<Select.Item
value={currency.code.toLowerCase()}
key={currency.code}
>
{currency.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
<Form.Field
control={form.control}
name={`${fieldScope}budget.limit`}
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>{t("campaigns.budget.fields.limit")}</Form.Label>
<Form.Label
tooltip={
currencyValue
? undefined
: t("promotions.fields.amount.tooltip")
}
>
{t("campaigns.budget.fields.limit")}
</Form.Label>
<Form.Control>
{isTypeSpend ? (
@@ -245,13 +305,14 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
}
{...field}
value={value}
disabled={!currencyValue}
/>
) : (
<Input
type="number"
key="usage"
min={0}
{...field}
min={0}
value={value}
onChange={(e) => {
onChange(
@@ -1,17 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { XMarkMini } from "@medusajs/icons"
import { PromotionDTO, PromotionRuleDTO } from "@medusajs/types"
import { Badge, Button, Heading, Input, Select, Text } from "@medusajs/ui"
import { Button } from "@medusajs/ui"
import i18n from "i18next"
import { Fragment, useState } from "react"
import { useState } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../../components/common/form"
import { Combobox } from "../../../../../../components/inputs/combobox"
import { RouteDrawer } from "../../../../../../components/route-modal"
import { usePromotionRuleValues } from "../../../../../../hooks/api/promotions"
import { RuleTypeValues } from "../../edit-rules"
import { RulesFormField } from "../rules-form-field"
import { getDisguisedRules } from "./utils"
type EditPromotionFormProps = {
@@ -47,307 +44,6 @@ const EditRules = zod.object({
),
})
const RuleValueFormField = ({
identifier,
scope,
valuesFields,
valuesRef,
fieldRule,
attributes,
ruleType,
}) => {
const attribute = attributes?.find(
(attr) => attr.value === fieldRule.attribute
)
const { values: options = [] } = usePromotionRuleValues(
ruleType,
attribute?.id,
{
enabled: !!attribute?.id && !attribute.disguised,
}
)
return (
<Form.Field
key={`${identifier}.${scope}.${valuesFields.name}-${fieldRule.attribute}`}
{...valuesFields}
render={({ field: { onChange, ref, ...field } }) => {
if (fieldRule.field_type === "number") {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Input
{...field}
type="number"
onChange={onChange}
className="bg-ui-bg-base"
ref={valuesRef}
min={1}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
} else if (fieldRule.field_type === "text") {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Input
{...field}
onChange={onChange}
className="bg-ui-bg-base"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
} else {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Combobox
{...field}
placeholder="Select Values"
options={options}
onChange={onChange}
className="bg-ui-bg-base"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}
}}
/>
)
}
export const RulesFormField = ({
form,
ruleType,
fields,
attributes,
operators,
removeRule,
updateRule,
appendRule,
setRulesToRemove,
rulesToRemove,
scope = "rules",
}) => {
const { t } = useTranslation()
return (
<div className="flex flex-col">
<Heading level="h2" className="mb-2">
{t(`promotions.fields.conditions.${ruleType}.title`)}
</Heading>
<Text className="text-ui-fg-subtle txt-small mb-10">
{t(`promotions.fields.conditions.${ruleType}.description`)}
</Text>
{fields.map((fieldRule, index) => {
const identifier = fieldRule.id
const { ref: attributeRef, ...attributeFields } = form.register(
`${scope}.${index}.attribute`
)
const { ref: operatorRef, ...operatorFields } = form.register(
`${scope}.${index}.operator`
)
const { ref: valuesRef, ...valuesFields } = form.register(
`${scope}.${index}.values`
)
return (
<Fragment key={`${fieldRule.id}.${index}`}>
<div className="bg-ui-bg-subtle border-ui-border-base flex flex-row gap-2 rounded-xl border px-2 py-2">
<div className="grow">
<Form.Field
key={`${identifier}.${scope}.${attributeFields.name}`}
{...attributeFields}
render={({ field: { onChange, ref, ...field } }) => {
const existingAttributes =
fields?.map((field) => field.attribute) || []
const attributeOptions =
attributes?.filter((attr) => {
if (attr.value === fieldRule.attribute) {
return true
}
return !existingAttributes.includes(attr.value)
}) || []
return (
<Form.Item className="mb-2">
{fieldRule.required && (
<p className="text text-ui-fg-muted txt-small">
{t("promotions.form.required")}
</p>
)}
<Form.Control>
<Select
{...field}
onValueChange={(e) => {
updateRule(index, { ...fieldRule, values: [] })
onChange(e)
}}
disabled={fieldRule.required}
>
<Select.Trigger
ref={attributeRef}
className="bg-ui-bg-base"
>
<Select.Value
placeholder={t(
"promotions.form.selectAttribute"
)}
/>
</Select.Trigger>
<Select.Content>
{attributeOptions?.map((c, i) => (
<Select.Item
key={`${identifier}-attribute-option-${i}`}
value={c.value}
>
<span className="text-ui-fg-subtle">
{c.label}
</span>
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex gap-2">
<Form.Field
key={`${identifier}.${scope}.${operatorFields.name}`}
{...operatorFields}
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Select
{...field}
onValueChange={onChange}
disabled={fieldRule.required}
>
<Select.Trigger
ref={operatorRef}
className="bg-ui-bg-base"
>
<Select.Value placeholder="Select Operator" />
</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>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<RuleValueFormField
identifier={identifier}
scope={scope}
valuesFields={valuesFields}
valuesRef={valuesRef}
fieldRule={fieldRule}
attributes={attributes}
ruleType={ruleType}
/>
</div>
</div>
<div className="flex-none self-center px-1">
<XMarkMini
className={`text-ui-fg-muted cursor-pointer ${
fieldRule.required ? "invisible" : "visible"
}`}
onClick={() => {
if (!fieldRule.required) {
fieldRule.id &&
setRulesToRemove &&
setRulesToRemove([...rulesToRemove, fieldRule])
removeRule(index)
}
}}
/>
</div>
</div>
{index < fields.length - 1 && (
<div className="relative px-6 py-3">
<div className="border-ui-border-strong absolute bottom-0 left-[40px] top-0 z-[-1] w-px bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-repeat-y"></div>
<Badge size="2xsmall" className=" text-xs">
{t("promotions.form.and")}
</Badge>
</div>
)}
</Fragment>
)
})}
<div className="mt-8">
<Button
type="button"
variant="secondary"
className="inline-block"
onClick={() => {
appendRule({
attribute: "",
operator: "",
values: [],
required: false,
})
}}
>
{t("promotions.fields.addCondition")}
</Button>
<Button
type="button"
variant="transparent"
className="text-ui-fg-muted hover:text-ui-fg-subtle ml-2 inline-block"
onClick={() => {
const indicesToRemove = fields
.map((field, index) => (field.required ? null : index))
.filter((f) => f !== null)
setRulesToRemove &&
setRulesToRemove(fields.filter((f) => !f.required))
removeRule(indicesToRemove)
}}
>
{t("promotions.fields.clearAll")}
</Button>
</div>
</div>
)
}
export const EditRulesForm = ({
promotion,
rules,
@@ -369,10 +65,11 @@ export const EditRulesForm = ({
rules: [...disguisedRules, ...rules].map((rule) => ({
id: rule.id,
required: requiredAttributeValues.includes(rule.attribute),
field_type: rule.field_type,
attribute: rule.attribute!,
operator: rule.operator!,
values: rule?.values?.map((v: { value: string }) => v.value!),
values: Array.isArray(rule?.values)
? rule?.values?.map((v: any) => v.value!)
: rule.values!,
})),
},
resolver: zodResolver(EditRules),
@@ -22,6 +22,22 @@ export function getDisguisedRules(
(attr) => attr.id === "buy_rules_min_quantity"
)
const currencyCodeRule = requiredAttributes.find(
(attr) => attr.id === "currency_code"
)
if (ruleType === RuleType.RULES) {
return [
{
id: "currency_code",
attribute: "currency_code",
operator: "eq",
required: currencyCodeRule?.required,
values: promotion?.application_method?.currency_code?.toLowerCase(),
},
]
}
if (ruleType === RuleType.TARGET_RULES) {
return [
{
@@ -29,8 +45,7 @@ export function getDisguisedRules(
attribute: "apply_to_quantity",
operator: "eq",
required: applyToQuantityRule?.required,
field_type: applyToQuantityRule?.field_type,
values: [{ value: promotion?.application_method?.apply_to_quantity }],
values: promotion?.application_method?.apply_to_quantity,
},
]
}
@@ -42,7 +57,6 @@ export function getDisguisedRules(
attribute: "buy_rules_min_quantity",
operator: "eq",
required: buyRulesMinQuantityRule?.required,
field_type: buyRulesMinQuantityRule?.field_type,
values: [
{ value: promotion?.application_method?.buy_rules_min_quantity },
],
@@ -1,4 +1,8 @@
import { PromotionDTO, PromotionRuleDTO } from "@medusajs/types"
import {
CreatePromotionRuleDTO,
PromotionDTO,
PromotionRuleDTO,
} from "@medusajs/types"
import { useRouteModal } from "../../../../../../components/route-modal"
import {
usePromotionAddRules,
@@ -58,11 +62,10 @@ export const EditRulesWrapper = ({
const { mutateAsync: updatePromotionRules, isPending } =
usePromotionUpdateRules(promotion.id, ruleType)
const handleSubmit = (rulesToRemove?: any[]) => {
return async function (data) {
const handleSubmit = (rulesToRemove?: { id: string }[]) => {
return async function (data: { rules: PromotionRuleDTO[] }) {
const applicationMethodData: Record<any, any> = {}
const { rules: allRules = [] } = data
const disguisedRulesData = allRules.filter((rule) =>
disguisedRules.map((rule) => rule.id).includes(rule.id!)
)
@@ -71,7 +74,14 @@ export const EditRulesWrapper = ({
// database, they are currently all under application_method. If more of these are coming
// up, abstract this away.
for (const rule of disguisedRulesData) {
applicationMethodData[rule.id!] = parseInt(rule.values as string)
const currentAttribute = attributes?.find(
(attr) => attr.value === rule.attribute
)
applicationMethodData[rule.id!] =
currentAttribute?.field_type === "number"
? parseInt(rule.values as unknown as string)
: rule.values
}
// This variable will contain the rules that are actual rule objects, without the disguised
@@ -80,13 +90,17 @@ export const EditRulesWrapper = ({
(rule) => !disguisedRules.map((rule) => rule.id).includes(rule.id!)
)
const rulesToCreate = rulesData.filter((rule) => !("id" in rule))
const rulesToCreate: CreatePromotionRuleDTO[] = rulesData.filter(
(rule) => !("id" in rule)
)
const rulesToUpdate = rulesData.filter(
(rule) => typeof rule.id === "string"
(rule: { id: string }) => typeof rule.id === "string"
)
if (Object.keys(applicationMethodData).length) {
await updatePromotion({ application_method: applicationMethodData })
await updatePromotion({
application_method: applicationMethodData,
} as any)
}
rulesToCreate.length &&
@@ -95,7 +109,7 @@ export const EditRulesWrapper = ({
return {
attribute: rule.attribute,
operator: rule.operator,
values: rule.values,
values: rule.operator === "eq" ? rule.values[0] : rule.values,
} as any
}),
}))
@@ -107,13 +121,13 @@ export const EditRulesWrapper = ({
rulesToUpdate.length &&
(await updatePromotionRules({
rules: rulesToUpdate.map((rule) => {
rules: rulesToUpdate.map((rule: PromotionRuleDTO) => {
return {
id: rule.id!,
attribute: rule.attribute,
operator: rule.operator,
values: rule.values,
} as any
values: rule.values as unknown as string | string[],
}
}),
}))
@@ -0,0 +1 @@
export * from "./rule-value-form-field"
@@ -0,0 +1,157 @@
import { RuleAttributeOptionsResponse, StoreDTO } from "@medusajs/types"
import { Input, Select } from "@medusajs/ui"
import { RefCallBack, useWatch } from "react-hook-form"
import { Form } from "../../../../../../components/common/form"
import { Combobox } from "../../../../../../components/inputs/combobox"
import { usePromotionRuleValues } from "../../../../../../hooks/api/promotions"
import { useStore } from "../../../../../../hooks/api/store"
type RuleValueFormFieldType = {
form: any
identifier: string
scope:
| "application_method.buy_rules"
| "rules"
| "application_method.target_rules"
valuesField: any
operatorsField: any
valuesRef: RefCallBack
fieldRule: any
attributes: RuleAttributeOptionsResponse[]
ruleType: "rules" | "target-rules" | "buy-rules"
}
const buildFilters = (attribute?: string, store?: StoreDTO) => {
if (!attribute || !store) {
return {}
}
if (attribute === "currency_code") {
return {
value: store.supported_currency_codes,
}
}
return {}
}
export const RuleValueFormField = ({
form,
identifier,
scope,
valuesField,
operatorsField,
valuesRef,
fieldRule,
attributes,
ruleType,
}: RuleValueFormFieldType) => {
const attribute = attributes?.find(
(attr) => attr.value === fieldRule.attribute
)
const { store, isLoading: isStoreLoading } = useStore()
const { values: options = [] } = usePromotionRuleValues(
ruleType,
attribute?.id!,
buildFilters(attribute?.id, store),
{
enabled:
!!attribute?.id &&
["select", "multiselect"].includes(attribute.field_type) &&
!isStoreLoading,
}
)
const watchOperator = useWatch({
control: form.control,
name: operatorsField.name,
})
return (
<Form.Field
key={`${identifier}.${scope}.${valuesField.name}-${fieldRule.attribute}`}
{...valuesField}
render={({ field: { onChange, ref, ...field } }) => {
if (attribute?.field_type === "number") {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Input
{...field}
type="number"
onChange={onChange}
className="bg-ui-bg-base"
ref={valuesRef}
min={1}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
} else if (attribute?.field_type === "text") {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Input
{...field}
onChange={onChange}
className="bg-ui-bg-base"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
} else if (watchOperator === "eq") {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Select
{...field}
value={
Array.isArray(field.value) ? field.value[0] : field.value
}
onValueChange={onChange}
>
<Select.Trigger ref={ref} className="bg-ui-bg-base">
<Select.Value placeholder="Select Value" />
</Select.Trigger>
<Select.Content>
{options?.map((option, i) => (
<Select.Item
key={`${identifier}-value-option-${i}`}
value={option.value}
>
<span className="text-ui-fg-subtle">
{option.label}
</span>
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
} else {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Combobox
{...field}
placeholder="Select Values"
options={options}
onChange={onChange}
className="bg-ui-bg-base"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}
}}
/>
)
}
@@ -0,0 +1 @@
export * from "./rules-form-field"
@@ -0,0 +1,262 @@
import { XMarkMini } from "@medusajs/icons"
import {
RuleAttributeOptionsResponse,
RuleOperatorOptionsResponse,
} from "@medusajs/types"
import { Badge, Button, Heading, Select, Text } from "@medusajs/ui"
import { Fragment } from "react"
import {
FieldValues,
Path,
UseFieldArrayAppend,
UseFieldArrayRemove,
UseFieldArrayUpdate,
UseFormReturn,
} from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../../components/common/form"
import { RuleValueFormField } from "../rule-value-form-field"
type RulesFormFieldType<TSchema extends FieldValues> = {
form: UseFormReturn<TSchema>
ruleType: "rules" | "target-rules" | "buy-rules"
fields: any[]
attributes: RuleAttributeOptionsResponse[]
operators: RuleOperatorOptionsResponse[]
removeRule: UseFieldArrayRemove
updateRule: UseFieldArrayUpdate<TSchema>
appendRule: UseFieldArrayAppend<TSchema>
setRulesToRemove?: any
rulesToRemove?: any
scope?:
| "application_method.buy_rules"
| "rules"
| "application_method.target_rules"
}
export const RulesFormField = <TSchema extends FieldValues>({
form,
ruleType,
fields,
attributes,
operators,
removeRule,
updateRule,
appendRule,
setRulesToRemove,
rulesToRemove,
scope = "rules",
}: RulesFormFieldType<TSchema>) => {
const { t } = useTranslation()
return (
<div className="flex flex-col">
<Heading level="h2" className="mb-2">
{t(`promotions.fields.conditions.${ruleType}.title`)}
</Heading>
<Text className="text-ui-fg-subtle txt-small mb-10">
{t(`promotions.fields.conditions.${ruleType}.description`)}
</Text>
{fields.map((fieldRule: any, index) => {
const identifier = fieldRule.id
const { ref: attributeRef, ...attributeField } = form.register(
`${scope}.${index}.attribute` as Path<TSchema>
)
const { ref: operatorRef, ...operatorsField } = form.register(
`${scope}.${index}.operator` as Path<TSchema>
)
const { ref: valuesRef, ...valuesField } = form.register(
`${scope}.${index}.values` as Path<TSchema>
)
return (
<Fragment key={`${fieldRule.id}.${index}`}>
<div className="bg-ui-bg-subtle border-ui-border-base flex flex-row gap-2 rounded-xl border px-2 py-2">
<div className="grow">
<Form.Field
key={`${identifier}.${scope}.${attributeField.name}`}
{...attributeField}
render={({ field: { onChange, ref, ...field } }) => {
const existingAttributes =
fields?.map((field: any) => field.attribute) || []
const attributeOptions =
attributes?.filter((attr) => {
if (attr.value === fieldRule.attribute) {
return true
}
return !existingAttributes.includes(attr.value)
}) || []
return (
<Form.Item className="mb-2">
{fieldRule.required && (
<p className="text text-ui-fg-muted txt-small">
{t("promotions.form.required")}
</p>
)}
<Form.Control>
<Select
{...field}
onValueChange={(e) => {
updateRule(index, { ...fieldRule, values: [] })
onChange(e)
}}
disabled={fieldRule.required}
>
<Select.Trigger
ref={attributeRef}
className="bg-ui-bg-base"
>
<Select.Value
placeholder={t(
"promotions.form.selectAttribute"
)}
/>
</Select.Trigger>
<Select.Content>
{attributeOptions?.map((c, i) => (
<Select.Item
key={`${identifier}-attribute-option-${i}`}
value={c.value}
>
<span className="text-ui-fg-subtle">
{c.label}
</span>
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex gap-2">
<Form.Field
key={`${identifier}.${scope}.${operatorsField.name}`}
{...operatorsField}
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Select
{...field}
onValueChange={onChange}
disabled={fieldRule.required}
>
<Select.Trigger
ref={operatorRef}
className="bg-ui-bg-base"
>
<Select.Value placeholder="Select Operator" />
</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>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<RuleValueFormField
form={form}
identifier={identifier}
scope={scope}
valuesField={valuesField}
operatorsField={operatorsField}
valuesRef={valuesRef}
fieldRule={fieldRule}
attributes={attributes}
ruleType={ruleType}
/>
</div>
</div>
<div className="flex-none self-center px-1">
<XMarkMini
className={`text-ui-fg-muted cursor-pointer ${
fieldRule.required ? "invisible" : "visible"
}`}
onClick={() => {
if (!fieldRule.required) {
fieldRule.id &&
setRulesToRemove &&
setRulesToRemove([...rulesToRemove, fieldRule])
removeRule(index)
}
}}
/>
</div>
</div>
{index < fields.length - 1 && (
<div className="relative px-6 py-3">
<div className="border-ui-border-strong absolute bottom-0 left-[40px] top-0 z-[-1] w-px bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-repeat-y"></div>
<Badge size="2xsmall" className=" text-xs">
{t("promotions.form.and")}
</Badge>
</div>
)}
</Fragment>
)
})}
<div className="mt-8">
<Button
type="button"
variant="secondary"
className="inline-block"
onClick={() => {
appendRule({
attribute: "",
operator: "",
values: [],
required: false,
} as any)
}}
>
{t("promotions.fields.addCondition")}
</Button>
<Button
type="button"
variant="transparent"
className="text-ui-fg-muted hover:text-ui-fg-subtle ml-2 inline-block"
onClick={() => {
const indicesToRemove = fields
.map((field: any, index) => (field.required ? null : index))
.filter((f) => f !== null)
setRulesToRemove &&
setRulesToRemove(fields.filter((field: any) => !field.required))
removeRule(indicesToRemove)
}}
>
{t("promotions.fields.clearAll")}
</Button>
</div>
</div>
)
}
@@ -1,9 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { CampaignResponse, PromotionDTO } from "@medusajs/types"
import { Button, clx, RadioGroup, Select } from "@medusajs/ui"
import { Button, clx, RadioGroup, Select, Text } from "@medusajs/ui"
import { useEffect } from "react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Trans, useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import {
@@ -133,6 +133,18 @@ export const AddCampaignPromotionFields = ({
</Select.Content>
</Select>
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="campaigns.fields.campaign_id.hint"
components={[<br key="break" />]}
/>
</Text>
<Form.ErrorMessage />
</Form.Item>
)
@@ -87,7 +87,9 @@ export const CampaignDetails = ({ campaign }: CampaignDetailsProps) => {
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">{campaign.currency || "-"}</Text>
<Text className="txt-small">
{campaign?.budget?.currency_code || "-"}
</Text>
</div>
</div>
@@ -11,13 +11,23 @@ export const PromotionAddCampaign = () => {
const { id } = useParams()
const { t } = useTranslation()
const { promotion, isPending, isError, error } = usePromotion(id!)
let campaignQuery = {}
if (promotion?.application_method?.currency_code) {
campaignQuery = {
budget: {
currency_code: promotion?.application_method?.currency_code,
},
}
}
const {
campaigns,
isPending: areCampaignsLoading,
isError: isCampaignError,
error: campaignError,
} = useCampaigns()
} = useCampaigns(campaignQuery)
if (isError || isCampaignError) {
throw error || campaignError
}
@@ -4,6 +4,7 @@ import {
Button,
clx,
CurrencyInput,
Heading,
Input,
ProgressTabs,
RadioGroup,
@@ -15,21 +16,23 @@ import { Trans, useTranslation } from "react-i18next"
import { z } from "zod"
import {
CampaignResponse,
PromotionRuleOperatorValues,
PromotionRuleResponse,
RuleAttributeOptionsResponse,
RuleOperatorOptionsResponse,
} from "@medusajs/types"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCampaigns } from "../../../../../hooks/api/campaigns"
import { useCreatePromotion } from "../../../../../hooks/api/promotions"
import { getCurrencySymbol } from "../../../../../lib/currencies"
import { defaultCampaignValues } from "../../../../campaigns/campaign-create/components/create-campaign-form"
import { RulesFormField } from "../../../common/edit-rules/components/edit-rules-form"
import { RulesFormField } from "../../../common/edit-rules/components/rules-form-field"
import { AddCampaignPromotionFields } from "../../../promotion-add-campaign/components/add-campaign-promotion-form"
import { Tab } from "./constants"
import { CreatePromotionSchema } from "./form-schema"
@@ -43,7 +46,6 @@ type CreatePromotionFormProps = {
rules: PromotionRuleResponse[]
targetRules: PromotionRuleResponse[]
buyRules: PromotionRuleResponse[]
campaigns: CampaignResponse[]
}
export const CreatePromotionForm = ({
@@ -54,7 +56,6 @@ export const CreatePromotionForm = ({
rules,
targetRules,
buyRules,
campaigns,
}: CreatePromotionFormProps) => {
const [tab, setTab] = useState<Tab>(Tab.TYPE)
const [detailsValidated, setDetailsValidated] = useState(false)
@@ -146,46 +147,46 @@ export const CreatePromotionForm = ({
...applicationMethodData
} = application_method
const disguisedRuleAttributes = [
...targetRules.filter((r) => !!r.disguised),
...buyRules.filter((r) => !!r.disguised),
].map((r) => r.attribute)
const disguisedRules = [
...targetRulesData.filter((r) => !!r.disguised),
...buyRulesData.filter((r) => !!r.disguised),
...rules.filter((r) => !!r.disguised),
]
const attr: Record<any, any> = {}
const applicationMethodRuleData: Record<any, any> = {}
for (const rule of [...targetRulesData, ...buyRulesData]) {
if (disguisedRuleAttributes.includes(rule.attribute)) {
attr[rule.attribute] =
rule.field_type === "number"
? parseInt(rule.values as string)
: rule.values
}
for (const rule of disguisedRules) {
applicationMethodRuleData[rule.attribute] =
rule.field_type === "number"
? parseInt(rule.values as string)
: rule.values
}
const buildRulesData = (
rules: {
operator: string
attribute: string
values: any[] | any
disguised?: boolean
}[]
) => {
return rules
.filter((r) => !r.disguised)
.map((rule) => ({
operator: rule.operator as PromotionRuleOperatorValues,
attribute: rule.attribute,
values: rule.values,
}))
}
createPromotion({
...promotionData,
rules: rules.map((rule) => ({
operator: rule.operator,
attribute: rule.attribute,
values: rule.values,
})),
rules: buildRulesData(rules),
application_method: {
...applicationMethodData,
...attr,
target_rules: targetRulesData
.filter((r) => !disguisedRuleAttributes.includes(r.attribute))
.map((rule) => ({
operator: rule.operator,
attribute: rule.attribute,
values: rule.values,
})),
buy_rules: buyRulesData
.filter((r) => !disguisedRuleAttributes.includes(r.attribute))
.map((rule) => ({
operator: rule.operator,
attribute: rule.attribute,
values: rule.values,
})),
...applicationMethodRuleData,
target_rules: buildRulesData(targetRulesData),
buy_rules: buildRulesData(buyRulesData),
},
is_automatic: is_automatic === "true",
}).then(() => handleSuccess())
@@ -272,12 +273,28 @@ export const CreatePromotionForm = ({
const isAllocationEach = watchAllocation === "each"
useEffect(() => {
if (watchAllocation === "across") {
form.setValue("application_method.max_quantity", null)
}
}, [watchAllocation])
const watchType = useWatch({
control: form.control,
name: "type",
})
const isTypeStandard = watchType === "standard"
const formData = form.getValues()
let campaignQuery: object = {}
if (isFixedValueType && formData.application_method.currency_code) {
campaignQuery = {
budget: { currency_code: formData.application_method.currency_code },
}
}
const { campaigns } = useCampaigns(campaignQuery)
useEffect(() => {
if (isTypeStandard) {
@@ -316,11 +333,36 @@ export const CreatePromotionForm = ({
if (watchCampaignChoice === "new") {
if (!formData.campaign || !formData.campaign?.budget?.type) {
form.setValue("campaign", defaultCampaignValues)
form.setValue("campaign", {
...defaultCampaignValues,
budget: {
...defaultCampaignValues.budget,
currency_code: formData.application_method.currency_code,
},
})
}
}
}, [watchCampaignChoice])
const watchRules = useWatch({
control: form.control,
name: "rules",
})
const watchCurrencyRule = watchRules.find(
(rule) => rule.attribute === "currency_code"
)
if (watchCurrencyRule) {
const formData = form.getValues()
const currencyCode = formData.application_method.currency_code
const ruleValue = watchCurrencyRule.values
if (!Array.isArray(ruleValue) && currencyCode !== ruleValue) {
form.setValue("application_method.currency_code", ruleValue as string)
}
}
return (
<RouteFocusModal.Form form={form}>
<form
@@ -410,8 +452,8 @@ export const CreatePromotionForm = ({
value={template.id}
label={template.title}
description={template.description}
className={clx("", {
"border-ui-border-interactive border-2":
className={clx("border", {
"border border-ui-border-interactive":
template.id === field.value,
})}
/>
@@ -430,6 +472,8 @@ export const CreatePromotionForm = ({
value={Tab.PROMOTION}
className="flex flex-1 flex-col gap-10"
>
<Heading level="h2">{t(`promotions.sections.details`)}</Heading>
{form.formState.errors.root && (
<Alert
variant="error"
@@ -461,19 +505,20 @@ export const CreatePromotionForm = ({
description={t(
"promotions.form.method.code.description"
)}
className={clx("basis-1/2", {
"border-ui-border-interactive border-2":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"false" === field.value,
})}
/>
<RadioGroup.ChoiceBox
value={"true"}
label={t("promotions.form.method.automatic.title")}
description={t(
"promotions.form.method.automatic.description"
)}
className={clx("basis-1/2", {
"border-ui-border-interactive border-2":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"true" === field.value,
})}
/>
@@ -517,6 +562,65 @@ 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 border", {
"border border-ui-border-interactive":
"standard" === field.value,
})}
/>
<RadioGroup.ChoiceBox
value={"buyget"}
label={t("promotions.form.type.buyget.title")}
description={t(
"promotions.form.type.buyget.description"
)}
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"buyget" === field.value,
})}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Divider />
<RulesFormField
form={form}
ruleType={"rules"}
attributes={ruleAttributes}
operators={operators}
fields={ruleFields}
appendRule={appendRule}
removeRule={removeRule}
updateRule={updateRule}
/>
<Divider />
<Form.Field
control={form.control}
name="application_method.type"
@@ -538,8 +642,8 @@ export const CreatePromotionForm = ({
description={t(
"promotions.form.value_type.fixed.description"
)}
className={clx("basis-1/2", {
"border-ui-border-interactive border-2":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"fixed" === field.value,
})}
/>
@@ -552,8 +656,8 @@ export const CreatePromotionForm = ({
description={t(
"promotions.form.value_type.percentage.description"
)}
className={clx("basis-1/2", {
"border-ui-border-interactive border-2":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"percentage" === field.value,
})}
/>
@@ -570,24 +674,39 @@ export const CreatePromotionForm = ({
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>
<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={"USD"}
symbol={getCurrencySymbol("USD")}
{...field}
code={currencyCode}
symbol={
currencyCode
? getCurrencySymbol(currencyCode)
: ""
}
value={value}
disabled={!currencyCode}
/>
) : (
<PercentageInput
@@ -614,50 +733,6 @@ 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", {
"border-ui-border-interactive border-2":
"standard" === field.value,
})}
/>
<RadioGroup.ChoiceBox
value={"buyget"}
label={t("promotions.form.type.buyget.title")}
description={t(
"promotions.form.type.buyget.description"
)}
className={clx("basis-1/2", {
"border-ui-border-interactive border-2":
"buyget" === field.value,
})}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{isTypeStandard && (
<Form.Field
control={form.control}
@@ -681,8 +756,8 @@ export const CreatePromotionForm = ({
description={t(
"promotions.form.allocation.each.description"
)}
className={clx("basis-1/2", {
"border-ui-border-interactive border-2":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"each" === field.value,
})}
/>
@@ -695,8 +770,8 @@ export const CreatePromotionForm = ({
description={t(
"promotions.form.allocation.across.description"
)}
className={clx("basis-1/2", {
"border-ui-border-interactive border-2":
className={clx("basis-1/2 border", {
"border border-ui-border-interactive":
"across" === field.value,
})}
/>
@@ -751,16 +826,7 @@ export const CreatePromotionForm = ({
</div>
)}
<RulesFormField
form={form}
ruleType={"rules"}
attributes={ruleAttributes}
operators={operators}
fields={ruleFields}
appendRule={appendRule}
removeRule={removeRule}
updateRule={updateRule}
/>
<Divider />
<RulesFormField
form={form}
@@ -774,6 +840,8 @@ export const CreatePromotionForm = ({
scope="application_method.target_rules"
/>
<Divider />
{!isTypeStandard && (
<RulesFormField
form={form}
@@ -793,7 +861,10 @@ export const CreatePromotionForm = ({
value={Tab.CAMPAIGN}
className="flex flex-col items-center"
>
<AddCampaignPromotionFields form={form} campaigns={campaigns} />
<AddCampaignPromotionFields
form={form}
campaigns={campaigns || []}
/>
</ProgressTabs.Content>
</RouteFocusModal.Body>
</ProgressTabs>
@@ -17,22 +17,40 @@ const RuleSchema = z.array(
})
)
export const CreatePromotionSchema = z.object({
template_id: z.string().optional(),
campaign_id: z.string().optional(),
campaign_choice: z.enum(["none", "existing", "new"]).optional(),
is_automatic: z.string().toLowerCase(),
code: z.string().min(1),
type: z.enum(["buyget", "standard"]),
rules: RuleSchema,
application_method: z.object({
allocation: z.enum(["each", "across"]),
value: z.number().min(0),
max_quantity: z.number().optional(),
target_rules: RuleSchema,
buy_rules: RuleSchema.min(2).optional(),
type: z.enum(["fixed", "percentage"]),
target_type: z.enum(["order", "shipping_methods", "items"]),
}),
campaign: CreateCampaignSchema.optional(),
})
export const CreatePromotionSchema = z
.object({
template_id: z.string().optional(),
campaign_id: z.string().optional(),
campaign_choice: z.enum(["none", "existing", "new"]).optional(),
is_automatic: z.string().toLowerCase(),
code: z.string().min(1),
type: z.enum(["buyget", "standard"]),
rules: RuleSchema,
application_method: z.object({
allocation: z.enum(["each", "across"]),
value: z.number().min(0),
currency_code: z.string(),
max_quantity: z.number().optional().nullable(),
target_rules: RuleSchema,
buy_rules: RuleSchema.min(2).optional(),
type: z.enum(["fixed", "percentage"]),
target_type: z.enum(["order", "shipping_methods", "items"]),
}),
campaign: CreateCampaignSchema.optional(),
})
.refine(
(data) => {
if (data.application_method.allocation === "across") {
return true
}
return (
data.application_method.allocation === "each" &&
typeof data.application_method.max_quantity === "number"
)
},
{
path: ["application_method.max_quantity"],
message: `required field`,
}
)
@@ -59,4 +59,20 @@ export const templates = [
},
},
},
{
id: "buy_get",
type: "buy_get",
title: "Buy X Get Y",
description: "Buy X product(s), get Y product(s)",
defaults: {
is_automatic: "false",
type: "buyget",
application_method: {
type: "percentage",
value: 100,
apply_to_quantity: 1,
max_quantity: 1,
},
},
},
]
@@ -1,5 +1,4 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { useCampaigns } from "../../../hooks/api/campaigns"
import {
usePromotionRuleAttributes,
usePromotionRuleOperators,
@@ -18,14 +17,12 @@ export const PromotionCreate = () => {
const { rules: targetRules } = usePromotionRules(null, "target-rules")
const { rules: buyRules } = usePromotionRules(null, "buy-rules")
const { operators } = usePromotionRuleOperators()
const { campaigns } = useCampaigns()
return (
<RouteFocusModal>
{rules &&
buyRules &&
targetRules &&
campaigns &&
operators &&
ruleAttributes &&
targetRuleAttributes &&
@@ -38,7 +35,6 @@ export const PromotionCreate = () => {
rules={rules}
targetRules={targetRules}
buyRules={buyRules}
campaigns={campaigns}
/>
)}
</RouteFocusModal>
@@ -1,6 +1,7 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PromotionDTO } from "@medusajs/types"
import {
Badge,
Container,
Copy,
Heading,
@@ -142,7 +143,15 @@ export const PromotionGeneralSection = ({
</Text>
<Text size="small" leading="compact" className="text-pretty">
{promotion.application_method?.value}
<Text className="inline pr-3" size="small" leading="compact">
{promotion.application_method?.value}
</Text>
{promotion?.application_method?.type === "fixed" && (
<Badge size="xsmall">
{promotion?.application_method?.currency_code}
</Badge>
)}
</Text>
</div>
@@ -15,8 +15,8 @@ import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import {
RouteDrawer,
useRouteModal,
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdatePromotion } from "../../../../../hooks/api/promotions"
import { getCurrencySymbol } from "../../../../../lib/currencies"
@@ -29,7 +29,7 @@ const EditPromotionSchema = zod.object({
is_automatic: zod.string().toLowerCase(),
code: zod.string().min(1),
value_type: zod.enum(["fixed", "percentage"]),
value: zod.string(),
value: zod.number(),
allocation: zod.enum(["each", "across"]),
})
@@ -43,7 +43,7 @@ export const EditPromotionDetailsForm = ({
defaultValues: {
is_automatic: promotion.is_automatic!.toString(),
code: promotion.code,
value: promotion.application_method!.value?.toString(),
value: promotion.application_method!.value,
allocation: promotion.application_method!.allocation,
value_type: promotion.application_method!.type,
},
@@ -218,11 +218,13 @@ export const EditPromotionDetailsForm = ({
{isFixedValueType ? (
<CurrencyInput
min={0}
onValueChange={onChange}
onValueChange={(val) =>
onChange(val ? parseInt(val) : null)
}
code={"USD"}
symbol={getCurrencySymbol("USD")}
{...field}
value={Number(field.value)}
value={field.value}
/>
) : (
<PercentageInput
@@ -233,7 +235,9 @@ export const EditPromotionDetailsForm = ({
value={field.value || ""}
onChange={(e) => {
onChange(
e.target.value === "" ? null : e.target.value
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
@@ -19,6 +19,7 @@ import {
ProductTypeDTO,
ProductVariantDTO,
PromotionDTO,
PromotionRuleDTO,
SalesChannelDTO,
ShippingOptionDTO,
ShippingProfileDTO,
@@ -52,7 +53,7 @@ export type PromotionListRes = { promotions: PromotionDTO[] } & ListRes
export type PromotionRuleAttributesListRes = { attributes: Record<any, any>[] }
export type PromotionRuleOperatorsListRes = { operators: Record<any, any>[] }
export type PromotionRuleValuesListRes = { values: Record<any, any>[] }
export type PromotionRulesListRes = { rules: Record<any, any>[] }
export type PromotionRulesListRes = { rules: PromotionRuleDTO[] }
export type PromotionDeleteRes = DeleteRes
// Users
@@ -12,6 +12,7 @@ export interface CampaignResponse {
budget: {
id: string
type: CampaignBudgetTypeValues
currency_code: string
limit: number
used: number
}
@@ -1,3 +1,4 @@
export * from "./promotion-rule"
export * from "./rule-attribute-options"
export * from "./rule-operator-options"
export * from "./rule-value-options"
@@ -0,0 +1,15 @@
/**
* @experimental
*/
export interface RuleValueOptionsResponse {
id: string
value: string
label: string
}
/**
* @experimental
*/
export interface AdminRuleValueOptionsListResponse {
values: RuleValueOptionsResponse[]
}
@@ -53,6 +53,11 @@ export interface ApplicationMethodDTO {
*/
value?: number
/**
* The currency code of the application method
*/
currency_code?: string
/**
* The max quantity allowed in the cart for the associated promotion to be applied.
*/
@@ -116,6 +121,11 @@ export interface CreateApplicationMethodDTO {
*/
value?: number
/**
* Currency of the value to apply.
*/
currency_code: string
/**
* The max quantity allowed in the cart for the associated promotion to be applied.
*/
@@ -158,7 +168,7 @@ export interface UpdateApplicationMethodDTO {
/**
* The ID of the application method.
*/
id: string
id?: string
/**
* The type of the application method indicating how
@@ -184,6 +194,11 @@ export interface UpdateApplicationMethodDTO {
*/
value?: number
/**
* The currency code of the promotions application
*/
currency_code?: string
/**
* The max quantity allowed in the cart for the associated promotion to be applied.
*/
@@ -36,6 +36,11 @@ export interface CampaignBudgetDTO {
*
*/
used?: number
/**
* The currency of the campaign.
*/
currency_code?: string
}
/**
@@ -21,11 +21,6 @@ export interface CampaignDTO {
*/
description?: string
/**
* The currency of the campaign.
*/
currency?: string
/**
* The campaign identifier of the campaign.
*/
@@ -88,7 +88,7 @@ export interface CreatePromotionDTO {
/**
* The associated application method.
*/
application_method?: CreateApplicationMethodDTO
application_method: CreateApplicationMethodDTO
/**
* The rules of the promotion.
@@ -4,6 +4,7 @@ export type AdminGetPromotionRulesRes = {
attribute_label: string
field_type?: string
operator: string
hydrate: boolean
operator_label: string
values: { label?: string; value?: string }[]
disguised?: boolean
+12 -12
View File
@@ -12,12 +12,17 @@ export interface CreateCampaignBudgetDTO {
/**
* The limit of the campaign budget.
*/
limit?: number
limit?: number | null
/**
* How much is used of the campaign budget.
*/
used?: number
/**
* The currency of the campaign.
*/
currency_code?: string | null
}
/**
@@ -37,7 +42,12 @@ export interface UpdateCampaignBudgetDTO {
/**
* The limit of the campaign budget.
*/
limit?: number
limit?: number | null
/**
* The limit of the campaign budget.
*/
currency_code?: string | null
/**
* How much is used of the campaign budget.
@@ -59,11 +69,6 @@ export interface CreateCampaignDTO {
*/
description?: string
/**
* The currency of the campaign.
*/
currency?: string
/**
* The campaign identifier of the campaign.
*/
@@ -104,11 +109,6 @@ export interface UpdateCampaignDTO {
*/
description?: string
/**
* The currency of the campaign.
*/
currency?: string
/**
* The campaign identifier of the campaign.
*/
@@ -1,4 +1,4 @@
import { CampaignBudgetType } from "@medusajs/utils"
import { CampaignBudgetType, isPresent } from "@medusajs/utils"
import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators"
@@ -10,44 +10,70 @@ export type AdminGetCampaignsParamsType = z.infer<
export const AdminGetCampaignsParams = createFindParams({
offset: 0,
limit: 50,
}).merge(
z.object({
q: z.string().optional(),
campaign_identifier: z.string().optional(),
currency: z.string().optional(),
$and: z.lazy(() => AdminGetCampaignsParams.array()).optional(),
$or: z.lazy(() => AdminGetCampaignsParams.array()).optional(),
})
.merge(
z.object({
q: z.string().optional(),
campaign_identifier: z.string().optional(),
budget: z
.object({
currency_code: z.string().optional(),
})
.optional(),
$and: z.lazy(() => AdminGetCampaignsParams.array()).optional(),
$or: z.lazy(() => AdminGetCampaignsParams.array()).optional(),
})
)
.strict()
export const CreateCampaignBudget = z
.object({
type: z.nativeEnum(CampaignBudgetType),
limit: z.number().optional().nullable(),
currency_code: z.string().optional().nullable(),
})
)
.strict()
.refine(
(data) =>
data.type !== CampaignBudgetType.SPEND || isPresent(data.currency_code),
{
path: ["currency_code"],
message: `currency_code is required when budget type is ${CampaignBudgetType.SPEND}`,
}
)
.refine(
(data) =>
data.type !== CampaignBudgetType.USAGE || !isPresent(data.currency_code),
{
path: ["currency_code"],
message: `currency_code should not be present when budget type is ${CampaignBudgetType.USAGE}`,
}
)
const CreateCampaignBudget = z.object({
type: z.nativeEnum(CampaignBudgetType),
limit: z.number(),
})
const UpdateCampaignBudget = z.object({
type: z.nativeEnum(CampaignBudgetType).optional(),
limit: z.number().optional(),
})
export const UpdateCampaignBudget = z
.object({
limit: z.number().optional().nullable(),
})
.strict()
export type AdminCreateCampaignType = z.infer<typeof AdminCreateCampaign>
export const AdminCreateCampaign = z.object({
name: z.string(),
campaign_identifier: z.string(),
description: z.string().optional(),
currency: z.string().optional(),
budget: CreateCampaignBudget.optional(),
starts_at: z.coerce.date().optional(),
ends_at: z.coerce.date().optional(),
promotions: z.array(z.object({ id: z.string() })).optional(),
})
export const AdminCreateCampaign = z
.object({
name: z.string(),
campaign_identifier: z.string(),
description: z.string().optional(),
budget: CreateCampaignBudget.optional(),
starts_at: z.coerce.date().optional(),
ends_at: z.coerce.date().optional(),
promotions: z.array(z.object({ id: z.string() })).optional(),
})
.strict()
export type AdminUpdateCampaignType = z.infer<typeof AdminUpdateCampaign>
export const AdminUpdateCampaign = z.object({
name: z.string().optional(),
campaign_identifier: z.string().optional(),
description: z.string().optional(),
currency: z.string().optional(),
budget: UpdateCampaignBudget.optional(),
starts_at: z.coerce.date().optional(),
ends_at: z.coerce.date().optional(),
@@ -57,6 +57,7 @@ export const GET = async (
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,
@@ -67,9 +68,11 @@ export const GET = async (
continue
}
for (const promotionRule of promotionRules) {
for (const promotionRule of [...promotionRules, ...transformedRules]) {
const currentRuleAttribute = ruleAttributes.find(
(attr) => attr.value === promotionRule.attribute
(attr) =>
attr.value === promotionRule.attribute ||
attr.value === promotionRule.attribute
)
if (!currentRuleAttribute) {
@@ -77,6 +80,11 @@ export const GET = async (
}
const queryConfig = ruleQueryConfigurations[currentRuleAttribute.id]
if (!queryConfig) {
continue
}
const rows = await remoteQuery(
remoteQueryObjectFromString({
entryPoint: queryConfig.entryPoint,
@@ -101,15 +109,17 @@ export const GET = async (
label: valueLabelMap.get(value.value) || value.value,
}))
transformedRules.push({
...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 (!currentRuleAttribute.hydrate) {
transformedRules.push({
...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) {
@@ -124,6 +134,7 @@ export const GET = async (
values: [],
disguised: true,
required: true,
hydrate: false,
})
continue
@@ -19,6 +19,13 @@ export const GET = async (
const { rule_type: ruleType, rule_attribute_id: ruleAttributeId } = req.params
const queryConfig = ruleQueryConfigurations[ruleAttributeId]
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const filterableFields = req.filterableFields
if (filterableFields.value) {
filterableFields[queryConfig.valueAttr] = filterableFields.value
delete filterableFields.value
}
validateRuleType(ruleType)
validateRuleAttribute(ruleType, ruleAttributeId)
@@ -27,7 +34,7 @@ export const GET = async (
remoteQueryObjectFromString({
entryPoint: queryConfig.entryPoint,
variables: {
filters: req.filterableFields,
filters: filterableFields,
...req.remoteQueryConfig.pagination,
},
fields: [queryConfig.labelAttr, queryConfig.valueAttr],
@@ -1,6 +1,7 @@
export enum DisguisedRule {
APPLY_TO_QUANTITY = "apply_to_quantity",
BUY_RULES_MIN_QUANTITY = "buy_rules_min_quantity",
CURRENCY_CODE = "currency_code",
}
export const disguisedRulesMap = {
@@ -10,38 +11,48 @@ export const disguisedRulesMap = {
[DisguisedRule.BUY_RULES_MIN_QUANTITY]: {
relation: "application_method",
},
[DisguisedRule.CURRENCY_CODE]: {
relation: "application_method",
},
}
const ruleAttributes = [
{
id: "currency",
value: "currency_code",
label: "Currency code",
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_group.id",
value: "customer.groups.id",
label: "Customer Group",
required: false,
field_type: "multiselect",
},
{
id: "region",
value: "region.id",
label: "Region",
required: false,
field_type: "multiselect",
},
{
id: "country",
value: "shipping_address.country_code",
label: "Country",
required: false,
field_type: "multiselect",
},
{
id: "sales_channel",
value: "sales_channel.id",
value: "sales_channel_id",
label: "Sales Channel",
required: false,
field_type: "multiselect",
},
]
@@ -51,30 +62,35 @@ const commonAttributes = [
value: "items.product.id",
label: "Product",
required: false,
field_type: "multiselect",
},
{
id: "product_category",
value: "items.product.categories.id",
label: "Product Category",
required: false,
field_type: "multiselect",
},
{
id: "product_collection",
value: "items.product.collection_id",
label: "Product Collection",
required: false,
field_type: "multiselect",
},
{
id: "product_type",
value: "items.product.type_id",
label: "Product Type",
required: false,
field_type: "multiselect",
},
{
id: "product_tag",
value: "items.product.tags.id",
label: "Product Tag",
required: false,
field_type: "multiselect",
},
]
@@ -4,9 +4,9 @@ export const ruleQueryConfigurations = {
labelAttr: "name",
valueAttr: "id",
},
currency: {
currency_code: {
entryPoint: "currency",
labelAttr: "code",
labelAttr: "name",
valueAttr: "code",
},
customer_group: {
@@ -2,7 +2,6 @@ import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ApplicationMethodType,
CampaignBudgetType,
PromotionRuleOperator,
PromotionType,
} from "@medusajs/utils"
@@ -12,6 +11,7 @@ import {
createOperatorMap,
createSelectParams,
} from "../../utils/validators"
import { AdminCreateCampaign } from "../campaigns/validators"
export type AdminGetPromotionParamsType = z.infer<
typeof AdminGetPromotionParams
@@ -30,6 +30,11 @@ export const AdminGetPromotionsParams = createFindParams({
q: z.string().optional(),
code: z.union([z.string(), z.array(z.string())]).optional(),
campaign_id: z.union([z.string(), z.array(z.string())]).optional(),
application_method: z
.object({
currency_code: z.union([z.string(), z.array(z.string())]).optional(),
})
.optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
@@ -58,6 +63,7 @@ export const AdminGetPromotionsRuleValueParams = createFindParams({
}).merge(
z.object({
q: z.string().optional(),
value: z.union([z.string(), z.array(z.string())]).optional(),
})
)
@@ -69,7 +75,7 @@ export const AdminCreatePromotionRule = z
operator: z.nativeEnum(PromotionRuleOperator),
description: z.string().optional(),
attribute: z.string(),
values: z.array(z.string()),
values: z.union([z.string(), z.array(z.string())]),
})
.strict()
@@ -82,7 +88,7 @@ export const AdminUpdatePromotionRule = z
operator: z.nativeEnum(PromotionRuleOperator).optional(),
description: z.string().optional(),
attribute: z.string().optional(),
values: z.array(z.string()).optional(),
values: z.union([z.string(), z.array(z.string())]),
})
.strict()
@@ -93,12 +99,12 @@ export const AdminCreateApplicationMethod = z
.object({
description: z.string().optional(),
value: z.number(),
max_quantity: z.number().optional(),
currency_code: z.string(),
max_quantity: z.number().optional().nullable(),
type: z.nativeEnum(ApplicationMethodType),
target_type: z.nativeEnum(ApplicationMethodTargetType),
allocation: z.nativeEnum(ApplicationMethodAllocation).optional(),
target_rules: z.array(AdminCreatePromotionRule).optional(),
buy_rules: z.array(AdminCreatePromotionRule).optional(),
apply_to_quantity: z.number().optional(),
buy_rules_min_quantity: z.number().optional(),
@@ -111,8 +117,9 @@ export type AdminUpdateApplicationMethodType = z.infer<
export const AdminUpdateApplicationMethod = z
.object({
description: z.string().optional(),
value: z.string().optional(),
max_quantity: z.number().optional(),
value: z.number().optional(),
max_quantity: z.number().optional().nullable(),
currency_code: z.string().optional(),
type: z.nativeEnum(ApplicationMethodType).optional(),
target_type: z.nativeEnum(ApplicationMethodTargetType).optional(),
allocation: z.nativeEnum(ApplicationMethodAllocation).optional(),
@@ -140,23 +147,6 @@ const promoRefinement = (promo) => {
return true
}
// Ideally we don't allow for creation of campaigns through promotions, it should be the other way around.
const CreateCampaignBudget = z.object({
type: z.nativeEnum(CampaignBudgetType),
limit: z.number(),
})
export type AdminCreateCampaignType = z.infer<typeof AdminCreateCampaign>
export const AdminCreateCampaign = z.object({
name: z.string(),
campaign_identifier: z.string(),
description: z.string().optional(),
currency: z.string().optional(),
budget: CreateCampaignBudget.optional(),
starts_at: z.coerce.date().optional(),
ends_at: z.coerce.date().optional(),
})
export type AdminCreatePromotionType = z.infer<typeof AdminCreatePromotion>
export const AdminCreatePromotion = z
.object({
@@ -5,13 +5,13 @@ export const defaultCampaignsData = [
id: "campaign-id-1",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-1",
starts_at: new Date("01/01/2023"),
ends_at: new Date("01/01/2024"),
budget: {
type: CampaignBudgetType.SPEND,
limit: 1000,
currency_code: "USD",
used: 0,
},
},
@@ -19,13 +19,13 @@ export const defaultCampaignsData = [
id: "campaign-id-2",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-2",
starts_at: new Date("01/01/2023"),
ends_at: new Date("01/01/2024"),
budget: {
type: CampaignBudgetType.USAGE,
limit: 1000,
currency_code: "USD",
used: 0,
},
},
@@ -1,14 +1,29 @@
import { CreatePromotionDTO } from "@medusajs/types"
import { PromotionType } from "@medusajs/utils"
export const defaultPromotionsData = [
export const defaultPromotionsData: CreatePromotionDTO[] = [
{
id: "promotion-id-1",
code: "PROMOTION_1",
type: PromotionType.STANDARD,
application_method: {
currency_code: "USD",
target_type: "items",
type: "fixed",
allocation: "across",
value: 1000,
},
},
{
id: "promotion-id-2",
code: "PROMOTION_2",
type: PromotionType.STANDARD,
application_method: {
currency_code: "USD",
target_type: "items",
type: "fixed",
allocation: "across",
value: 1000,
},
},
]
@@ -1,4 +1,9 @@
import { CreatePromotionDTO } from "@medusajs/types"
import {
CreatePromotionDTO,
IPromotionModuleService,
PromotionDTO,
} from "@medusajs/types"
import { isPresent } from "@medusajs/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Promotion } from "@models"
import { defaultPromotionsData } from "./data"
@@ -21,3 +26,47 @@ export async function createPromotions(
return promotions
}
export async function createDefaultPromotions(
service: IPromotionModuleService,
promotionsData: Partial<CreatePromotionDTO>[] = defaultPromotionsData
): Promise<Promotion[]> {
const promotions: Promotion[] = []
for (let promotionData of promotionsData) {
let promotion = await createDefaultPromotion(service, promotionData)
promotions.push(promotion)
}
return promotions
}
export async function createDefaultPromotion(
service: IPromotionModuleService,
data: Partial<CreatePromotionDTO>
): Promise<PromotionDTO> {
const { application_method = {}, campaign = {}, ...promotion } = data
return await service.create({
code: "PROMOTION_TEST",
type: "standard",
campaign_id: "campaign-id-1",
...promotion,
application_method: {
currency_code: "USD",
target_type: "items",
type: "fixed",
allocation: "across",
value: 1000,
...application_method,
},
campaign: isPresent(campaign)
? {
campaign_identifier: "campaign-identifier",
name: "new campaign",
...campaign,
}
: undefined,
})
}
@@ -1,6 +1,7 @@
import { Modules } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { CampaignBudgetType } from "../../../../../../core/utils/src/promotion/index"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { createPromotions } from "../../../__fixtures__/promotion"
@@ -27,7 +28,7 @@ moduleIntegrationTestRunner({
id: "campaign-id-1",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-1",
starts_at: expect.any(Date),
ends_at: expect.any(Date),
@@ -40,7 +41,7 @@ moduleIntegrationTestRunner({
id: "campaign-id-2",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-2",
starts_at: expect.any(Date),
ends_at: expect.any(Date),
@@ -92,6 +93,26 @@ moduleIntegrationTestRunner({
)
})
it("should throw an error when required budget params are not met", async () => {
const error = await service
.createCampaigns([
{
name: "test",
campaign_identifier: "test",
budget: {
limit: 1000,
type: "spend",
used: 10,
},
},
])
.catch((e) => e)
expect(error.message).toContain(
"Campaign Budget type is a required field"
)
})
it("should create a basic campaign successfully", async () => {
const startsAt = new Date("01/01/2024")
const endsAt = new Date("01/01/2025")
@@ -174,7 +195,6 @@ moduleIntegrationTestRunner({
{
id: "campaign-id-1",
description: "test description 1",
currency: "EUR",
campaign_identifier: "new",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
@@ -184,7 +204,6 @@ moduleIntegrationTestRunner({
expect(updatedCampaign).toEqual(
expect.objectContaining({
description: "test description 1",
currency: "EUR",
campaign_identifier: "new",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
@@ -214,6 +233,37 @@ moduleIntegrationTestRunner({
})
)
})
it("should create a campaign budget if not present successfully", async () => {
await createCampaigns(MikroOrmWrapper.forkManager(), [
{
id: "campaign-id-new",
name: "campaign 1",
description: "test description",
campaign_identifier: "test-1",
} as any,
])
const [updatedCampaign] = await service.updateCampaigns([
{
id: "campaign-id-new",
budget: {
type: CampaignBudgetType.SPEND,
limit: 100,
used: 100,
},
},
])
expect(updatedCampaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
limit: 100,
used: 100,
}),
})
)
})
})
describe("retrieveCampaign", () => {
@@ -2,6 +2,7 @@ import { Modules } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { createDefaultPromotion } from "../../../__fixtures__/promotion"
jest.setTimeout(30000)
@@ -18,11 +19,7 @@ moduleIntegrationTestRunner({
describe("registerUsage", () => {
it("should register usage for type spend", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_SPEND",
type: "standard",
campaign_id: "campaign-id-1",
})
const createdPromotion = await createDefaultPromotion(service, {})
await service.registerUsage([
{
@@ -53,9 +50,7 @@ moduleIntegrationTestRunner({
})
it("should register usage for type usage", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_USAGE",
type: "standard",
const createdPromotion = await createDefaultPromotion(service, {
campaign_id: "campaign-id-2",
})
@@ -103,9 +98,7 @@ moduleIntegrationTestRunner({
})
it("should not register usage when limit is exceed for type usage", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_USAGE",
type: "standard",
const createdPromotion = await createDefaultPromotion(service, {
campaign_id: "campaign-id-2",
})
@@ -144,11 +137,7 @@ moduleIntegrationTestRunner({
})
it("should not register usage above limit when exceeded for type spend", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_SPEND",
type: "standard",
campaign_id: "campaign-id-1",
})
const createdPromotion = await createDefaultPromotion(service, {})
await service.updateCampaigns({
id: "campaign-id-1",
@@ -1,58 +0,0 @@
import { PromotionType } from "@medusajs/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { createPromotions } from "../../../__fixtures__/promotion"
import { IPromotionModuleService } from "@medusajs/types"
import { Modules } from "@medusajs/modules-sdk"
jest.setTimeout(30000)
moduleIntegrationTestRunner({
moduleName: Modules.PROMOTION,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Service", () => {
beforeEach(async () => {
await createPromotions(MikroOrmWrapper.forkManager())
})
describe("create", () => {
it("should throw an error when required params are not passed", async () => {
const error = await service
.create([
{
type: PromotionType.STANDARD,
} as any,
])
.catch((e) => e)
expect(error.message).toContain(
"Value for Promotion.code is required, 'undefined' found"
)
})
it("should create a promotion successfully", async () => {
await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
},
])
const [promotion] = await service.list({
code: ["PROMOTION_TEST"],
})
expect(promotion).toEqual(
expect.objectContaining({
code: "PROMOTION_TEST",
is_automatic: false,
type: "standard",
})
)
})
})
})
},
})
@@ -1,5 +1,7 @@
{
"namespaces": ["public"],
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
@@ -31,15 +33,6 @@
"nullable": true,
"mappedType": "text"
},
"currency": {
"name": "currency",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"campaign_identifier": {
"name": "campaign_identifier",
"type": "text",
@@ -107,14 +100,18 @@
"indexes": [
{
"keyName": "IDX_campaign_identifier_unique",
"columnNames": ["campaign_identifier"],
"columnNames": [
"campaign_identifier"
],
"composite": false,
"primary": false,
"unique": true
},
{
"keyName": "promotion_campaign_pkey",
"columnNames": ["id"],
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
@@ -141,7 +138,10 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["spend", "usage"],
"enumItems": [
"spend",
"usage"
],
"mappedType": "enum"
},
"campaign_id": {
@@ -153,6 +153,15 @@
"nullable": false,
"mappedType": "text"
},
"currency_code": {
"name": "currency_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"limit": {
"name": "limit",
"type": "numeric",
@@ -177,7 +186,8 @@
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"nullable": false,
"default": "0",
"mappedType": "decimal"
},
"raw_used": {
@@ -186,7 +196,7 @@
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"nullable": false,
"mappedType": "json"
},
"created_at": {
@@ -226,14 +236,18 @@
"schema": "public",
"indexes": [
{
"columnNames": ["type"],
"columnNames": [
"type"
],
"composite": false,
"keyName": "IDX_campaign_budget_type",
"primary": false,
"unique": false
},
{
"columnNames": ["campaign_id"],
"columnNames": [
"campaign_id"
],
"composite": false,
"keyName": "promotion_campaign_budget_campaign_id_unique",
"primary": false,
@@ -241,7 +255,9 @@
},
{
"keyName": "promotion_campaign_budget_pkey",
"columnNames": ["id"],
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
@@ -251,9 +267,13 @@
"foreignKeys": {
"promotion_campaign_budget_campaign_id_foreign": {
"constraintName": "promotion_campaign_budget_campaign_id_foreign",
"columnNames": ["campaign_id"],
"columnNames": [
"campaign_id"
],
"localTableName": "public.promotion_campaign_budget",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_campaign",
"updateRule": "cascade"
}
@@ -305,7 +325,10 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["standard", "buyget"],
"enumItems": [
"standard",
"buyget"
],
"mappedType": "enum"
},
"created_at": {
@@ -345,14 +368,18 @@
"schema": "public",
"indexes": [
{
"columnNames": ["code"],
"columnNames": [
"code"
],
"composite": false,
"keyName": "IDX_promotion_code",
"primary": false,
"unique": false
},
{
"columnNames": ["type"],
"columnNames": [
"type"
],
"composite": false,
"keyName": "IDX_promotion_type",
"primary": false,
@@ -360,14 +387,18 @@
},
{
"keyName": "IDX_promotion_code_unique",
"columnNames": ["code"],
"columnNames": [
"code"
],
"composite": false,
"primary": false,
"unique": true
},
{
"keyName": "promotion_pkey",
"columnNames": ["id"],
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
@@ -377,11 +408,16 @@
"foreignKeys": {
"promotion_campaign_id_foreign": {
"constraintName": "promotion_campaign_id_foreign",
"columnNames": ["campaign_id"],
"columnNames": [
"campaign_id"
],
"localTableName": "public.promotion",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_campaign",
"deleteRule": "set null"
"deleteRule": "set null",
"updateRule": "cascade"
}
}
},
@@ -402,7 +438,7 @@
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"nullable": false,
"mappedType": "decimal"
},
"raw_value": {
@@ -411,9 +447,18 @@
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"nullable": false,
"mappedType": "json"
},
"currency_code": {
"name": "currency_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"max_quantity": {
"name": "max_quantity",
"type": "numeric",
@@ -448,7 +493,10 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["fixed", "percentage"],
"enumItems": [
"fixed",
"percentage"
],
"mappedType": "enum"
},
"target_type": {
@@ -458,7 +506,11 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["order", "shipping_methods", "items"],
"enumItems": [
"order",
"shipping_methods",
"items"
],
"mappedType": "enum"
},
"allocation": {
@@ -468,7 +520,10 @@
"autoincrement": false,
"primary": false,
"nullable": true,
"enumItems": ["each", "across"],
"enumItems": [
"each",
"across"
],
"mappedType": "enum"
},
"promotion_id": {
@@ -517,36 +572,56 @@
"schema": "public",
"indexes": [
{
"columnNames": ["type"],
"columnNames": [
"type"
],
"composite": false,
"keyName": "IDX_application_method_type",
"primary": false,
"unique": false
},
{
"columnNames": ["target_type"],
"columnNames": [
"target_type"
],
"composite": false,
"keyName": "IDX_application_method_target_type",
"primary": false,
"unique": false
},
{
"columnNames": ["allocation"],
"columnNames": [
"allocation"
],
"composite": false,
"keyName": "IDX_application_method_allocation",
"primary": false,
"unique": false
},
{
"columnNames": ["promotion_id"],
"columnNames": [
"promotion_id"
],
"composite": false,
"keyName": "promotion_application_method_promotion_id_unique",
"primary": false,
"unique": true
},
{
"keyName": "IDX_promotion_application_method_currency_code",
"columnNames": [
"currency_code"
],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_application_method_currency_code\" ON \"promotion_application_method\" (currency_code) WHERE deleted_at IS NOT NULL"
},
{
"keyName": "promotion_application_method_pkey",
"columnNames": ["id"],
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
@@ -556,9 +631,13 @@
"foreignKeys": {
"promotion_application_method_promotion_id_foreign": {
"constraintName": "promotion_application_method_promotion_id_foreign",
"columnNames": ["promotion_id"],
"columnNames": [
"promotion_id"
],
"localTableName": "public.promotion_application_method",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion",
"deleteRule": "cascade",
"updateRule": "cascade"
@@ -601,7 +680,15 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["gte", "lte", "gt", "lt", "eq", "ne", "in"],
"enumItems": [
"gte",
"lte",
"gt",
"lt",
"eq",
"ne",
"in"
],
"mappedType": "enum"
},
"created_at": {
@@ -641,14 +728,18 @@
"schema": "public",
"indexes": [
{
"columnNames": ["attribute"],
"columnNames": [
"attribute"
],
"composite": false,
"keyName": "IDX_promotion_rule_attribute",
"primary": false,
"unique": false
},
{
"columnNames": ["operator"],
"columnNames": [
"operator"
],
"composite": false,
"keyName": "IDX_promotion_rule_operator",
"primary": false,
@@ -656,7 +747,9 @@
},
{
"keyName": "promotion_rule_pkey",
"columnNames": ["id"],
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
@@ -691,7 +784,10 @@
"indexes": [
{
"keyName": "promotion_promotion_rule_pkey",
"columnNames": ["promotion_id", "promotion_rule_id"],
"columnNames": [
"promotion_id",
"promotion_rule_id"
],
"composite": true,
"primary": true,
"unique": true
@@ -701,18 +797,26 @@
"foreignKeys": {
"promotion_promotion_rule_promotion_id_foreign": {
"constraintName": "promotion_promotion_rule_promotion_id_foreign",
"columnNames": ["promotion_id"],
"columnNames": [
"promotion_id"
],
"localTableName": "public.promotion_promotion_rule",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"promotion_promotion_rule_promotion_rule_id_foreign": {
"constraintName": "promotion_promotion_rule_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"columnNames": [
"promotion_rule_id"
],
"localTableName": "public.promotion_promotion_rule",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
@@ -745,7 +849,10 @@
"indexes": [
{
"keyName": "application_method_target_rules_pkey",
"columnNames": ["application_method_id", "promotion_rule_id"],
"columnNames": [
"application_method_id",
"promotion_rule_id"
],
"composite": true,
"primary": true,
"unique": true
@@ -755,18 +862,26 @@
"foreignKeys": {
"application_method_target_rules_application_method_id_foreign": {
"constraintName": "application_method_target_rules_application_method_id_foreign",
"columnNames": ["application_method_id"],
"columnNames": [
"application_method_id"
],
"localTableName": "public.application_method_target_rules",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_application_method",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"application_method_target_rules_promotion_rule_id_foreign": {
"constraintName": "application_method_target_rules_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"columnNames": [
"promotion_rule_id"
],
"localTableName": "public.application_method_target_rules",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
@@ -799,7 +914,10 @@
"indexes": [
{
"keyName": "application_method_buy_rules_pkey",
"columnNames": ["application_method_id", "promotion_rule_id"],
"columnNames": [
"application_method_id",
"promotion_rule_id"
],
"composite": true,
"primary": true,
"unique": true
@@ -809,18 +927,26 @@
"foreignKeys": {
"application_method_buy_rules_application_method_id_foreign": {
"constraintName": "application_method_buy_rules_application_method_id_foreign",
"columnNames": ["application_method_id"],
"columnNames": [
"application_method_id"
],
"localTableName": "public.application_method_buy_rules",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_application_method",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"application_method_buy_rules_promotion_rule_id_foreign": {
"constraintName": "application_method_buy_rules_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"columnNames": [
"promotion_rule_id"
],
"localTableName": "public.application_method_buy_rules",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
@@ -893,7 +1019,9 @@
"schema": "public",
"indexes": [
{
"columnNames": ["promotion_rule_id"],
"columnNames": [
"promotion_rule_id"
],
"composite": false,
"keyName": "IDX_promotion_rule_promotion_rule_value_id",
"primary": false,
@@ -901,7 +1029,9 @@
},
{
"keyName": "promotion_rule_value_pkey",
"columnNames": ["id"],
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
@@ -911,9 +1041,13 @@
"foreignKeys": {
"promotion_rule_value_promotion_rule_id_foreign": {
"constraintName": "promotion_rule_value_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"columnNames": [
"promotion_rule_id"
],
"localTableName": "public.promotion_rule_value",
"referencedColumnNames": ["id"],
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
@@ -3,14 +3,14 @@ import { Migration } from "@mikro-orm/migrations"
export class Migration20240227120221 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table if not exists "promotion_campaign" ("id" text not null, "name" text not null, "description" text null, "currency" text null, "campaign_identifier" text not null, "starts_at" timestamptz null, "ends_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_pkey" primary key ("id"));'
'create table if not exists "promotion_campaign" ("id" text not null, "name" text not null, "description" text null, "campaign_identifier" text not null, "starts_at" timestamptz null, "ends_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_pkey" primary key ("id"));'
)
this.addSql(
'alter table if exists "promotion_campaign" add constraint "IDX_campaign_identifier_unique" unique ("campaign_identifier");'
)
this.addSql(
'create table if not exists "promotion_campaign_budget" ("id" text not null, "type" text check ("type" in (\'spend\', \'usage\')) not null, "campaign_id" text not null, "limit" numeric null, "raw_limit" jsonb null, "used" numeric null, "raw_used" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_budget_pkey" primary key ("id"));'
'create table if not exists "promotion_campaign_budget" ("id" text not null, "type" text check ("type" in (\'spend\', \'usage\')) not null, "campaign_id" text not null, "limit" numeric null, "raw_limit" jsonb null, "used" numeric not null default 0, "raw_used" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_budget_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_campaign_budget_type" on "promotion_campaign_budget" ("type");'
@@ -113,5 +113,37 @@ export class Migration20240227120221 extends Migration {
this.addSql(
'alter table if exists "promotion_rule_value" add constraint "promotion_rule_value_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "promotion" drop constraint if exists "promotion_campaign_id_foreign";'
)
this.addSql(
'alter table if exists "promotion" add constraint "promotion_campaign_id_foreign" foreign key ("campaign_id") references "promotion_campaign" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table if exists "promotion_application_method" add column if not exists "currency_code" text not null;'
)
this.addSql(
'CREATE INDEX IF NOT EXISTS "IDX_promotion_application_method_currency_code" ON "promotion_application_method" (currency_code) WHERE deleted_at IS NOT NULL;'
)
this.addSql(
'alter table "promotion_application_method" alter column "value" type numeric using ("value"::numeric);'
)
this.addSql(
'alter table "promotion_application_method" alter column "raw_value" type jsonb using ("raw_value"::jsonb);'
)
this.addSql(
'alter table "promotion_application_method" alter column "raw_value" set not null;'
)
this.addSql(
'alter table if exists "promotion_campaign_budget" add column if not exists "currency_code" text null;'
)
}
}
@@ -3,13 +3,13 @@ import {
ApplicationMethodTargetTypeValues,
ApplicationMethodTypeValues,
BigNumberRawValue,
DAL,
} from "@medusajs/types"
import {
BigNumber,
DALUtils,
MikroOrmBigNumberProperty,
PromotionUtils,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
import {
@@ -22,34 +22,34 @@ import {
ManyToMany,
OnInit,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import Promotion from "./promotion"
import PromotionRule from "./promotion-rule"
type OptionalFields =
| "value"
| "max_quantity"
| "apply_to_quantity"
| "buy_rules_min_quantity"
| "allocation"
| DAL.SoftDeletableEntityDateColumns
const tableName = "promotion_application_method"
const CurrencyCodeIndex = createPsqlIndexStatementHelper({
tableName,
columns: "currency_code",
where: "deleted_at IS NOT NULL",
})
@Entity({ tableName: "promotion_application_method" })
@Entity({ tableName })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class ApplicationMethod {
[OptionalProps]?: OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@MikroOrmBigNumberProperty({ nullable: true })
value: BigNumber | number | null = null
@MikroOrmBigNumberProperty()
value: BigNumber | number | null
@Property({ columnType: "jsonb", nullable: true })
raw_value: BigNumberRawValue | null = null
@Property({ columnType: "jsonb" })
raw_value: BigNumberRawValue | null
@Property({ columnType: "text", nullable: true })
@CurrencyCodeIndex.MikroORMIndex()
currency_code: string | null = null
@Property({ columnType: "numeric", nullable: true, serializer: Number })
max_quantity?: number | null = null
@@ -47,17 +47,20 @@ export default class CampaignBudget {
})
campaign: Campaign | null = null
@Property({ columnType: "text", nullable: true })
currency_code: string | null = null
@MikroOrmBigNumberProperty({ nullable: true })
limit: BigNumber | number | null = null
@Property({ columnType: "jsonb", nullable: true })
raw_limit: BigNumberRawValue | null = null
@MikroOrmBigNumberProperty({ nullable: true })
used: BigNumber | number | null = null
@MikroOrmBigNumberProperty({ default: 0 })
used: BigNumber | number = 0
@Property({ columnType: "jsonb", nullable: true })
raw_used: BigNumberRawValue | null = null
@Property({ columnType: "jsonb" })
raw_used: BigNumberRawValue
@Property({
onCreate: () => new Date(),
@@ -19,7 +19,6 @@ import Promotion from "./promotion"
type OptionalRelations = "budget"
type OptionalFields =
| "description"
| "currency"
| "starts_at"
| "ends_at"
| DAL.SoftDeletableEntityDateColumns
@@ -40,9 +39,6 @@ export default class Campaign {
@Property({ columnType: "text", nullable: true })
description: string | null = null
@Property({ columnType: "text", nullable: true })
currency: string | null = null
@Property({ columnType: "text" })
@Unique({
name: "IDX_campaign_identifier_unique",
@@ -1,4 +1,5 @@
import {
CampaignBudgetTypeValues,
Context,
DAL,
InternalModuleDeclaration,
@@ -20,6 +21,7 @@ import {
arrayDifference,
deduplicate,
isDefined,
isPresent,
isString,
} from "@medusajs/utils"
import {
@@ -475,6 +477,11 @@ export default class PromotionModuleService<
const promotionsData: CreatePromotionDTO[] = []
const applicationMethodsData: CreateApplicationMethodDTO[] = []
const campaignsData: CreateCampaignDTO[] = []
const existingCampaigns = await this.campaignService_.list(
{ id: data.map((d) => d.campaign_id).filter((id) => isString(id)) },
{ relations: ["budget"] },
sharedContext
)
const promotionCodeApplicationMethodDataMap = new Map<
string,
@@ -504,12 +511,10 @@ export default class PromotionModuleService<
campaign_id: campaignId,
...promotionData
} of data) {
if (applicationMethodData) {
promotionCodeApplicationMethodDataMap.set(
promotionData.code,
applicationMethodData
)
}
promotionCodeApplicationMethodDataMap.set(
promotionData.code,
applicationMethodData
)
if (rulesData) {
promotionCodeRulesDataMap.set(promotionData.code, rulesData)
@@ -522,6 +527,38 @@ export default class PromotionModuleService<
)
}
if (!campaignData && !campaignId) {
promotionsData.push({ ...promotionData })
continue
}
const existingCampaign = existingCampaigns.find(
(c) => c.id === campaignId
)
if (campaignId && !existingCampaign) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Could not find campaign with id - ${campaignId}`
)
}
const campaignCurrency =
campaignData?.budget?.currency_code ||
existingCampaigns.find((c) => c.id === campaignId)?.budget
?.currency_code
if (
campaignData?.budget?.type === CampaignBudgetType.SPEND &&
campaignCurrency !== applicationMethodData?.currency_code
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Currency between promotion and campaigns should match`
)
}
if (campaignData) {
promotionCodeCampaignMap.set(promotionData.code, campaignData)
}
@@ -536,7 +573,6 @@ export default class PromotionModuleService<
promotionsData,
sharedContext
)
const promotionsToAdd: PromotionTypes.AddPromotionsToCampaignDTO[] = []
for (const promotion of createdPromotions) {
const applMethodData = promotionCodeApplicationMethodDataMap.get(
@@ -708,6 +744,10 @@ export default class PromotionModuleService<
{ id: promotionIds },
{ relations: ["application_method"] }
)
const existingCampaigns = await this.campaignService_.list(
{ id: data.map((d) => d.campaign_id).filter((d) => isPresent(d)) },
{ relations: ["budget"] }
)
const existingPromotionsMap = new Map<string, Promotion>(
existingPromotions.map((promotion) => [promotion.id, promotion])
@@ -721,20 +761,40 @@ export default class PromotionModuleService<
campaign_id: campaignId,
...promotionData
} of data) {
if (campaignId) {
const existingCampaign = existingCampaigns.find(
(c) => c.id === campaignId
)
const existingPromotion = existingPromotionsMap.get(promotionData.id)!
const existingApplicationMethod = existingPromotion?.application_method
const promotionCurrencyCode =
existingApplicationMethod?.currency_code ||
applicationMethodData?.currency_code
if (campaignId && !existingCampaign) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Could not find campaign with id ${campaignId}`
)
}
if (
campaignId &&
existingCampaign?.budget?.type === CampaignBudgetType.SPEND &&
existingCampaign.budget.currency_code !== promotionCurrencyCode
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Currency code doesn't match for campaign (${campaignId}) and promotion (${existingPromotion.id})`
)
}
if (isDefined(campaignId)) {
promotionsData.push({ ...promotionData, campaign_id: campaignId })
} else {
promotionsData.push(promotionData)
}
if (!applicationMethodData) {
continue
}
const existingPromotion = existingPromotionsMap.get(promotionData.id)
const existingApplicationMethod = existingPromotion?.application_method
if (!existingApplicationMethod) {
if (!applicationMethodData || !existingApplicationMethod) {
continue
}
@@ -913,9 +973,11 @@ export default class PromotionModuleService<
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionRuleDTO[]> {
const promotion = await this.promotionService_.retrieve(promotionId, {
relations: ["application_method"],
})
const promotion = await this.promotionService_.retrieve(
promotionId,
{ relations: ["application_method"] },
sharedContext
)
const applicationMethod = promotion.application_method
@@ -1153,6 +1215,8 @@ export default class PromotionModuleService<
)
if (campaignBudgetData) {
this.validateCampaignBudgetData(campaignBudgetData)
campaignBudgetsData.push({
...campaignBudgetData,
campaign: createdCampaign.id,
@@ -1170,6 +1234,28 @@ export default class PromotionModuleService<
return createdCampaigns
}
protected validateCampaignBudgetData(data: {
type?: CampaignBudgetTypeValues
currency_code?: string | null
}) {
if (!data.type) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Campaign Budget type is a required field`
)
}
if (
data.type === CampaignBudgetType.SPEND &&
!isPresent(data.currency_code)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Campaign Budget type is a required field`
)
}
}
async updateCampaigns(
data: PromotionTypes.UpdateCampaignDTO,
sharedContext?: Context
@@ -1207,7 +1293,8 @@ export default class PromotionModuleService<
) {
const campaignIds = data.map((d) => d.id)
const campaignsData: UpdateCampaignDTO[] = []
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
const updateBudgetData: UpdateCampaignBudgetDTO[] = []
const createBudgetData: CreateCampaignBudgetDTO[] = []
const existingCampaigns = await this.listCampaigns(
{ id: campaignIds },
@@ -1220,18 +1307,38 @@ export default class PromotionModuleService<
)
for (const updateCampaignData of data) {
const { budget: campaignBudgetData, ...campaignData } = updateCampaignData
const existingCampaign = existingCampaignsMap.get(campaignData.id)
const existingCampaignBudget = existingCampaign?.budget
const { budget: budgetData, ...campaignData } = updateCampaignData
const existingCampaign = existingCampaignsMap.get(campaignData.id)!
campaignsData.push(campaignData)
if (existingCampaignBudget && campaignBudgetData) {
campaignBudgetsData.push({
id: existingCampaignBudget.id,
...campaignBudgetData,
})
// Type & currency code of the budget is immutable, we don't allow for it to be updated.
// If an existing budget is present, we remove the type and currency from being updated
if (
(existingCampaign?.budget && budgetData?.type) ||
budgetData?.currency_code
) {
delete budgetData?.type
delete budgetData?.currency_code
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Campaign budget attributes (type, currency_code) are immutable`
)
}
if (budgetData) {
if (existingCampaign?.budget) {
updateBudgetData.push({
id: existingCampaign.budget.id,
...budgetData,
})
} else {
createBudgetData.push({
...budgetData,
campaign: existingCampaign.id,
})
}
}
}
@@ -1240,11 +1347,12 @@ export default class PromotionModuleService<
sharedContext
)
if (campaignBudgetsData.length) {
await this.campaignBudgetService_.update(
campaignBudgetsData,
sharedContext
)
if (updateBudgetData.length) {
await this.campaignBudgetService_.update(updateBudgetData, sharedContext)
}
if (createBudgetData.length) {
await this.campaignBudgetService_.create(createBudgetData, sharedContext)
}
return updatedCampaigns
@@ -1274,7 +1382,7 @@ export default class PromotionModuleService<
const campaign = await this.campaignService_.retrieve(id, {}, sharedContext)
const promotionsToAdd = await this.promotionService_.list(
{ id: promotionIds, campaign_id: null },
{ take: null },
{ take: null, relations: ["application_method"] },
sharedContext
)
@@ -1292,6 +1400,20 @@ export default class PromotionModuleService<
)
}
const promotionsWithInvalidCurrency = promotionsToAdd.filter(
(promotion) =>
campaign.budget?.type === CampaignBudgetType.SPEND &&
promotion.application_method?.currency_code !==
campaign?.budget?.currency_code
)
if (promotionsWithInvalidCurrency.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot add promotions to campaign where currency_code don't match.`
)
}
await this.promotionService_.update(
promotionsToAdd.map((promotion) => ({
id: promotion.id,
@@ -12,6 +12,7 @@ export interface CreateApplicationMethodDTO {
target_type: ApplicationMethodTargetTypeValues
allocation?: ApplicationMethodAllocationValues
value?: number
currency_code: string
promotion: Promotion | string | PromotionDTO
max_quantity?: number | null
buy_rules_min_quantity?: number | null
@@ -19,11 +20,12 @@ export interface CreateApplicationMethodDTO {
}
export interface UpdateApplicationMethodDTO {
id: string
id?: string
type?: ApplicationMethodTypeValues
target_type?: ApplicationMethodTargetTypeValues
allocation?: ApplicationMethodAllocationValues
value?: number
currency_code?: string
promotion?: Promotion | string | PromotionDTO
max_quantity?: number | null
buy_rules_min_quantity?: number | null
@@ -3,7 +3,8 @@ import { Campaign } from "@models"
export interface CreateCampaignBudgetDTO {
type?: CampaignBudgetTypeValues
limit?: number
limit?: number | null
currency_code?: string | null
used?: number
campaign?: Campaign | string
}
@@ -11,6 +12,7 @@ export interface CreateCampaignBudgetDTO {
export interface UpdateCampaignBudgetDTO {
id: string
type?: CampaignBudgetTypeValues
limit?: number
limit?: number | null
currency_code?: string | null
used?: number
}
@@ -4,7 +4,6 @@ import { Promotion } from "@models"
export interface CreateCampaignDTO {
name: string
description?: string
currency?: string
campaign_identifier: string
starts_at?: Date
ends_at?: Date
@@ -15,7 +14,6 @@ export interface UpdateCampaignDTO {
id: string
name?: string
description?: string
currency?: string
campaign_identifier?: string
starts_at?: Date
ends_at?: Date
@@ -4,7 +4,7 @@ export interface CreatePromotionDTO {
code: string
type: PromotionTypeValues
is_automatic?: boolean
campaign_id?: string
campaign_id?: string | null
}
export interface UpdatePromotionDTO {
@@ -12,5 +12,5 @@ export interface UpdatePromotionDTO {
code?: string
type?: PromotionTypeValues
is_automatic?: boolean
campaign_id?: string
campaign_id?: string | null
}