diff --git a/src/routes/commission/commission.tsx b/src/routes/commission/commission.tsx index 23032cd..ad455dd 100644 --- a/src/routes/commission/commission.tsx +++ b/src/routes/commission/commission.tsx @@ -116,14 +116,12 @@ export const Commission = () => { Create Rule - { setCreateRuleOpen(false); refetch(); }} /> - diff --git a/src/routes/commission/components/create-commission-rule-form.tsx b/src/routes/commission/components/create-commission-rule-form.tsx index a977af9..55f1cc4 100644 --- a/src/routes/commission/components/create-commission-rule-form.tsx +++ b/src/routes/commission/components/create-commission-rule-form.tsx @@ -1,10 +1,27 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo } from "react"; -import { Button, Input, Label, Select, Switch, toast } from "@medusajs/ui"; +import { + Button, + Drawer, + Input, + Label, + Select, + Switch, + toast, +} from "@medusajs/ui"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; + +import { Form } from "@components/common/form"; +import { SwitchBox } from "@components/common/switch-box"; +import { KeyboundForm } from "@components/utilities/keybound-form"; import { useProductCategories, useProductTypes, useStores } from "@hooks/api"; import { useCreateCommisionRule } from "@hooks/api/commission"; import { useSellers } from "@hooks/api/sellers"; +import { useDocumentDirection } from "@hooks/use-document-direction"; type Props = { onSuccess?: () => void; @@ -12,21 +29,152 @@ type Props = { type Price = { amount: number; currency_code: string }; +const ReferenceType = { + SELLER: "seller", + PRODUCT_TYPE: "product_type", + PRODUCT_CATEGORY: "product_category", + SELLER_PRODUCT_TYPE: "seller+product_type", + SELLER_PRODUCT_CATEGORY: "seller+product_category", +} as const; + +const RateType = { + FLAT: "flat", + PERCENTAGE: "percentage", +} as const; + +type ReferenceTypeValue = (typeof ReferenceType)[keyof typeof ReferenceType]; + +const referenceRequirements = { + needsSeller: (reference: ReferenceTypeValue) => + ( + [ + ReferenceType.SELLER, + ReferenceType.SELLER_PRODUCT_TYPE, + ReferenceType.SELLER_PRODUCT_CATEGORY, + ] as ReferenceTypeValue[] + ).includes(reference), + needsType: (reference: ReferenceTypeValue) => + ( + [ + ReferenceType.PRODUCT_TYPE, + ReferenceType.SELLER_PRODUCT_TYPE, + ] as ReferenceTypeValue[] + ).includes(reference), + needsCategory: (reference: ReferenceTypeValue) => + ( + [ + ReferenceType.PRODUCT_CATEGORY, + ReferenceType.SELLER_PRODUCT_CATEGORY, + ] as ReferenceTypeValue[] + ).includes(reference), +}; + +const validateReferenceFields = ( + data: { + reference: ReferenceTypeValue; + seller?: string; + type?: string; + category?: string; + }, + ctx: z.RefinementCtx, +) => { + if (referenceRequirements.needsSeller(data.reference) && !data.seller) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Seller is required for this rule type", + path: ["seller"], + }); + } + + if (referenceRequirements.needsType(data.reference) && !data.type) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Product type is required for this rule type", + path: ["type"], + }); + } + + if (referenceRequirements.needsCategory(data.reference) && !data.category) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Product category is required for this rule type", + path: ["category"], + }); + } +}; + +const validateRateFields = ( + data: { + rateType: string; + ratePercentValue?: number; + rateFlatValue?: Record; + }, + ctx: z.RefinementCtx, +) => { + if (data.rateType === RateType.PERCENTAGE && !data.ratePercentValue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Percentage value is required", + path: ["ratePercentValue"], + }); + } + + if (data.rateType === RateType.FLAT) { + if (!data.rateFlatValue || Object.keys(data.rateFlatValue).length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one flat fee value is required", + path: ["rateFlatValue"], + }); + + return; + } + + const hasValidValue = Object.values(data.rateFlatValue).some( + (value) => value > 0, + ); + + if (!hasValidValue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one flat fee value must be greater than 0", + path: ["rateFlatValue"], + }); + } + } +}; + +const CreateCommissionRuleSchema = z + .object({ + name: z.string().min(1, "Name is required"), + reference: z.enum([ + ReferenceType.SELLER, + ReferenceType.PRODUCT_TYPE, + ReferenceType.PRODUCT_CATEGORY, + ReferenceType.SELLER_PRODUCT_TYPE, + ReferenceType.SELLER_PRODUCT_CATEGORY, + ]), + seller: z.string().optional(), + type: z.string().optional(), + category: z.string().optional(), + includeTax: z.boolean(), + rateType: z.enum([RateType.FLAT, RateType.PERCENTAGE]), + ratePercentValue: z.number().min(0).max(100).optional(), + rateFlatValue: z.record(z.string(), z.number().min(0)).optional(), + minCommissionEnabled: z.boolean(), + minCommission: z.record(z.string(), z.number().min(0)).optional(), + maxCommissionEnabled: z.boolean(), + maxCommission: z.record(z.string(), z.number().min(0)).optional(), + }) + .superRefine((data, ctx) => { + validateReferenceFields(data, ctx); + validateRateFields(data, ctx); + }); + +type CreateCommissionRuleFormData = z.infer; + const CreateCommissionRuleForm = ({ onSuccess }: Props) => { - const [name, setName] = useState(""); - const [reference, setReference] = useState("seller"); - const [rateType, setRateType] = useState("flat"); - const [ratePercentValue, setRatePercentValue] = useState(0); - const [rateFlatValue, setRateFlatValue] = useState([]); - const [includeTax, setIncludeTax] = useState(false); - const [currencies, setCurrencies] = useState([]); - const [minCommissionEnabled, setMinCommissionEnabled] = - useState(false); - const [maxCommissionEnabled, setMaxCommissionEnabled] = - useState(false); - const [minCommission, setMinCommission] = useState([]); - const [maxCommission, setMaxCommission] = useState([]); - const [loading, setLoading] = useState(false); + const direction = useDocumentDirection(); const { product_types } = useProductTypes({ fields: "id,value", @@ -37,368 +185,463 @@ const CreateCommissionRuleForm = ({ onSuccess }: Props) => { limit: 9999, }); const { sellers } = useSellers({ fields: "id,name", limit: 9999 }); + const { stores } = useStores(); + + const currencies = useMemo( + () => stores?.[0]?.supported_currencies.map((c) => c.currency_code) ?? [], + [stores], + ); + + const form = useForm({ + defaultValues: { + name: "", + reference: ReferenceType.SELLER, + seller: "", + type: "", + category: "", + includeTax: false, + rateType: RateType.FLAT, + ratePercentValue: 0, + rateFlatValue: {}, + minCommissionEnabled: false, + minCommission: {}, + maxCommissionEnabled: false, + maxCommission: {}, + }, + resolver: zodResolver(CreateCommissionRuleSchema), + }); - const [showSellers, setShowSellers] = useState(false); - const [showProductTypes, setShowProductTypes] = useState(false); - const [showProductCategories, setShowProductCategories] = useState(false); + const reference = useWatch({ control: form.control, name: "reference" }); + const rateType = useWatch({ control: form.control, name: "rateType" }); + const minCommissionEnabled = useWatch({ + control: form.control, + name: "minCommissionEnabled", + }); + const maxCommissionEnabled = useWatch({ + control: form.control, + name: "maxCommissionEnabled", + }); - const [seller, setSeller] = useState(""); - const [type, setType] = useState(""); - const [category, setCategory] = useState(""); + const showSellers = useMemo( + () => + reference === ReferenceType.SELLER || + reference === ReferenceType.SELLER_PRODUCT_TYPE || + reference === ReferenceType.SELLER_PRODUCT_CATEGORY, + [reference], + ); - const { mutateAsync: createCommissionRule } = useCreateCommisionRule({}); + const showProductTypes = useMemo( + () => + reference === ReferenceType.PRODUCT_TYPE || + reference === ReferenceType.SELLER_PRODUCT_TYPE, + [reference], + ); - const { stores } = useStores(); + const showProductCategories = useMemo( + () => + reference === ReferenceType.PRODUCT_CATEGORY || + reference === ReferenceType.SELLER_PRODUCT_CATEGORY, + [reference], + ); - useEffect(() => { - if (stores && stores[0]) { - setCurrencies(stores[0].supported_currencies.map((c) => c.currency_code)); + const { mutateAsync: createCommissionRule, isPending } = + useCreateCommisionRule({}); + + const handleSubmit = form.handleSubmit(async (data) => { + const referenceIdParts: string[] = []; + + if (showSellers && data.seller) { + referenceIdParts.push(data.seller); } - }, [stores]); - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - - try { - let reference_id = ""; - switch (reference) { - case "seller": - reference_id = seller; - break; - case "product_type": - reference_id = type; - break; - case "product_category": - reference_id = category; - break; - case "seller+product_type": - reference_id = `${seller}+${type}`; - break; - case "seller+product_category": - reference_id = `${seller}+${category}`; - break; - } - - const rule_payload = { - name, - reference, - reference_id, - is_active: true, - rate: { - type: rateType as "flat" | "percentage", - percentage_rate: - rateType === "percentage" ? ratePercentValue : undefined, - include_tax: includeTax, - price_set: rateType === "flat" ? rateFlatValue : undefined, - min_price_set: minCommissionEnabled ? minCommission : undefined, - max_price_set: maxCommissionEnabled ? maxCommission : undefined, - }, - }; - - await createCommissionRule(rule_payload); - setLoading(false); - toast.success("Created!"); - onSuccess?.(); - } catch (e: unknown) { - toast.error("Error!"); - console.error(e); - setLoading(false); + if (showProductTypes && data.type) { + referenceIdParts.push(data.type); } - }; - useEffect(() => { - if (reference === "seller") { - setShowSellers(true); - setShowProductCategories(false); - setShowProductTypes(false); + if (showProductCategories && data.category) { + referenceIdParts.push(data.category); } - if (reference === "product_category") { - setShowSellers(false); - setShowProductCategories(true); - setShowProductTypes(false); - } + const reference_id = referenceIdParts.join("+"); - if (reference === "product_type") { - setShowSellers(false); - setShowProductCategories(false); - setShowProductTypes(true); - } + const convertRecordToArray = ( + record: Record | undefined, + ): Price[] | undefined => { + if (!record) return undefined; - if (reference === "seller+product_type") { - setShowSellers(true); - setShowProductCategories(false); - setShowProductTypes(true); - } + return Object.entries(record).map(([currency_code, amount]) => ({ + currency_code, + amount, + })); + }; + + const rule_payload = { + name: data.name, + reference: data.reference, + reference_id, + is_active: true, + rate: { + type: data.rateType, + percentage_rate: + data.rateType === RateType.PERCENTAGE + ? data.ratePercentValue + : undefined, + include_tax: data.includeTax, + price_set: + data.rateType === RateType.FLAT + ? convertRecordToArray(data.rateFlatValue) + : undefined, + min_price_set: data.minCommissionEnabled + ? convertRecordToArray(data.minCommission) + : undefined, + max_price_set: data.maxCommissionEnabled + ? convertRecordToArray(data.maxCommission) + : undefined, + }, + }; - if (reference === "seller+product_category") { - setShowSellers(true); - setShowProductCategories(true); - setShowProductTypes(false); + await createCommissionRule(rule_payload, { + onSuccess: () => { + toast.success("Commission rule created successfully"); + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message || "Failed to create commission rule"); + }, + }); + }); + + useEffect(() => { + if (!showSellers) { + form.setValue("seller", ""); + } + if (!showProductTypes) { + form.setValue("type", ""); } - }, [reference]); + if (!showProductCategories) { + form.setValue("category", ""); + } + }, [reference, showSellers, showProductTypes, showProductCategories, form]); return ( -
-
- Rule Name - setName(e.target.value)} - /> -
-
- Rule Type - -
-
- Attribute - {showSellers && sellers && ( - - )} - {showProductCategories && product_categories && ( - - )} - {showProductTypes && product_types && ( - - )} -
-
-
- { - setIncludeTax(val); - }} + + + + ( + + Rule Name + + + + + + )} /> - -
-
-
- Fee type -
- - { - setRateType(val ? "percentage" : "flat"); - }} + + ( + + Rule Type + + + + + + )} /> - -
-
-
- Fee value - {rateType === "percentage" && ( - setRatePercentValue(parseFloat(e.target.value))} + + {showSellers && sellers && ( + ( + + Seller + + + + + + )} + /> + )} + + {showProductCategories && product_categories && ( + ( + + Product Category + + + + + + )} + /> + )} + + {showProductTypes && product_types && ( + ( + + Product Type + + + + + + )} + /> + )} + + - )} - {rateType === "flat" && - currencies && - currencies.map((currency) => { - return ( - <> - - v.currency_code === currency)[0] - ?.amount || 0 - } - onChange={(e) => - setRateFlatValue((prevValue) => { - return [ - ...prevValue.filter( - (v) => v.currency_code !== currency, - ), - { - currency_code: currency, - amount: parseFloat(e.target.value), - }, - ]; - }) - } - /> - - ); - })} -
- {rateType === "percentage" && ( - <> -
-
- - { - setMinCommissionEnabled(val); - }} - /> -
- {minCommissionEnabled && - currencies && - currencies.map((currency) => { - return ( - <> - - v.currency_code === currency, - )[0]?.amount || 0 - } - onChange={(e) => - setMinCommission((prevValue) => { - return [ - ...prevValue.filter( - (v) => v.currency_code !== currency, - ), - { - currency_code: currency, - amount: parseFloat(e.target.value), - }, - ]; - }) + + ( + + Fee Type + +
+ + + onChange(checked ? RateType.PERCENTAGE : RateType.FLAT) } /> - - ); - })} -
-
-
- - { - setMaxCommissionEnabled(val); - }} - /> -
- {maxCommissionEnabled && - currencies && - currencies.map((currency) => { - return ( - <> - + + + + + + )} + /> + + {rateType === RateType.PERCENTAGE && ( + ( + + Percentage Value + v.currency_code === currency, - )[0]?.amount || 0 - } + min={0} + max={100} + step={0.01} + value={value ?? 0} onChange={(e) => - setMaxCommission((prevValue) => { - return [ - ...prevValue.filter( - (v) => v.currency_code !== currency, - ), - { - currency_code: currency, - amount: parseFloat(e.target.value), - }, - ]; - }) + onChange(parseFloat(e.target.value) || 0) } + placeholder="Enter percentage (0-100)" /> - - ); - })} -
- - )} - -
+ + + + )} + /> + )} + + {rateType === RateType.FLAT && currencies.length > 0 && ( + ( + + Flat Fee Values +
+ {currencies.map((currency) => ( +
+ + + onChange({ + ...value, + [currency]: parseFloat(e.target.value) || 0, + }) + } + placeholder={`Enter amount in ${currency.toUpperCase()}`} + /> +
+ ))} +
+ +
+ )} + /> + )} + + {rateType === RateType.PERCENTAGE && ( + <> + + + {minCommissionEnabled && currencies.length > 0 && ( +
+ + {currencies.map((currency) => ( + ( + + {currency.toUpperCase()} + + + onChange({ + ...value, + [currency]: parseFloat(e.target.value) || 0, + }) + } + placeholder={`Enter minimum in ${currency.toUpperCase()}`} + /> + + + )} + /> + ))} +
+ )} + + + + {maxCommissionEnabled && currencies.length > 0 && ( +
+ + {currencies.map((currency) => ( + ( + + {currency.toUpperCase()} + + + onChange({ + ...value, + [currency]: parseFloat(e.target.value) || 0, + }) + } + placeholder={`Enter maximum in ${currency.toUpperCase()}`} + /> + + + )} + /> + ))} +
+ )} + + )} + + + + + + + ); };