feat(dashboard): add campaign create to promotion UI (#7306)

* chore(medusa): strict zod versions in workspace

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

* chore: fix bug with form reset

* chore: address reviews
This commit is contained in:
Riqwan Thamir
2024-05-15 10:43:13 +02:00
committed by GitHub
parent 5c14d283d0
commit bb2b041954
10 changed files with 394 additions and 338 deletions

View File

@@ -25,6 +25,7 @@ import {
PromotionRulesListRes,
PromotionRuleValuesListRes,
} from "../../types/api-responses"
import { campaignsQueryKeys } from "./campaigns"
const PROMOTIONS_QUERY_KEY = "promotions" as const
export const promotionsQueryKeys = {
@@ -185,6 +186,7 @@ export const useCreatePromotion = (
mutationFn: (payload) => client.promotions.create(payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
options?.onSuccess?.(data, variables, context)
},
...options,

View File

@@ -19,7 +19,6 @@ import {
CreateShippingProfileDTO,
CreateStockLocationInput,
InventoryNext,
ShippingOptionDTO,
UpdateApiKeyDTO,
UpdateCampaignDTO,
UpdateCustomerDTO,
@@ -102,7 +101,7 @@ export type DeletePriceListPricesReq = { ids: string[] }
// Promotion
export type CreatePromotionReq = CreatePromotionDTO
export type UpdatePromotionReq = UpdatePromotionDTO
export type UpdatePromotionReq = Omit<UpdatePromotionDTO, "id">
export type BatchAddPromotionRulesReq = { rules: CreatePromotionRuleDTO[] }
export type BatchRemovePromotionRulesReq = { rule_ids: string[] }
export type BatchUpdatePromotionRulesReq = { rules: UpdatePromotionRuleDTO[] }

View File

@@ -1,60 +1,50 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Button,
clx,
CurrencyInput,
DatePicker,
Heading,
Input,
RadioGroup,
Select,
Text,
toast,
} from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { Button, 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"
import { CampaignBudgetTypeValues } from "@medusajs/types"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateCampaign } from "../../../../../hooks/api/campaigns"
import { currencies, getCurrencySymbol } from "../../../../../lib/currencies"
import { CreateCampaignFormFields } from "../../../common/components/create-campaign-form-fields"
const CreateCampaignSchema = zod.object({
name: zod.string(),
export const CreateCampaignSchema = zod.object({
name: zod.string().min(1),
description: zod.string().optional(),
currency: zod.string(),
campaign_identifier: zod.string(),
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"]).optional(),
type: zod.enum(["spend", "usage"]),
}),
})
export const defaultCampaignValues = {
name: "",
description: "",
currency: "",
campaign_identifier: "",
starts_at: undefined,
ends_at: undefined,
budget: {
type: "spend" as CampaignBudgetTypeValues,
limit: undefined,
},
}
export const CreateCampaignForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { mutateAsync, isPending } = useCreateCampaign()
const form = useForm<zod.infer<typeof CreateCampaignSchema>>({
defaultValues: {
name: "",
description: "",
currency: "",
campaign_identifier: "",
starts_at: undefined,
ends_at: undefined,
budget: {
type: "spend",
limit: undefined,
},
},
defaultValues: defaultCampaignValues,
resolver: zodResolver(CreateCampaignSchema),
})
@@ -92,18 +82,6 @@ export const CreateCampaignForm = () => {
)
})
const watchValueType = useWatch({
control: form.control,
name: "budget.type",
})
const isTypeSpend = watchValueType === "spend"
const currencyValue = useWatch({
control: form.control,
name: "currency",
})
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit}>
@@ -127,259 +105,7 @@ export const CreateCampaignForm = () => {
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-col items-center py-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("campaigns.create.header")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("campaigns.create.hint")}
</Text>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.description")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="campaign_identifier"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("campaigns.fields.identifier")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<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"
render={({
field: { value, onChange, ref: _ref, ...field },
}) => {
return (
<Form.Item>
<Form.Label>
{t("campaigns.fields.start_date")}
</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="ends_at"
render={({
field: { value, onChange, ref: _ref, ...field },
}) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => onChange(v ?? null)}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div>
<Heading>{t("campaigns.budget.create.header")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("campaigns.budget.create.hint")}
</Text>
</div>
<Form.Field
control={form.control}
name="budget.type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.budget.fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex 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>
)
}}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="budget.limit"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("campaigns.budget.fields.limit")}
</Form.Label>
<Form.Control>
{isTypeSpend ? (
<CurrencyInput
min={0}
onValueChange={(value) =>
onChange(value ? parseInt(value) : "")
}
code={currencyValue}
symbol={
currencyValue
? getCurrencySymbol(currencyValue)
: ""
}
{...field}
value={value}
/>
) : (
<Input
key="usage"
min={0}
{...field}
value={value}
onChange={(e) => {
onChange(
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<CreateCampaignFormFields form={form} />
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>

View File

@@ -0,0 +1,274 @@
import {
clx,
CurrencyInput,
DatePicker,
Heading,
Input,
RadioGroup,
Select,
Text,
} from "@medusajs/ui"
import { useEffect } from "react"
import { useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { currencies, getCurrencySymbol } from "../../../../../lib/currencies"
export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
const { t } = useTranslation()
const watchValueType = useWatch({
control: form.control,
name: `${fieldScope}budget.type`,
})
const isTypeSpend = watchValueType === "spend"
const currencyValue = useWatch({
control: form.control,
name: `${fieldScope}currency`,
})
useEffect(() => {
form.setValue(`${fieldScope}budget.limit`, undefined)
}, [watchValueType])
return (
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("campaigns.create.header")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("campaigns.create.hint")}
</Text>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name={`${fieldScope}name`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`${fieldScope}description`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.description")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`${fieldScope}campaign_identifier`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.identifier")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<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>
)
}}
/>
<Form.Field
control={form.control}
name={`${fieldScope}starts_at`}
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.start_date")}</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`${fieldScope}ends_at`}
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => onChange(v ?? null)}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div>
<Heading>{t("campaigns.budget.create.header")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("campaigns.budget.create.hint")}
</Text>
</div>
<Form.Field
control={form.control}
name={`${fieldScope}budget.type`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.budget.fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex 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>
)
}}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<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.Control>
{isTypeSpend ? (
<CurrencyInput
min={0}
onValueChange={(value) =>
onChange(value ? parseInt(value) : "")
}
code={currencyValue}
symbol={
currencyValue ? getCurrencySymbol(currencyValue) : ""
}
{...field}
value={value}
/>
) : (
<Input
type="number"
key="usage"
min={0}
{...field}
value={value}
onChange={(e) => {
onChange(
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-campaign-form-fields"

View File

@@ -1,29 +1,38 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { CampaignDTO, PromotionDTO } from "@medusajs/types"
import { CampaignResponse, PromotionDTO } from "@medusajs/types"
import { Button, clx, RadioGroup, Select } from "@medusajs/ui"
import { useEffect } from "react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { CampaignDetails } from "./campaign-details"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdatePromotion } from "../../../../../hooks/api/promotions"
import { CreateCampaignFormFields } from "../../../../campaigns/common/components/create-campaign-form-fields"
import { CampaignDetails } from "./campaign-details"
type EditPromotionFormProps = {
promotion: PromotionDTO
campaigns: CampaignDTO[]
campaigns: CampaignResponse[]
}
const EditPromotionSchema = zod.object({
campaign_id: zod.string().optional(),
existing: zod.string().toLowerCase(),
campaign_id: zod.string().optional().nullable(),
campaign_choice: zod.enum(["none", "existing"]).optional(),
})
export const AddCampaignPromotionFields = ({ form, campaigns }) => {
export const AddCampaignPromotionFields = ({
form,
campaigns,
withNewCampaign = true,
}: {
form: any
campaigns: CampaignResponse[]
withNewCampaign?: boolean
}) => {
const { t } = useTranslation()
const watchCampaignId = useWatch({
control: form.control,
@@ -46,9 +55,10 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
return (
<Form.Item>
<Form.Label>Method</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
className="flex gap-y-3"
{...field}
value={field.value}
onValueChange={field.onChange}
@@ -57,8 +67,8 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
value={"none"}
label={t("promotions.form.campaign.none.title")}
description={t("promotions.form.campaign.none.description")}
className={clx("", {
"border-2 border-ui-border-interactive":
className={clx("border", {
"border border-ui-border-interactive":
"none" === field.value,
})}
/>
@@ -69,22 +79,25 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
description={t(
"promotions.form.campaign.existing.description"
)}
className={clx("", {
"border-2 border-ui-border-interactive":
className={clx("border", {
"border border-ui-border-interactive":
"existing" === field.value,
})}
/>
<RadioGroup.ChoiceBox
value={"new"}
label={t("promotions.form.campaign.new.title")}
description={t("promotions.form.campaign.new.description")}
className={clx("", {
"border-2 border-ui-border-interactive":
"new" === field.value,
})}
disabled
/>
{withNewCampaign && (
<RadioGroup.ChoiceBox
value={"new"}
label={t("promotions.form.campaign.new.title")}
description={t(
"promotions.form.campaign.new.description"
)}
className={clx("border", {
"border border-ui-border-interactive":
"new" === field.value,
})}
/>
)}
</RadioGroup>
</Form.Control>
@@ -127,6 +140,10 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
/>
)}
{watchCampaignChoice === "new" && (
<CreateCampaignFormFields form={form} fieldScope="campaign." />
)}
<CampaignDetails campaign={selectedCampaign} />
</div>
)
@@ -143,19 +160,12 @@ export const AddCampaignPromotionForm = ({
const form = useForm<zod.infer<typeof EditPromotionSchema>>({
defaultValues: {
campaign_id: campaign?.id,
existing: "true",
campaign_choice: campaign?.id ? "existing" : "none",
},
resolver: zodResolver(EditPromotionSchema),
})
const watchCampaignId = useWatch({
control: form.control,
name: "campaign_id",
})
const selectedCampaign = campaigns.find((c) => c.id === watchCampaignId)
const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{ campaign_id: data.campaign_id },
@@ -163,11 +173,30 @@ export const AddCampaignPromotionForm = ({
)
})
const watchCampaignChoice = useWatch({
control: form.control,
name: "campaign_choice",
})
useEffect(() => {
if (watchCampaignChoice === "none") {
form.setValue("campaign_id", null)
}
if (watchCampaignChoice === "existing") {
form.setValue("campaign_id", campaign?.id)
}
}, [watchCampaignChoice])
return (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<AddCampaignPromotionFields form={form} campaigns={campaigns} />
<AddCampaignPromotionFields
form={form}
campaigns={campaigns}
withNewCampaign={false}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>

View File

@@ -1,10 +1,10 @@
import { CampaignDTO } from "@medusajs/types"
import { CampaignResponse } from "@medusajs/types"
import { Heading, Text } from "@medusajs/ui"
import { Fragment } from "react"
import { useTranslation } from "react-i18next"
type CampaignDetailsProps = {
campaign?: CampaignDTO
campaign?: CampaignResponse
}
export const CampaignDetails = ({ campaign }: CampaignDetailsProps) => {

View File

@@ -28,6 +28,7 @@ import {
} from "../../../../../components/route-modal"
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 { AddCampaignPromotionFields } from "../../../promotion-add-campaign/components/add-campaign-promotion-form"
import { Tab } from "./constants"
@@ -89,6 +90,7 @@ export const CreatePromotionForm = ({
target_rules: generateRuleAttributes(targetRules),
buy_rules: generateRuleAttributes(buyRules),
},
campaign: undefined,
},
resolver: zodResolver(CreatePromotionSchema),
})
@@ -296,6 +298,29 @@ export const CreatePromotionForm = ({
return "not-started"
}, [detailsValidated])
const watchCampaignChoice = useWatch({
control: form.control,
name: "campaign_choice",
})
useEffect(() => {
const formData = form.getValues()
if (watchCampaignChoice !== "existing") {
form.setValue("campaign_id", undefined)
}
if (watchCampaignChoice !== "new") {
form.setValue("campaign", undefined)
}
if (watchCampaignChoice === "new") {
if (!formData.campaign || !formData.campaign?.budget?.type) {
form.setValue("campaign", defaultCampaignValues)
}
}
}, [watchCampaignChoice])
return (
<RouteFocusModal.Form form={form}>
<form
@@ -700,9 +725,7 @@ export const CreatePromotionForm = ({
<Input
{...form.register(
"application_method.max_quantity",
{
valueAsNumber: true,
}
{ valueAsNumber: true }
)}
type="number"
min={1}

View File

@@ -1,4 +1,5 @@
import { z } from "zod"
import { CreateCampaignSchema } from "../../../../campaigns/campaign-create/components/create-campaign-form"
const RuleSchema = z.array(
z.object({
@@ -33,4 +34,5 @@ export const CreatePromotionSchema = z.object({
type: z.enum(["fixed", "percentage"]),
target_type: z.enum(["order", "shipping_methods", "items"]),
}),
campaign: CreateCampaignSchema.optional(),
})

View File

@@ -163,7 +163,7 @@ export const AdminCreatePromotion = z
code: z.string(),
is_automatic: z.boolean().optional(),
type: z.nativeEnum(PromotionType),
campaign_id: z.string().optional(),
campaign_id: z.string().optional().nullable(),
campaign: AdminCreateCampaign.optional(),
application_method: AdminCreateApplicationMethod,
rules: z.array(AdminCreatePromotionRule).optional(),
@@ -181,7 +181,7 @@ export const AdminUpdatePromotion = z
code: z.string().optional(),
is_automatic: z.boolean().optional(),
type: z.nativeEnum(PromotionType).optional(),
campaign_id: z.string().optional(),
campaign_id: z.string().optional().nullable(),
campaign: AdminCreateCampaign.optional(),
application_method: AdminUpdateApplicationMethod.optional(),
rules: z.array(AdminCreatePromotionRule).optional(),