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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user