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:
co-authored by
Frane Polić
parent
b61dcb84c9
commit
93ef94cad3
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
"@medusajs/types": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(medusa,types): create promotion flows
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { IPromotionModuleService } from "@medusajs/types"
|
|
||||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||||
|
import { IPromotionModuleService } from "@medusajs/types"
|
||||||
import { PromotionType } from "@medusajs/utils"
|
import { PromotionType } from "@medusajs/utils"
|
||||||
import { createAdminUser } from "../../../../helpers/create-admin-user"
|
|
||||||
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
||||||
|
import { createAdminUser } from "../../../../helpers/create-admin-user"
|
||||||
|
|
||||||
jest.setTimeout(50000)
|
jest.setTimeout(50000)
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ medusaIntegrationTestRunner({
|
|||||||
target_type: "items",
|
target_type: "items",
|
||||||
type: "fixed",
|
type: "fixed",
|
||||||
allocation: "each",
|
allocation: "each",
|
||||||
value: "100",
|
value: 100,
|
||||||
max_quantity: 100,
|
max_quantity: 100,
|
||||||
target_rules: [
|
target_rules: [
|
||||||
{
|
{
|
||||||
@@ -144,7 +144,7 @@ medusaIntegrationTestRunner({
|
|||||||
target_type: "items",
|
target_type: "items",
|
||||||
type: "fixed",
|
type: "fixed",
|
||||||
allocation: "each",
|
allocation: "each",
|
||||||
value: "100",
|
value: 100,
|
||||||
max_quantity: 100,
|
max_quantity: 100,
|
||||||
target_rules: [
|
target_rules: [
|
||||||
{
|
{
|
||||||
@@ -184,7 +184,7 @@ medusaIntegrationTestRunner({
|
|||||||
target_type: "items",
|
target_type: "items",
|
||||||
type: "fixed",
|
type: "fixed",
|
||||||
allocation: "each",
|
allocation: "each",
|
||||||
value: "100",
|
value: 100,
|
||||||
max_quantity: 100,
|
max_quantity: 100,
|
||||||
buy_rules: [
|
buy_rules: [
|
||||||
{
|
{
|
||||||
@@ -231,7 +231,7 @@ medusaIntegrationTestRunner({
|
|||||||
target_type: "items",
|
target_type: "items",
|
||||||
type: "fixed",
|
type: "fixed",
|
||||||
allocation: "each",
|
allocation: "each",
|
||||||
value: "100",
|
value: 100,
|
||||||
max_quantity: 100,
|
max_quantity: 100,
|
||||||
apply_to_quantity: 1,
|
apply_to_quantity: 1,
|
||||||
buy_rules_min_quantity: 1,
|
buy_rules_min_quantity: 1,
|
||||||
|
|||||||
@@ -834,8 +834,8 @@ medusaIntegrationTestRunner({
|
|||||||
expect(response.data.values.length).toEqual(2)
|
expect(response.data.values.length).toEqual(2)
|
||||||
expect(response.data.values).toEqual(
|
expect(response.data.values).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
{ label: "Afghan Afghani", value: "afn" },
|
{ label: "afn", value: "afn" },
|
||||||
{ label: "Albanian Lek", value: "all" },
|
{ label: "all", value: "all" },
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -824,6 +824,8 @@
|
|||||||
"value": "Value",
|
"value": "Value",
|
||||||
"campaign": "Campaign",
|
"campaign": "Campaign",
|
||||||
"allocation": "Allocation",
|
"allocation": "Allocation",
|
||||||
|
"addCondition": "Add condition",
|
||||||
|
"clearAll": "Clear all",
|
||||||
"conditions": {
|
"conditions": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Who can use this code?",
|
"title": "Who can use this code?",
|
||||||
@@ -839,6 +841,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"requiredField": "Required field"
|
||||||
|
},
|
||||||
|
"create": {},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit Promotion Details",
|
"title": "Edit Promotion Details",
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -855,6 +861,9 @@
|
|||||||
"title": "Add Promotion To Campaign"
|
"title": "Add Promotion To Campaign"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
|
"required": "Required",
|
||||||
|
"and": "AND",
|
||||||
|
"selectAttribute": "Select Attribute",
|
||||||
"campaign": {
|
"campaign": {
|
||||||
"existing": {
|
"existing": {
|
||||||
"title": "Existing Campaign",
|
"title": "Existing Campaign",
|
||||||
@@ -875,17 +884,31 @@
|
|||||||
},
|
},
|
||||||
"automatic": {
|
"automatic": {
|
||||||
"title": "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": {
|
"allocation": {
|
||||||
"each": {
|
"each": {
|
||||||
"title": "Each",
|
"title": "Each",
|
||||||
"description": "Applies value on each product"
|
"description": "Applies value on each item"
|
||||||
},
|
},
|
||||||
"across": {
|
"across": {
|
||||||
"title": "Across",
|
"title": "Across",
|
||||||
"description": "Applies value proportionally across products"
|
"description": "Applies value across items"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"code": {
|
"code": {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
const PROMOTIONS_QUERY_KEY = "promotions" as const
|
const PROMOTIONS_QUERY_KEY = "promotions" as const
|
||||||
export const promotionsQueryKeys = {
|
export const promotionsQueryKeys = {
|
||||||
...queryKeysFactory(PROMOTIONS_QUERY_KEY),
|
...queryKeysFactory(PROMOTIONS_QUERY_KEY),
|
||||||
listRules: (id: string, ruleType: string) => [
|
listRules: (id: string | null, ruleType: string) => [
|
||||||
PROMOTIONS_QUERY_KEY,
|
PROMOTIONS_QUERY_KEY,
|
||||||
id,
|
id,
|
||||||
ruleType,
|
ruleType,
|
||||||
@@ -60,7 +60,7 @@ export const usePromotion = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const usePromotionRules = (
|
export const usePromotionRules = (
|
||||||
id: string,
|
id: string | null,
|
||||||
ruleType: string,
|
ruleType: string,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<
|
UseQueryOptions<
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ export const v2Routes: RouteObject[] = [
|
|||||||
path: "",
|
path: "",
|
||||||
lazy: () => import("../../v2-routes/promotions/promotion-list"),
|
lazy: () => import("../../v2-routes/promotions/promotion-list"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "create",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../v2-routes/promotions/promotion-create"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ":id",
|
path: ":id",
|
||||||
lazy: () =>
|
lazy: () =>
|
||||||
@@ -196,7 +201,8 @@ export const v2Routes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":ruleType/edit",
|
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 { UserRoles } from "@medusajs/medusa"
|
||||||
import { Alert, Button, Heading, Input, Text, Tooltip } from "@medusajs/ui"
|
import { Alert, Button, Heading, Input, Text, Tooltip } from "@medusajs/ui"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import i18n from "i18next"
|
||||||
import { useAdminAcceptInvite } from "medusa-react"
|
import { useAdminAcceptInvite } from "medusa-react"
|
||||||
import { Trans, useTranslation } from "react-i18next"
|
import { Trans, useTranslation } from "react-i18next"
|
||||||
import { Link, useSearchParams } from "react-router-dom"
|
import { Link, useSearchParams } from "react-router-dom"
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
import i18n from "i18next"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
|
|||||||
+423
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+137
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-rules-wrapper"
|
||||||
+8
-24
@@ -2,13 +2,9 @@ import { PromotionRuleDTO } from "@medusajs/types"
|
|||||||
import { Heading } from "@medusajs/ui"
|
import { Heading } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { RouteDrawer } from "../../../components/route-modal"
|
import { RouteDrawer } from "../../../../components/route-modal"
|
||||||
import {
|
import { usePromotion } from "../../../../hooks/api/promotions"
|
||||||
usePromotion,
|
import { EditRulesWrapper } from "./components/edit-rules-wrapper"
|
||||||
usePromotionRuleAttributes,
|
|
||||||
usePromotionRuleOperators,
|
|
||||||
} from "../../../hooks/api/promotions"
|
|
||||||
import { EditRulesForm } from "./components/edit-rules-form"
|
|
||||||
|
|
||||||
export enum RuleType {
|
export enum RuleType {
|
||||||
RULES = "rules",
|
RULES = "rules",
|
||||||
@@ -33,17 +29,7 @@ export const EditRules = () => {
|
|||||||
const ruleType = params.ruleType as RuleTypeValues
|
const ruleType = params.ruleType as RuleTypeValues
|
||||||
const id = params.id as string
|
const id = params.id as string
|
||||||
const rules: PromotionRuleDTO[] = []
|
const rules: PromotionRuleDTO[] = []
|
||||||
const { promotion, isLoading, isError, error } = usePromotion(id)
|
const { promotion, isPending: isLoading, isError, error } = usePromotion(id)
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
isError: isAttributesError,
|
|
||||||
error: attributesError,
|
|
||||||
} = usePromotionRuleAttributes(ruleType!)
|
|
||||||
const {
|
|
||||||
operators,
|
|
||||||
isError: isOperatorsError,
|
|
||||||
error: operatorsError,
|
|
||||||
} = usePromotionRuleOperators()
|
|
||||||
|
|
||||||
if (promotion) {
|
if (promotion) {
|
||||||
if (ruleType === RuleType.RULES) {
|
if (ruleType === RuleType.RULES) {
|
||||||
@@ -55,8 +41,8 @@ export const EditRules = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || isAttributesError || isOperatorsError) {
|
if (isError) {
|
||||||
throw error || attributesError || operatorsError
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -65,13 +51,11 @@ export const EditRules = () => {
|
|||||||
<Heading>{t(`promotions.edit.${ruleType}.title`)}</Heading>
|
<Heading>{t(`promotions.edit.${ruleType}.title`)}</Heading>
|
||||||
</RouteDrawer.Header>
|
</RouteDrawer.Header>
|
||||||
|
|
||||||
{!isLoading && promotion && attributes && operators && (
|
{!isLoading && promotion && (
|
||||||
<EditRulesForm
|
<EditRulesWrapper
|
||||||
promotion={promotion}
|
promotion={promotion}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
ruleType={ruleType}
|
ruleType={ruleType}
|
||||||
attributes={attributes}
|
|
||||||
operators={operators}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</RouteDrawer>
|
</RouteDrawer>
|
||||||
-442
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+93
-73
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { CampaignDTO, PromotionDTO } from "@medusajs/types"
|
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 { useForm, useWatch } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import * as zod from "zod"
|
import * as zod from "zod"
|
||||||
@@ -23,6 +23,97 @@ const EditPromotionSchema = zod.object({
|
|||||||
existing: zod.string().toLowerCase(),
|
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 = ({
|
export const AddCampaignPromotionForm = ({
|
||||||
promotion,
|
promotion,
|
||||||
campaigns,
|
campaigns,
|
||||||
@@ -58,78 +149,7 @@ export const AddCampaignPromotionForm = ({
|
|||||||
<RouteDrawer.Form form={form}>
|
<RouteDrawer.Form form={form}>
|
||||||
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||||
<RouteDrawer.Body>
|
<RouteDrawer.Body>
|
||||||
<div className="flex h-full flex-col gap-y-8">
|
<AddCampaignPromotionFields form={form} campaigns={campaigns} />
|
||||||
<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>
|
|
||||||
</RouteDrawer.Body>
|
</RouteDrawer.Body>
|
||||||
|
|
||||||
<RouteDrawer.Footer>
|
<RouteDrawer.Footer>
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
export enum View {
|
||||||
|
TEMPLATE = "template",
|
||||||
|
PROMOTION = "promotion",
|
||||||
|
CAMPAIGN = "campaign",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Tab {
|
||||||
|
TYPE = "type",
|
||||||
|
PROMOTION = "promotion",
|
||||||
|
CAMPAIGN = "campaign",
|
||||||
|
}
|
||||||
+775
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+36
@@ -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"]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./create-promotion-form"
|
||||||
+62
@@ -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"
|
||||||
+46
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
-2
@@ -1,6 +1,13 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { PromotionDTO } from "@medusajs/types"
|
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 { useForm, useWatch } from "react-hook-form"
|
||||||
import { Trans, useTranslation } from "react-i18next"
|
import { Trans, useTranslation } from "react-i18next"
|
||||||
import * as zod from "zod"
|
import * as zod from "zod"
|
||||||
@@ -53,7 +60,6 @@ export const EditPromotionDetailsForm = ({
|
|||||||
const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
|
const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit(async (data) => {
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
console.log("data ------ ", data)
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
is_automatic: data.is_automatic === "true",
|
is_automatic: data.is_automatic === "true",
|
||||||
@@ -92,6 +98,10 @@ export const EditPromotionDetailsForm = ({
|
|||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
>
|
>
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
|
className={clx("basis-1/2", {
|
||||||
|
"border-2 border-ui-border-interactive":
|
||||||
|
"false" === field.value,
|
||||||
|
})}
|
||||||
value={"false"}
|
value={"false"}
|
||||||
label={t("promotions.form.method.code.title")}
|
label={t("promotions.form.method.code.title")}
|
||||||
description={t(
|
description={t(
|
||||||
@@ -99,6 +109,10 @@ export const EditPromotionDetailsForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
|
className={clx("basis-1/2", {
|
||||||
|
"border-2 border-ui-border-interactive":
|
||||||
|
"true" === field.value,
|
||||||
|
})}
|
||||||
value={"true"}
|
value={"true"}
|
||||||
label={t("promotions.form.method.automatic.title")}
|
label={t("promotions.form.method.automatic.title")}
|
||||||
description={t(
|
description={t(
|
||||||
@@ -157,6 +171,10 @@ export const EditPromotionDetailsForm = ({
|
|||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
>
|
>
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
|
className={clx("basis-1/2", {
|
||||||
|
"border-2 border-ui-border-interactive":
|
||||||
|
"fixed" === field.value,
|
||||||
|
})}
|
||||||
value={"fixed"}
|
value={"fixed"}
|
||||||
label={t("promotions.form.value_type.fixed.title")}
|
label={t("promotions.form.value_type.fixed.title")}
|
||||||
description={t(
|
description={t(
|
||||||
@@ -165,6 +183,10 @@ export const EditPromotionDetailsForm = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
|
className={clx("basis-1/2", {
|
||||||
|
"border-2 border-ui-border-interactive":
|
||||||
|
"percentage" === field.value,
|
||||||
|
})}
|
||||||
value={"percentage"}
|
value={"percentage"}
|
||||||
label={t(
|
label={t(
|
||||||
"promotions.form.value_type.percentage.title"
|
"promotions.form.value_type.percentage.title"
|
||||||
@@ -237,6 +259,10 @@ export const EditPromotionDetailsForm = ({
|
|||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
>
|
>
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
|
className={clx("basis-1/2", {
|
||||||
|
"border-2 border-ui-border-interactive":
|
||||||
|
"each" === field.value,
|
||||||
|
})}
|
||||||
value={"each"}
|
value={"each"}
|
||||||
label={t("promotions.form.allocation.each.title")}
|
label={t("promotions.form.allocation.each.title")}
|
||||||
description={t(
|
description={t(
|
||||||
@@ -245,6 +271,10 @@ export const EditPromotionDetailsForm = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
|
className={clx("basis-1/2", {
|
||||||
|
"border-2 border-ui-border-interactive":
|
||||||
|
"across" === field.value,
|
||||||
|
})}
|
||||||
value={"across"}
|
value={"across"}
|
||||||
label={t("promotions.form.allocation.across.title")}
|
label={t("promotions.form.allocation.across.title")}
|
||||||
description={t(
|
description={t(
|
||||||
|
|||||||
@@ -39,22 +39,24 @@ export const GET = async (
|
|||||||
if (dasherizedRuleType === RuleType.RULES) {
|
if (dasherizedRuleType === RuleType.RULES) {
|
||||||
promotionRules.push(...(promotion?.rules || []))
|
promotionRules.push(...(promotion?.rules || []))
|
||||||
} else if (dasherizedRuleType === RuleType.TARGET_RULES) {
|
} else if (dasherizedRuleType === RuleType.TARGET_RULES) {
|
||||||
promotionRules.push(...(promotion.application_method?.target_rules || []))
|
promotionRules.push(...(promotion?.application_method?.target_rules || []))
|
||||||
} else if (dasherizedRuleType === RuleType.BUY_RULES) {
|
} else if (dasherizedRuleType === RuleType.BUY_RULES) {
|
||||||
promotionRules.push(...(promotion.application_method?.buy_rules || []))
|
promotionRules.push(...(promotion?.application_method?.buy_rules || []))
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformedRules: AdminGetPromotionRulesRes = []
|
const transformedRules: AdminGetPromotionRulesRes = []
|
||||||
const disguisedRules = ruleAttributes.filter((attr) => !!attr.disguised)
|
const disguisedRules = ruleAttributes.filter((attr) => !!attr.disguised)
|
||||||
|
const requiredRules = ruleAttributes.filter((attr) => !!attr.required)
|
||||||
|
|
||||||
for (const disguisedRule of disguisedRules) {
|
for (const disguisedRule of disguisedRules) {
|
||||||
const value = promotion.application_method?.[disguisedRule.id]
|
const value = promotion?.application_method?.[disguisedRule.id]
|
||||||
const values = [{ label: value, value }]
|
const values = value ? [{ label: value, value }] : []
|
||||||
|
|
||||||
transformedRules.push({
|
transformedRules.push({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
attribute: disguisedRule.id,
|
attribute: disguisedRule.id,
|
||||||
attribute_label: disguisedRule.label,
|
attribute_label: disguisedRule.label,
|
||||||
|
field_type: disguisedRule.field_type,
|
||||||
operator: RuleOperator.EQ,
|
operator: RuleOperator.EQ,
|
||||||
operator_label: operatorsMap[RuleOperator.EQ].label,
|
operator_label: operatorsMap[RuleOperator.EQ].label,
|
||||||
values,
|
values,
|
||||||
@@ -102,6 +104,7 @@ export const GET = async (
|
|||||||
transformedRules.push({
|
transformedRules.push({
|
||||||
...promotionRule,
|
...promotionRule,
|
||||||
attribute_label: currentRuleAttribute.label,
|
attribute_label: currentRuleAttribute.label,
|
||||||
|
field_type: currentRuleAttribute.field_type,
|
||||||
operator_label:
|
operator_label:
|
||||||
operatorsMap[promotionRule.operator]?.label || promotionRule.operator,
|
operatorsMap[promotionRule.operator]?.label || promotionRule.operator,
|
||||||
disguised: false,
|
disguised: false,
|
||||||
@@ -109,6 +112,24 @@ export const GET = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiredRules.length && !transformedRules.length) {
|
||||||
|
for (const requiredRule of requiredRules) {
|
||||||
|
transformedRules.push({
|
||||||
|
id: undefined,
|
||||||
|
attribute: requiredRule.value,
|
||||||
|
attribute_label: requiredRule.label,
|
||||||
|
operator: RuleOperator.EQ,
|
||||||
|
field_type: requiredRule.field_type,
|
||||||
|
operator_label: operatorsMap[RuleOperator.EQ].label,
|
||||||
|
values: [],
|
||||||
|
disguised: true,
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
rules: transformedRules,
|
rules: transformedRules,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const ruleQueryConfigurations = {
|
|||||||
},
|
},
|
||||||
currency: {
|
currency: {
|
||||||
entryPoint: "currency",
|
entryPoint: "currency",
|
||||||
labelAttr: "name",
|
labelAttr: "code",
|
||||||
valueAttr: "code",
|
valueAttr: "code",
|
||||||
},
|
},
|
||||||
customer_group: {
|
customer_group: {
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import { z } from "zod"
|
|
||||||
import {
|
|
||||||
createFindParams,
|
|
||||||
createOperatorMap,
|
|
||||||
createSelectParams,
|
|
||||||
} from "../../utils/validators"
|
|
||||||
import {
|
import {
|
||||||
ApplicationMethodAllocation,
|
ApplicationMethodAllocation,
|
||||||
ApplicationMethodTargetType,
|
ApplicationMethodTargetType,
|
||||||
@@ -12,6 +6,12 @@ import {
|
|||||||
PromotionRuleOperator,
|
PromotionRuleOperator,
|
||||||
PromotionType,
|
PromotionType,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
|
import { z } from "zod"
|
||||||
|
import {
|
||||||
|
createFindParams,
|
||||||
|
createOperatorMap,
|
||||||
|
createSelectParams,
|
||||||
|
} from "../../utils/validators"
|
||||||
|
|
||||||
export type AdminGetPromotionParamsType = z.infer<
|
export type AdminGetPromotionParamsType = z.infer<
|
||||||
typeof AdminGetPromotionParams
|
typeof AdminGetPromotionParams
|
||||||
@@ -89,7 +89,7 @@ export type AdminCreateApplicationMethodType = z.infer<
|
|||||||
export const AdminCreateApplicationMethod = z
|
export const AdminCreateApplicationMethod = z
|
||||||
.object({
|
.object({
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
value: z.string(),
|
value: z.number(),
|
||||||
max_quantity: z.number().optional(),
|
max_quantity: z.number().optional(),
|
||||||
type: z.nativeEnum(ApplicationMethodType),
|
type: z.nativeEnum(ApplicationMethodType),
|
||||||
target_type: z.nativeEnum(ApplicationMethodTargetType),
|
target_type: z.nativeEnum(ApplicationMethodTargetType),
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { PaginatedResponse } from "../../common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface CampaignResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
currency: string
|
||||||
|
campaign_identifier: string
|
||||||
|
starts_at: string
|
||||||
|
ends_at: string
|
||||||
|
budget: {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
limit: number | null
|
||||||
|
used: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface AdminCampaignListResponse extends PaginatedResponse {
|
||||||
|
campaigns: CampaignResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface AdminCampaignResponse {
|
||||||
|
campaign: CampaignResponse
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./campaign"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./admin"
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
export * from "./api-key"
|
export * from "./api-key"
|
||||||
|
export * from "./campaign"
|
||||||
export * from "./customer"
|
export * from "./customer"
|
||||||
export * from "./fulfillment"
|
export * from "./fulfillment"
|
||||||
export * from "./inventory"
|
export * from "./inventory"
|
||||||
export * from "./order"
|
export * from "./order"
|
||||||
export * from "./pricing"
|
export * from "./pricing"
|
||||||
export * from "./product-category"
|
export * from "./product-category"
|
||||||
|
export * from "./promotion"
|
||||||
export * from "./sales-channel"
|
export * from "./sales-channel"
|
||||||
export * from "./stock-locations"
|
export * from "./stock-locations"
|
||||||
export * from "./tax"
|
export * from "./tax"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./promotion-rule"
|
||||||
|
export * from "./rule-attribute-options"
|
||||||
|
export * from "./rule-operator-options"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface PromotionRuleResponse {
|
||||||
|
id: string
|
||||||
|
attribute: string
|
||||||
|
attribute_label: string
|
||||||
|
field_type: string
|
||||||
|
operator: string
|
||||||
|
operator_label: string
|
||||||
|
values: { value: string }[]
|
||||||
|
disguised: boolean
|
||||||
|
required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface AdminPromotionRuleListResponse {
|
||||||
|
attributes: PromotionRuleResponse[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface RuleAttributeOptionsResponse {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
field_type: string
|
||||||
|
required: boolean
|
||||||
|
disguised: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface AdminRuleAttributeOptionsListResponse {
|
||||||
|
attributes: RuleAttributeOptionsResponse[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface RuleOperatorOptionsResponse {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface AdminRuleOperatorOptionsListResponse {
|
||||||
|
operators: RuleOperatorOptionsResponse[]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./admin"
|
||||||
@@ -2,9 +2,10 @@ export type AdminGetPromotionRulesRes = {
|
|||||||
id?: string
|
id?: string
|
||||||
attribute: string
|
attribute: string
|
||||||
attribute_label: string
|
attribute_label: string
|
||||||
|
field_type?: string
|
||||||
operator: string
|
operator: string
|
||||||
operator_label: string
|
operator_label: string
|
||||||
values: { label?: string; value: string }[]
|
values: { label?: string; value?: string }[]
|
||||||
disguised?: boolean
|
disguised?: boolean
|
||||||
required?: boolean
|
required?: boolean
|
||||||
}[]
|
}[]
|
||||||
|
|||||||
Reference in New Issue
Block a user