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:
@@ -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": {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-rules-wrapper"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum View {
|
||||
TEMPLATE = "template",
|
||||
PROMOTION = "promotion",
|
||||
CAMPAIGN = "campaign",
|
||||
}
|
||||
|
||||
export enum Tab {
|
||||
TYPE = "type",
|
||||
PROMOTION = "promotion",
|
||||
CAMPAIGN = "campaign",
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"]),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./create-promotion-form"
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
export { PromotionCreate as Component } from "./promotion-create"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user