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
parent 4a10821bfe
commit d1d23f1e8d
72 changed files with 5380 additions and 3473 deletions

View File

@@ -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,
})

View File

@@ -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"
}

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
},
{

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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"

View File

@@ -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(

View File

@@ -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),

View File

@@ -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 },
],

View File

@@ -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[],
}
}),
}))

View File

@@ -0,0 +1 @@
export * from "./rule-value-form-field"

View File

@@ -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>
)
}
}}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./rules-form-field"

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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`,
}
)

View File

@@ -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,
},
},
},
]

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
)
}}
/>

View File

@@ -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