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