feat(medusa,types): create promotion flows (#7029)

* chore: create promotion phase

* chore: fix specs + minor ui changes

* chore: minor fixes

* chore: added changeset

* address pr reviews

* chore: fix spec

* Update packages/admin-next/dashboard/src/v2-routes/promotions/common/edit-rules/edit-rules.tsx

Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com>

* chore: fix specs

---------

Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2024-04-23 12:08:39 +02:00
committed by GitHub
parent b61dcb84c9
commit 93ef94cad3
37 changed files with 1807 additions and 569 deletions

View File

@@ -824,6 +824,8 @@
"value": "Value",
"campaign": "Campaign",
"allocation": "Allocation",
"addCondition": "Add condition",
"clearAll": "Clear all",
"conditions": {
"rules": {
"title": "Who can use this code?",
@@ -839,6 +841,10 @@
}
}
},
"errors": {
"requiredField": "Required field"
},
"create": {},
"edit": {
"title": "Edit Promotion Details",
"rules": {
@@ -855,6 +861,9 @@
"title": "Add Promotion To Campaign"
},
"form": {
"required": "Required",
"and": "AND",
"selectAttribute": "Select Attribute",
"campaign": {
"existing": {
"title": "Existing Campaign",
@@ -875,17 +884,31 @@
},
"automatic": {
"title": "Automatic",
"description": "Customers will automatically see this during checkout"
"description": "Customers will see this at checkout"
}
},
"max_quantity": {
"title": "Maximum Quantity",
"description": "Maximum quantity of items this promotion applies to"
},
"type": {
"standard": {
"title": "Standard",
"description": "A standard promotion"
},
"buyget": {
"title": "Buy Get",
"description": "Buy X get Y promotion"
}
},
"allocation": {
"each": {
"title": "Each",
"description": "Applies value on each product"
"description": "Applies value on each item"
},
"across": {
"title": "Across",
"description": "Applies value proportionally across products"
"description": "Applies value across items"
}
},
"code": {

View File

@@ -29,7 +29,7 @@ import {
const PROMOTIONS_QUERY_KEY = "promotions" as const
export const promotionsQueryKeys = {
...queryKeysFactory(PROMOTIONS_QUERY_KEY),
listRules: (id: string, ruleType: string) => [
listRules: (id: string | null, ruleType: string) => [
PROMOTIONS_QUERY_KEY,
id,
ruleType,
@@ -60,7 +60,7 @@ export const usePromotion = (
}
export const usePromotionRules = (
id: string,
id: string | null,
ruleType: string,
options?: Omit<
UseQueryOptions<

View File

@@ -172,6 +172,11 @@ export const v2Routes: RouteObject[] = [
path: "",
lazy: () => import("../../v2-routes/promotions/promotion-list"),
},
{
path: "create",
lazy: () =>
import("../../v2-routes/promotions/promotion-create"),
},
{
path: ":id",
lazy: () =>
@@ -196,7 +201,8 @@ export const v2Routes: RouteObject[] = [
},
{
path: ":ruleType/edit",
lazy: () => import("../../v2-routes/promotions/edit-rules"),
lazy: () =>
import("../../v2-routes/promotions/common/edit-rules"),
},
],
},

View File

@@ -2,11 +2,11 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { UserRoles } from "@medusajs/medusa"
import { Alert, Button, Heading, Input, Text, Tooltip } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import i18n from "i18next"
import { useAdminAcceptInvite } from "medusa-react"
import { Trans, useTranslation } from "react-i18next"
import { Link, useSearchParams } from "react-router-dom"
import * as z from "zod"
import i18n from "i18next"
import { useState } from "react"
import { useForm } from "react-hook-form"

View File

@@ -0,0 +1,423 @@
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 i18n from "i18next"
import { Fragment, useState } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Combobox } from "../../../../../../components/common/combobox"
import { Form } from "../../../../../../components/common/form"
import { RouteDrawer } from "../../../../../../components/route-modal"
import { usePromotionRuleValues } from "../../../../../../hooks/api/promotions"
import { RuleTypeValues } from "../../edit-rules"
import { getDisguisedRules } from "./utils"
type EditPromotionFormProps = {
promotion: PromotionDTO
rules: PromotionRuleDTO[]
ruleType: RuleTypeValues
attributes: any[]
operators: any[]
handleSubmit: any
isSubmitting: boolean
}
const EditRules = zod.object({
rules: zod.array(
zod.object({
id: zod.string().optional(),
attribute: zod
.string()
.min(1, { message: i18n.t("promotions.form.required") }),
operator: zod
.string()
.min(1, { message: i18n.t("promotions.form.required") }),
values: zod.union([
zod.number().min(1, { message: i18n.t("promotions.form.required") }),
zod.string().min(1, { message: i18n.t("promotions.form.required") }),
zod
.array(zod.string())
.min(1, { message: i18n.t("promotions.form.required") }),
]),
required: zod.boolean().optional(),
field_type: zod.string().optional(),
})
),
})
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="flex flex-row gap-2 bg-ui-bg-subtle py-2 px-2 rounded-xl border border-ui-border-base">
<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="absolute top-0 bottom-0 left-[40px] z-[-1] border-ui-border-strong 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="inline-block text-ui-fg-muted hover:text-ui-fg-subtle ml-2"
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,
ruleType,
attributes,
operators,
handleSubmit,
isSubmitting,
}: EditPromotionFormProps) => {
const { t } = useTranslation()
const requiredAttributes = attributes?.filter((ra) => ra.required) || []
const requiredAttributeValues = requiredAttributes?.map((ra) => ra.value)
const disguisedRules =
getDisguisedRules(promotion, requiredAttributes, ruleType) || []
const [rulesToRemove, setRulesToRemove] = useState([])
const form = useForm<zod.infer<typeof EditRules>>({
defaultValues: {
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!),
})),
},
resolver: zodResolver(EditRules),
})
const { fields, append, remove, update } = useFieldArray({
control: form.control,
name: "rules",
keyName: "rules_id",
})
const handleFormSubmit = form.handleSubmit(handleSubmit(rulesToRemove))
return (
<RouteDrawer.Form form={form}>
<form onSubmit={handleFormSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<RulesFormField
form={form}
ruleType={ruleType}
attributes={attributes}
operators={operators}
fields={fields}
setRulesToRemove={setRulesToRemove}
rulesToRemove={rulesToRemove}
appendRule={append}
removeRule={remove}
updateRule={update}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary" disabled={isSubmitting}>
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isSubmitting}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1,137 @@
import { PromotionDTO, PromotionRuleDTO } from "@medusajs/types"
import { useRouteModal } from "../../../../../../components/route-modal"
import {
usePromotionAddRules,
usePromotionRemoveRules,
usePromotionRuleAttributes,
usePromotionRuleOperators,
usePromotionUpdateRules,
useUpdatePromotion,
} from "../../../../../../hooks/api/promotions"
import { RuleTypeValues } from "../../edit-rules"
import { EditRulesForm } from "../edit-rules-form"
import { getDisguisedRules } from "../edit-rules-form/utils"
type EditPromotionFormProps = {
promotion: PromotionDTO
rules: PromotionRuleDTO[]
ruleType: RuleTypeValues
}
export const EditRulesWrapper = ({
promotion,
rules,
ruleType,
}: EditPromotionFormProps) => {
const { handleSuccess } = useRouteModal()
const {
attributes,
isError: isAttributesError,
error: attributesError,
} = usePromotionRuleAttributes(ruleType!)
const {
operators,
isError: isOperatorsError,
error: operatorsError,
} = usePromotionRuleOperators()
if (isAttributesError || isOperatorsError) {
throw attributesError || operatorsError
}
const requiredAttributes = attributes?.filter((ra) => ra.required) || []
const disguisedRules =
getDisguisedRules(promotion, requiredAttributes, ruleType) || []
const { mutateAsync: updatePromotion } = useUpdatePromotion(promotion.id)
const { mutateAsync: addPromotionRules } = usePromotionAddRules(
promotion.id,
ruleType
)
const { mutateAsync: removePromotionRules } = usePromotionRemoveRules(
promotion.id,
ruleType
)
const { mutateAsync: updatePromotionRules, isPending } =
usePromotionUpdateRules(promotion.id, ruleType)
const handleSubmit = (rulesToRemove?: any[]) => {
return async function (data) {
const applicationMethodData: Record<any, any> = {}
const { rules: allRules = [] } = data
const disguisedRulesData = allRules.filter((rule) =>
disguisedRules.map((rule) => rule.id).includes(rule.id!)
)
// For all the rules that were disguised, convert them to actual values in the
// 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)
}
// This variable will contain the rules that are actual rule objects, without the disguised
// objects
const rulesData = allRules.filter(
(rule) => !disguisedRules.map((rule) => rule.id).includes(rule.id!)
)
const rulesToCreate = rulesData.filter((rule) => !("id" in rule))
const rulesToUpdate = rulesData.filter(
(rule) => typeof rule.id === "string"
)
if (Object.keys(applicationMethodData).length) {
await updatePromotion({ application_method: applicationMethodData })
}
rulesToCreate.length &&
(await addPromotionRules({
rules: rulesToCreate.map((rule) => {
return {
attribute: rule.attribute,
operator: rule.operator,
values: rule.values,
} as any
}),
}))
rulesToRemove?.length &&
(await removePromotionRules({
rule_ids: rulesToRemove.map((r) => r.id!),
}))
rulesToUpdate.length &&
(await updatePromotionRules({
rules: rulesToUpdate.map((rule) => {
return {
id: rule.id!,
attribute: rule.attribute,
operator: rule.operator,
values: rule.values,
} as any
}),
}))
handleSuccess()
}
}
if (attributes && operators) {
return (
<EditRulesForm
promotion={promotion}
rules={rules}
ruleType={ruleType}
attributes={attributes}
operators={operators}
handleSubmit={handleSubmit}
isSubmitting={isPending}
/>
)
}
}

View File

@@ -0,0 +1 @@
export * from "./edit-rules-wrapper"

View File

@@ -2,13 +2,9 @@ import { PromotionRuleDTO } from "@medusajs/types"
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import {
usePromotion,
usePromotionRuleAttributes,
usePromotionRuleOperators,
} from "../../../hooks/api/promotions"
import { EditRulesForm } from "./components/edit-rules-form"
import { RouteDrawer } from "../../../../components/route-modal"
import { usePromotion } from "../../../../hooks/api/promotions"
import { EditRulesWrapper } from "./components/edit-rules-wrapper"
export enum RuleType {
RULES = "rules",
@@ -33,17 +29,7 @@ export const EditRules = () => {
const ruleType = params.ruleType as RuleTypeValues
const id = params.id as string
const rules: PromotionRuleDTO[] = []
const { promotion, isLoading, isError, error } = usePromotion(id)
const {
attributes,
isError: isAttributesError,
error: attributesError,
} = usePromotionRuleAttributes(ruleType!)
const {
operators,
isError: isOperatorsError,
error: operatorsError,
} = usePromotionRuleOperators()
const { promotion, isPending: isLoading, isError, error } = usePromotion(id)
if (promotion) {
if (ruleType === RuleType.RULES) {
@@ -55,8 +41,8 @@ export const EditRules = () => {
}
}
if (isError || isAttributesError || isOperatorsError) {
throw error || attributesError || operatorsError
if (isError) {
throw error
}
return (
@@ -65,13 +51,11 @@ export const EditRules = () => {
<Heading>{t(`promotions.edit.${ruleType}.title`)}</Heading>
</RouteDrawer.Header>
{!isLoading && promotion && attributes && operators && (
<EditRulesForm
{!isLoading && promotion && (
<EditRulesWrapper
promotion={promotion}
rules={rules}
ruleType={ruleType}
attributes={attributes}
operators={operators}
/>
)}
</RouteDrawer>

View File

@@ -1,442 +0,0 @@
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 { Fragment, useState } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import {
usePromotionAddRules,
usePromotionRemoveRules,
usePromotionRuleValues,
usePromotionUpdateRules,
useUpdatePromotion,
} from "../../../../../hooks/api/promotions"
import { RuleTypeValues } from "../../edit-rules"
import { getDisguisedRules } from "./utils"
type EditPromotionFormProps = {
promotion: PromotionDTO
rules: PromotionRuleDTO[]
ruleType: RuleTypeValues
attributes: any[]
operators: any[]
}
const EditRules = zod.object({
rules: zod.array(
zod.object({
id: zod.string().optional(),
attribute: zod.string().min(1, { message: "Required field" }),
operator: zod.string().min(1, { message: "Required field" }),
values: zod.union([
zod.number().min(1, { message: "Required field" }),
zod.string().min(1, { message: "Required field" }),
zod.array(zod.string()).min(1, { message: "Required field" }),
]),
required: zod.boolean().optional(),
field_type: zod.string().optional(),
})
),
})
const fetchOptionsForRule = (ruleType: string, attribute: string) => {
const { values } = usePromotionRuleValues(ruleType, attribute)
return values || []
}
export const EditRulesForm = ({
promotion,
rules,
ruleType,
attributes,
operators,
}: EditPromotionFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const requiredAttributes = attributes?.filter((ra) => ra.required) || []
const requiredAttributeValues = requiredAttributes?.map((ra) => ra.value)
const disguisedRules =
getDisguisedRules(promotion, requiredAttributes, ruleType) || []
const [rulesToRemove, setRulesToRemove] = useState([])
const form = useForm<zod.infer<typeof EditRules>>({
defaultValues: {
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!),
})),
},
resolver: zodResolver(EditRules),
})
const { fields, append, remove, update } = useFieldArray({
control: form.control,
name: "rules",
keyName: "rules_id",
})
const { mutateAsync: updatePromotion } = useUpdatePromotion(promotion.id)
const { mutateAsync: addPromotionRules } = usePromotionAddRules(
promotion.id,
ruleType
)
const { mutateAsync: removePromotionRules } = usePromotionRemoveRules(
promotion.id,
ruleType
)
const { mutateAsync: updatePromotionRules } = usePromotionUpdateRules(
promotion.id,
ruleType
)
const handleSubmit = form.handleSubmit(async (data) => {
const applicationMethodData: Record<any, any> = {}
const { rules: allRules = [] } = data
const disguisedRulesData = allRules.filter((rule) =>
disguisedRules.map((maskedRule) => maskedRule.id).includes(rule.id!)
)
// For all the rules that were disguised, convert them to actual values in the
// 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)
}
// This variable will contain the rules that are actual rule objects, without the disguised
// objects
const rulesData = allRules.filter(
(rule) =>
!disguisedRules.map((maskedRule) => maskedRule.id).includes(rule.id!)
)
const rulesToCreate = rulesData.filter((rule) => !("id" in rule))
const rulesToUpdate = rulesData.filter(
(rule) => typeof rule.id === "string"
)
if (Object.keys(applicationMethodData).length) {
await updatePromotion({ application_method: applicationMethodData })
}
rulesToCreate.length &&
(await addPromotionRules({
rules: rulesToCreate.map((rule) => {
return {
attribute: rule.attribute,
operator: rule.operator,
values: rule.values,
} as any
}),
}))
rulesToRemove.length &&
(await removePromotionRules({
rule_ids: rulesToRemove.map((r) => r.id!),
}))
rulesToUpdate.length &&
(await updatePromotionRules({
rules: rulesToUpdate.map((rule) => {
return {
id: rule.id!,
attribute: rule.attribute,
operator: rule.operator,
values: rule.values,
} as any
}),
}))
handleSuccess()
})
return (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<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(
`rules.${index}.attribute`
)
const { ref: operatorRef, ...operatorFields } = form.register(
`rules.${index}.operator`
)
const { ref: valuesRef, ...valuesFields } = form.register(
`rules.${index}.values`
)
return (
<Fragment key={index}>
<div className="flex flex-row gap-2 bg-ui-bg-subtle py-2 px-2 rounded-xl border border-ui-border-base">
<div className="grow">
<Form.Field
key={`${identifier}.${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">
Required
</p>
)}
<Form.Control>
<Select
{...field}
onValueChange={(e) => {
update(index, { ...fieldRule, values: [] })
onChange(e)
}}
disabled={fieldRule.required}
>
<Select.Trigger
ref={attributeRef}
className="bg-ui-bg-base"
>
<Select.Value placeholder="Select Attribute" />
</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}.${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>
)
}}
/>
<Form.Field
key={`${identifier}.${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 {
const attribute = attributes?.find(
(attr) => attr.value === fieldRule.attribute
)
const options = attribute
? fetchOptionsForRule(ruleType, attribute.id)
: []
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>
)
}
}}
/>
</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) {
if (fieldRule.id) {
setRulesToRemove([...rulesToRemove, fieldRule])
}
remove(index)
}
}}
/>
</div>
</div>
{index < fields.length - 1 && (
<div className="relative px-6 py-3">
<div className="absolute top-0 bottom-0 left-[40px] z-[-1] border-ui-border-strong 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">
AND
</Badge>
</div>
)}
</Fragment>
)
})}
<div className="mt-8">
<Button
type="button"
variant="secondary"
className="inline-block"
onClick={() => {
append({
attribute: "",
operator: "",
values: [],
required: false,
})
}}
>
Add condition
</Button>
<Button
type="button"
variant="transparent"
className="inline-block text-ui-fg-muted hover:text-ui-fg-subtle ml-2"
onClick={() => {
const indicesToRemove = fields
.map((field, index) => (field.required ? null : index))
.filter((f) => f !== null)
setRulesToRemove(fields.filter((f) => !f.required))
remove(indicesToRemove)
}}
>
Clear all
</Button>
</div>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit">
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { CampaignDTO, PromotionDTO } from "@medusajs/types"
import { Button, RadioGroup, Select } from "@medusajs/ui"
import { Button, clx, RadioGroup, Select } from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
@@ -23,6 +23,97 @@ const EditPromotionSchema = zod.object({
existing: zod.string().toLowerCase(),
})
export const AddCampaignPromotionFields = ({ form, campaigns }) => {
const { t } = useTranslation()
const watchCampaignId = useWatch({
control: form.control,
name: "campaign_id",
})
const selectedCampaign = campaigns.find((c) => c.id === watchCampaignId)
return (
<div className="flex h-full flex-col gap-y-8">
<Form.Field
control={form.control}
name="existing"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>Method</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
value={field.value}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"true"}
label={t("promotions.form.campaign.existing.title")}
description={t(
"promotions.form.campaign.existing.description"
)}
className={clx("", {
"border-2 border-ui-border-interactive":
"true" === field.value,
})}
/>
<RadioGroup.ChoiceBox
value={"false"}
label={t("promotions.form.campaign.new.title")}
description={t("promotions.form.campaign.new.description")}
className={clx("", {
"border-2 border-ui-border-interactive":
"false" === field.value,
})}
disabled
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="campaign_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.form.campaign.existing.title")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{campaigns.map((c) => (
<Select.Item key={c.id} value={c.id}>
{c.name?.toUpperCase()}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<CampaignDetails campaign={selectedCampaign} />
</div>
)
}
export const AddCampaignPromotionForm = ({
promotion,
campaigns,
@@ -58,78 +149,7 @@ export const AddCampaignPromotionForm = ({
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-8">
<Form.Field
control={form.control}
name="existing"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>Method</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
value={field.value}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"true"}
label={t("promotions.form.campaign.existing.title")}
description={t(
"promotions.form.campaign.existing.description"
)}
/>
<RadioGroup.ChoiceBox
value={"false"}
label={t("promotions.form.campaign.new.title")}
description={t(
"promotions.form.campaign.new.description"
)}
disabled
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="campaign_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.form.campaign.existing.title")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{campaigns.map((c) => (
<Select.Item key={c.id} value={c.id}>
{c.name?.toUpperCase()}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<CampaignDetails campaign={selectedCampaign} />
</div>
<AddCampaignPromotionFields form={form} campaigns={campaigns} />
</RouteDrawer.Body>
<RouteDrawer.Footer>

View File

@@ -0,0 +1,11 @@
export enum View {
TEMPLATE = "template",
PROMOTION = "promotion",
CAMPAIGN = "campaign",
}
export enum Tab {
TYPE = "type",
PROMOTION = "promotion",
CAMPAIGN = "campaign",
}

View File

@@ -0,0 +1,775 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Alert,
Button,
clx,
CurrencyInput,
Input,
ProgressTabs,
RadioGroup,
Text,
} from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
import { useFieldArray, useForm, useWatch } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { z } from "zod"
import {
CampaignResponse,
PromotionRuleResponse,
RuleAttributeOptionsResponse,
RuleOperatorOptionsResponse,
} from "@medusajs/types"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreatePromotion } from "../../../../../hooks/api/promotions"
import { getCurrencySymbol } from "../../../../../lib/currencies"
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"
import { CreatePromotionSchema } from "./form-schema"
import { templates } from "./templates"
type CreatePromotionFormProps = {
ruleAttributes: RuleAttributeOptionsResponse[]
targetRuleAttributes: RuleAttributeOptionsResponse[]
buyRuleAttributes: RuleAttributeOptionsResponse[]
operators: RuleOperatorOptionsResponse[]
rules: PromotionRuleResponse[]
targetRules: PromotionRuleResponse[]
buyRules: PromotionRuleResponse[]
campaigns: CampaignResponse[]
}
export const CreatePromotionForm = ({
ruleAttributes,
targetRuleAttributes,
buyRuleAttributes,
operators,
rules,
targetRules,
buyRules,
campaigns,
}: CreatePromotionFormProps) => {
const [tab, setTab] = useState<Tab>(Tab.TYPE)
const [detailsValidated, setDetailsValidated] = useState(false)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const generateRuleAttributes = (rules: PromotionRuleResponse[]) =>
rules.map((rule) => ({
id: rule.id,
required: rule.required,
field_type: rule.field_type,
disguised: rule.disguised,
attribute: rule.attribute!,
operator: rule.operator!,
values: rule?.values?.map((v: { value: string }) => v.value!),
}))
const form = useForm<z.infer<typeof CreatePromotionSchema>>({
defaultValues: {
campaign_id: undefined,
template_id: templates[0].id!,
existing: "true",
is_automatic: "false",
code: "",
type: "standard",
rules: generateRuleAttributes(rules),
application_method: {
allocation: "each",
type: "fixed",
target_type: "items",
max_quantity: 1,
target_rules: generateRuleAttributes(targetRules),
buy_rules: generateRuleAttributes(buyRules),
},
},
resolver: zodResolver(CreatePromotionSchema),
})
const {
fields: ruleFields,
append: appendRule,
remove: removeRule,
update: updateRule,
} = useFieldArray({
control: form.control,
name: "rules",
keyName: "rules_id",
})
const {
fields: targetRuleFields,
append: appendTargetRule,
remove: removeTargetRule,
update: updateTargetRule,
} = useFieldArray({
control: form.control,
name: "application_method.target_rules",
keyName: "target_rules_id",
})
const {
fields: buyRuleFields,
append: appendBuyRule,
remove: removeBuyRule,
update: updateBuyRule,
} = useFieldArray({
control: form.control,
name: "application_method.buy_rules",
keyName: "buy_rules_id",
})
const { mutateAsync: createPromotion } = useCreatePromotion()
const handleSubmit = form.handleSubmit(
async (data) => {
const {
existing,
is_automatic,
template_id,
application_method,
rules,
...promotionData
} = data
const {
target_rules: targetRulesData = [],
buy_rules: buyRulesData = [],
...applicationMethodData
} = application_method
const disguisedRuleAttributes = [
...targetRules.filter((r) => !!r.disguised),
...buyRules.filter((r) => !!r.disguised),
].map((r) => r.attribute)
const attr: Record<any, any> = {}
for (const rule of [...targetRulesData, ...buyRulesData]) {
if (disguisedRuleAttributes.includes(rule.attribute)) {
attr[rule.attribute] = rule.values
}
}
createPromotion({
...promotionData,
rules: rules.map((rule) => ({
operator: rule.operator,
attribute: rule.attribute,
values: rule.values,
})),
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,
})),
},
is_automatic: is_automatic === "true",
}).then(() => handleSuccess())
},
async (error) => {
// TODO: showcase error when something goes wrong
// Wait for alert component and use it here
}
)
const handleContinue = async () => {
switch (tab) {
case Tab.TYPE:
setTab(Tab.PROMOTION)
break
case Tab.PROMOTION:
const valid = await form.trigger()
if (valid) {
setTab(Tab.CAMPAIGN)
} else {
// TODO: Set errors on the root level
}
break
case Tab.CAMPAIGN:
break
}
}
const handleTabChange = (tab: Tab) => {
switch (tab) {
case Tab.TYPE:
setDetailsValidated(false)
setTab(tab)
break
case Tab.PROMOTION:
setDetailsValidated(false)
setTab(tab)
break
case Tab.CAMPAIGN:
setDetailsValidated(false)
setTab(tab)
break
}
}
const watchTemplateId = useWatch({
control: form.control,
name: "template_id",
})
useMemo(() => {
const currentTemplate = templates.find(
(template) => template.id === watchTemplateId
)
if (!currentTemplate) {
return
}
for (const [key, value] of Object.entries(currentTemplate.defaults)) {
if (typeof value === "object") {
for (const [subKey, subValue] of Object.entries(value)) {
form.setValue(`application_method.${subKey}`, subValue)
}
} else {
form.setValue(key, value)
}
}
}, [watchTemplateId])
const watchValueType = useWatch({
control: form.control,
name: "application_method.type",
})
const isFixedValueType = watchValueType === "fixed"
const watchAllocation = useWatch({
control: form.control,
name: "application_method.allocation",
})
const isAllocationEach = watchAllocation === "each"
const watchType = useWatch({
control: form.control,
name: "type",
})
const isTypeStandard = watchType === "standard"
useEffect(() => {
if (isTypeStandard) {
form.setValue("application_method.buy_rules", undefined)
} else {
form.setValue(
"application_method.buy_rules",
generateRuleAttributes(buyRules)
)
}
}, [isTypeStandard])
const detailsProgress = useMemo(() => {
if (detailsValidated) {
return "completed"
}
return "not-started"
}, [detailsValidated])
return (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-scroll"
onSubmit={handleSubmit}
>
<ProgressTabs
value={tab}
onValueChange={(tab) => handleTabChange(tab as Tab)}
>
<RouteFocusModal.Header>
<div className="flex w-full items-center justify-between gap-x-4">
<div className="-my-2 w-full max-w-[400px] border-l">
<ProgressTabs.List className="grid w-full grid-cols-3">
<ProgressTabs.Trigger
className="w-full"
value={Tab.TYPE}
status={detailsProgress}
>
{t("fields.type")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
className="w-full"
value={Tab.PROMOTION}
>
{t("fields.details")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger className="w-full" value={Tab.CAMPAIGN}>
{t("promotions.fields.campaign")}
</ProgressTabs.Trigger>
</ProgressTabs.List>
</div>
<div className="flex items-center gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
{tab === Tab.CAMPAIGN ? (
<Button
key="save-btn"
type="submit"
size="small"
isLoading={false}
>
{t("actions.save")}
</Button>
) : (
<Button
key="continue-btn"
type="button"
onClick={handleContinue}
size="small"
>
{t("actions.continue")}
</Button>
)}
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="w-[800px] mx-auto my-20">
<ProgressTabs.Content value={Tab.TYPE}>
<Form.Field
control={form.control}
name="template_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("promotions.fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
key={"template_id"}
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
{templates.map((template) => {
return (
<RadioGroup.ChoiceBox
key={template.id}
value={template.id}
label={template.title}
description={template.description}
className={clx("", {
"border-2 border-ui-border-interactive":
template.id === field.value,
})}
/>
)
})}
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</ProgressTabs.Content>
<ProgressTabs.Content
value={Tab.PROMOTION}
className="flex flex-col gap-10 flex-1"
>
{form.formState.errors.root && (
<Alert
variant="error"
dismissible={false}
className="text-balance"
>
{form.formState.errors.root.message}
</Alert>
)}
<Form.Field
control={form.control}
name="is_automatic"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>Method</Form.Label>
<Form.Control>
<RadioGroup
className="flex gap-y-3"
{...field}
value={field.value}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"false"}
label={t("promotions.form.method.code.title")}
description={t(
"promotions.form.method.code.description"
)}
className={clx("basis-1/2", {
"border-2 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-2 border-ui-border-interactive":
"true" === field.value,
})}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex gap-y-4">
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.code.title")}
</Form.Label>
<Form.Control>
<Input {...field} placeholder="SUMMER15" />
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.code.description"
components={[<br key="break" />]}
/>
</Text>
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="application_method.type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.value_type")}
</Form.Label>
<Form.Control>
<RadioGroup
className="flex gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"fixed"}
label={t("promotions.form.value_type.fixed.title")}
description={t(
"promotions.form.value_type.fixed.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"fixed" === field.value,
})}
/>
<RadioGroup.ChoiceBox
value={"percentage"}
label={t(
"promotions.form.value_type.percentage.title"
)}
description={t(
"promotions.form.value_type.percentage.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"percentage" === field.value,
})}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex gap-y-4">
<Form.Field
control={form.control}
name="application_method.value"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{isFixedValueType
? t("fields.amount")
: t("fields.percentage")}
</Form.Label>
<Form.Control>
{isFixedValueType ? (
<CurrencyInput
min={0}
onValueChange={(value) => {
onChange(value ? parseInt(value) : "")
}}
code={"USD"}
symbol={getCurrencySymbol("USD")}
{...field}
value={value}
/>
) : (
<PercentageInput
key="amount"
className="text-right"
min={0}
max={100}
{...field}
value={value}
onChange={(e) => {
onChange(
e.target.value === ""
? null
: parseInt(e.target.value)
)
}}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</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-2 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-2 border-ui-border-interactive":
"buyget" === field.value,
})}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{isTypeStandard && (
<Form.Field
control={form.control}
name="application_method.allocation"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.fields.allocation")}
</Form.Label>
<Form.Control>
<RadioGroup
className="flex gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"each"}
label={t("promotions.form.allocation.each.title")}
description={t(
"promotions.form.allocation.each.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"each" === field.value,
})}
/>
<RadioGroup.ChoiceBox
value={"across"}
label={t(
"promotions.form.allocation.across.title"
)}
description={t(
"promotions.form.allocation.across.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"across" === field.value,
})}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
{isTypeStandard && isAllocationEach && (
<div className="flex gap-y-4">
<Form.Field
control={form.control}
name="application_method.max_quantity"
render={({ field }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.max_quantity.title")}
</Form.Label>
<Form.Control>
<Input
{...form.register(
"application_method.max_quantity",
{
valueAsNumber: true,
}
)}
type="number"
min={1}
placeholder="3"
/>
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.max_quantity.description"
components={[<br key="break" />]}
/>
</Text>
</Form.Item>
)
}}
/>
</div>
)}
<RulesFormField
form={form}
ruleType={"rules"}
attributes={ruleAttributes}
operators={operators}
fields={ruleFields}
appendRule={appendRule}
removeRule={removeRule}
updateRule={updateRule}
/>
<RulesFormField
form={form}
ruleType={"target-rules"}
attributes={targetRuleAttributes}
operators={operators}
fields={targetRuleFields}
appendRule={appendTargetRule}
removeRule={removeTargetRule}
updateRule={updateTargetRule}
scope="application_method.target_rules"
/>
{!isTypeStandard && (
<RulesFormField
form={form}
ruleType={"buy-rules"}
attributes={buyRuleAttributes}
operators={operators}
fields={buyRuleFields}
appendRule={appendBuyRule}
removeRule={removeBuyRule}
updateRule={updateBuyRule}
scope="application_method.buy_rules"
/>
)}
</ProgressTabs.Content>
<ProgressTabs.Content
value={Tab.CAMPAIGN}
className="flex flex-col items-center"
>
<AddCampaignPromotionFields form={form} campaigns={campaigns} />
</ProgressTabs.Content>
</RouteFocusModal.Body>
</ProgressTabs>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,36 @@
import { z } from "zod"
const RuleSchema = z.array(
z.object({
id: z.string().optional(),
attribute: z.string().min(1, { message: "Required field" }),
operator: z.string().min(1, { message: "Required field" }),
values: z.union([
z.number().min(1, { message: "Required field" }),
z.string().min(1, { message: "Required field" }),
z.array(z.string()).min(1, { message: "Required field" }),
]),
required: z.boolean().optional(),
disguised: z.boolean().optional(),
field_type: z.string().optional(),
})
)
export const CreatePromotionSchema = z.object({
template_id: z.string().optional(),
campaign_id: z.string().optional(),
existing: z.string().toLowerCase().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"]),
}),
})

View File

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

View File

@@ -0,0 +1,62 @@
export const templates = [
{
id: "amount_off_products",
type: "standard",
title: "Amount off products",
description: "Discount specific products or collection of products",
defaults: {
is_automatic: "false",
type: "standard",
application_method: {
allocation: "each",
target_type: "items",
type: "fixed",
},
},
},
{
id: "amount_off_order",
type: "standard",
title: "Amount off order",
description: "Discounts the total order amount",
defaults: {
is_automatic: "false",
type: "standard",
application_method: {
allocation: "across",
target_type: "order",
type: "fixed",
},
},
},
{
id: "percentage_off_product",
type: "standard",
title: "Percentage off product",
description: "Discounts a percentage off selected products",
defaults: {
is_automatic: "false",
type: "standard",
application_method: {
allocation: "each",
target_type: "items",
type: "percentage",
},
},
},
{
id: "percentage_off_order",
type: "standard",
title: "Percentage off order",
description: "Discounts a percentage of the total order amount",
defaults: {
is_automatic: "false",
type: "standard",
application_method: {
allocation: "across",
target_type: "items",
type: "percentage",
},
},
},
]

View File

@@ -0,0 +1 @@
export { PromotionCreate as Component } from "./promotion-create"

View File

@@ -0,0 +1,46 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { useCampaigns } from "../../../hooks/api/campaigns"
import {
usePromotionRuleAttributes,
usePromotionRuleOperators,
usePromotionRules,
} from "../../../hooks/api/promotions"
import { CreatePromotionForm } from "./components/create-promotion-form/create-promotion-form"
export const PromotionCreate = () => {
const { attributes: ruleAttributes } = usePromotionRuleAttributes("rules")
const { attributes: targetRuleAttributes } =
usePromotionRuleAttributes("target-rules")
const { attributes: buyRuleAttributes } =
usePromotionRuleAttributes("buy-rules")
const { rules } = usePromotionRules(null, "rules")
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 &&
buyRuleAttributes && (
<CreatePromotionForm
ruleAttributes={ruleAttributes}
targetRuleAttributes={targetRuleAttributes}
buyRuleAttributes={buyRuleAttributes}
operators={operators}
rules={rules}
targetRules={targetRules}
buyRules={buyRules}
campaigns={campaigns}
/>
)}
</RouteFocusModal>
)
}

View File

@@ -1,6 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PromotionDTO } from "@medusajs/types"
import { Button, CurrencyInput, Input, RadioGroup, Text } from "@medusajs/ui"
import {
Button,
clx,
CurrencyInput,
Input,
RadioGroup,
Text,
} from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import * as zod from "zod"
@@ -53,7 +60,6 @@ export const EditPromotionDetailsForm = ({
const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
const handleSubmit = form.handleSubmit(async (data) => {
console.log("data ------ ", data)
await mutateAsync(
{
is_automatic: data.is_automatic === "true",
@@ -92,6 +98,10 @@ export const EditPromotionDetailsForm = ({
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"false" === field.value,
})}
value={"false"}
label={t("promotions.form.method.code.title")}
description={t(
@@ -99,6 +109,10 @@ export const EditPromotionDetailsForm = ({
)}
/>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"true" === field.value,
})}
value={"true"}
label={t("promotions.form.method.automatic.title")}
description={t(
@@ -157,6 +171,10 @@ export const EditPromotionDetailsForm = ({
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"fixed" === field.value,
})}
value={"fixed"}
label={t("promotions.form.value_type.fixed.title")}
description={t(
@@ -165,6 +183,10 @@ export const EditPromotionDetailsForm = ({
/>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"percentage" === field.value,
})}
value={"percentage"}
label={t(
"promotions.form.value_type.percentage.title"
@@ -237,6 +259,10 @@ export const EditPromotionDetailsForm = ({
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"each" === field.value,
})}
value={"each"}
label={t("promotions.form.allocation.each.title")}
description={t(
@@ -245,6 +271,10 @@ export const EditPromotionDetailsForm = ({
/>
<RadioGroup.ChoiceBox
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"across" === field.value,
})}
value={"across"}
label={t("promotions.form.allocation.across.title")}
description={t(