Untitled
unknown
plain_text
10 months ago
34 kB
14
Indexable
const ProductForm = () => {
const { id } = useParams();
const { replace } = useRouter();
const { store } = useLayoutContext();
const [createProduct] = useCreateProductMutation();
const [updateProduct] = useUpdateProductMutation();
const { data: productGroups, refetch: refetchProductGroups } =
useGetProductGroupsQuery(
{ storeId: store?.id ?? "", paginateResponse: "false" },
{
refetchOnMountOrArgChange: true,
skip: !store?.id,
}
);
const { data: productById } = useGetProductByIdQuery({
productId: id as string,
storeId: store?.id ?? "",
});
const selectProductGroups = useMemo(
() =>
productGroups?.result?.map((group) => ({
label: group.groupName,
value: group.id.toString(),
})) || [],
[productGroups?.result]
);
const { show, handleCloseDialog, handleShowDialog } = useQueryString();
const [selected, setSelected] = useState<string>("no");
const editorHeader = editorHeaderTemplate();
const radioOptions = [
{ label: "Yes", value: "yes" },
{ label: "No", value: "no" },
];
const {
selectedFiles,
setSelectedFiles,
featuredUrl,
setFeaturedUrl,
fileUrls,
loading,
} = useAzureStorage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const initialValues: any = useMemo(() => {
if (productById) {
return {
productId: productById?.id || "",
productType: productById?.productType || "",
productName: productById?.productName || "",
imageUrls: productById.images || [""],
productDescription: productById?.productDescription || "",
publishStatus: productById?.publishStatus || 1,
costPrice: productById?.costPrice || 0,
price: productById?.price || 0,
strikethroughPrice: productById?.strikethroughPrice || 0,
variants:
productById?.productVariants?.map((variant) => ({
variantName: variant?.variantName || "",
imageUrls: variant?.imageUrls || [""],
variantOptions:
variant?.variantOptions?.map((option) => ({
option: option?.option || "",
additionalPrice: option?.additionalPrice || 0,
})) || [],
})) || [],
onlyShooper: "no",
weightType: "kg",
groupId: 0,
quantity: productById?.inventory || 0,
minimumInventoryCount: productById?.minimumInventoryCount || 0,
minimumOrderQuantity: productById?.minimumOrderQuantity || 0,
maximumOrderQuantity: productById?.maximumOrderQuantity || 0,
storeId: store?.id || "",
};
}
return {
productType: ProductTypeEnum.Physical,
productName: "",
productDescription: "",
imageUrls: [],
groupId: 0,
publishStatus: 1,
productInventory: {
quantity: 0,
minimumInventoryCount: 0,
},
orderQuantity: {
minimumOrderQuantity: 0,
maximumOrderQuantity: 0,
},
variants: [
{
variantName: "",
imageUrls: [],
variantOptions: [
{
option: "",
additionalPrice: 0,
},
],
},
],
pricing: {
costPrice: 0,
price: 0,
strikethroughPrice: 0,
},
storeId: store?.id || "",
onlyShooper: "no",
weightType: "kg",
};
}, [productById, store?.id]);
const validationSchema = object().shape({
productType: number().required("Product type is required"),
productName: string().required("Product name is required"),
productDescription: string().required("Product description is required"),
groupId: number().required("Group ID is required"),
publishStatus: number()
.oneOf([0, 1], "Invalid publish status")
.required("Publish status is required"),
productInventory: object().shape({
quantity: number().notRequired(),
minimumInventoryCount: number().notRequired(),
}),
orderQuantity: object().shape({
minimumOrderQuantity: number().notRequired(),
maximumOrderQuantity: number().notRequired(),
}),
variants: array()
.of(
object().shape({
variantName: string(),
// imageUrls: array().of(string()).notRequired(),
variantOptions: array().of(
object().shape({
option: string(),
additionalPrice: number(),
})
),
})
)
.notRequired(),
pricing: object().shape({
costPrice: number()
.min(0, "Cost price must be at least 0")
.required("Cost price is required"),
price: number()
.min(0, "Price must be at least 0")
.required("Price is required"),
strikethroughPrice: number().notRequired(),
}),
storeId: string(),
onlyShooper: string().oneOf(["yes", "no"]).notRequired(),
weightType: string().oneOf(["kg", "gram"]).notRequired(),
});
const handleSubmit = async (
values: ICreateProductRequestBody,
actions: FormikHelpers<ICreateProductRequestBody>
) => {
try {
actions.setSubmitting(true);
if (!store?.id) {
throw new Error("Store ID is required");
}
const payload = {
productType: Number(values.productType) as ProductTypeEnum,
productName: values.productName,
productDescription: values.productDescription,
imageUrls: fileUrls.slice(0, 3).map((file) => file.url),
groupId: Number(values.groupId),
publishStatus: values.publishStatus,
productInventory: {
quantity: values.productInventory.quantity,
minimumInventoryCount: values.productInventory.minimumInventoryCount,
},
orderQuantity: {
minimumOrderQuantity: values.orderQuantity.minimumOrderQuantity,
maximumOrderQuantity: values.orderQuantity.maximumOrderQuantity,
},
variants:
selected === "yes"
? values.variants.map((variant) => ({
variantName: variant.variantName,
imageUrls: fileUrls.slice(0, 3).map((file) => file.url),
variantOptions: variant.variantOptions.map((option) => ({
option: option.option,
additionalPrice: option.additionalPrice,
})),
}))
: [],
pricing: {
costPrice: values.pricing.costPrice,
price: values.pricing.price,
strikethroughPrice: values.pricing.strikethroughPrice,
},
storeId: store?.id,
onlyShooper: values.onlyShooper,
weightType: values.weightType,
};
const res = id
? await createProduct(payload).unwrap()
: await updateProduct(payload).unwrap();
toast.success(
typeof res?.data === "string"
? res?.data
: `Products ${id ? "updated" : "created"} successfully`
);
setSelectedFiles([]);
replace("/products");
} catch (err: unknown) {
const errorMessage = getErrorMessage(err);
toast.error(errorMessage);
}
actions.setSubmitting(false);
};
const formik = useFormik({
initialValues,
validationSchema,
onSubmit: handleSubmit,
});
return (
<>
<AddGroup
show={show === "add-product-group"}
onHide={handleCloseDialog}
dataId={""}
refetch={refetchProductGroups}
/>
<AddVariation
formik={formik}
show={show === "add-variant"}
onHide={handleCloseDialog}
/>
<Formik
enableReinitialize
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}>
{(props) => {
return (
<Form onSubmit={props.handleSubmit} className="space-y-6">
<FormTopActions
btnType="submit"
title={`${id ? "Edit" : "Create"} Product`}
disabled={!props.isValid}
loading={props.isSubmitting}
/>
<SectionWrapper showInner>
<div className="mx-auto max-w-[644px] space-y-5 px-4 pb-12 pt-8">
<div className="space-y-[16px]">
<p className="border-b border-grey-100 bg-grey-25 px-[4px] py-[8px] text-base font-[600] text-grey-900">
Product Type
</p>
<FormSelect
filter={false}
name="productType"
hideTemplate
placeholder="Select product type"
options={[
{
label: "Physical Product",
value: ProductTypeEnum.Physical.toString(),
},
{
label: "Digital Product",
value: ProductTypeEnum.Digital.toString(),
},
]}
value={String(props.values.productType)}
onChange={(value) =>
props.setFieldValue("productType", value)
}
touched={props.touched.productType}
message={props.errors.productType}
/>
</div>
<div className="space-y-[16px]">
<p className="border-b border-grey-100 bg-grey-25 px-[4px] py-[8px] text-base font-[600] text-grey-900">
General Information
</p>
<FormInput
label="Product Name"
placeholder="Enter Name"
{...props.getFieldProps("productName")}
touched={props.touched.productName}
message={props.errors.productName}
/>
<div className="relative space-y-2">
<label
htmlFor="description"
className="form-control-label">
Product Description
</label>
<Editor
placeholder="Enter your store description"
value={props.values.productDescription}
onTextChange={(e: EditorTextChangeEvent) => {
props.setFieldValue(
"productDescription",
e.htmlValue
);
}}
className="form-editor h-full rounded-lg border-grey-100"
headerTemplate={editorHeader}
/>
<ErrorMessage
name="productDescription"
component="span"
className="text-xs text-red-500"
/>
</div>
{Number(props.values.productType) ===
ProductTypeEnum.Digital && (
<div className="space-y-[12px]">
<p className="text-sm font-[500] text-grey-500">
Do you want the product to be accessible on Shooppar?
</p>
<RadioButtonInput
options={radioOptions}
name="onlyShooper"
selectedValue={props.values.onlyShooper}
onChange={(value) =>
props.setFieldValue("onlyShooper", value)
}
className="mt-4"
/>
</div>
)}
</div>
{/* Product Media */}
<MediaUpload
text="Images (Recommended dimension 930px x 1136px)"
loading={loading}
title="Product Media"
setSelectedFiles={setSelectedFiles}
selectedFiles={selectedFiles}
desc="Maximum of {maxFiles} images ({maxSize}MB) // File format: PNG and JPEG"
/>
<div className="space-y-[16px]">
<div className="flex items-center gap-[5px] border-b border-grey-100 bg-grey-25 px-[4px] py-[8px]">
<p className="text-base font-[600] text-grey-900">
Product Group
</p>
<IoIosInformationCircleOutline size={20} />
</div>
<div>
<FormSelect
filter={false}
hideTemplate
label="Group (optional)"
placeholder="Select product type"
name="Group"
options={selectProductGroups}
value={String(props.values.groupId)}
onChange={(value) =>
props.setFieldValue("groupId", value as string)
}
/>
<p className="text-xs font-[500] text-grey-300">
This helps to group similar products on store…e.g
T-shirts, Shoes, Perfumes.”
</p>
</div>
<div className="inline-flex">
<Button
label="Add New Group"
height={40}
icon={<FiPlus size={20} />}
onPress={handleShowDialog.bind(
null,
"add-product-group"
)}
className=""
/>
</div>
{/* PRICING */}
<div className="my-[24px] space-y-[16px]">
<p className="border-b border-grey-100 bg-grey-25 px-[4px] py-[8px] text-base font-[600] text-grey-900">
Pricing
</p>
<div className="flex items-center justify-between gap-[20px]">
<div className="flex-grow">
<FormInput
name={"costPrice"}
value={formatAmount(
props.values.pricing?.costPrice
)}
keyfilter={"pnum"}
inputMode="decimal"
onChange={(e) =>
handleFormattedAmountChange(
e,
"pricing.costPrice",
props.setFieldValue
)
}
label="Cost Price"
placeholder="₦"
touched={props.touched.pricing?.costPrice}
message={props.errors.pricing?.costPrice}
/>
</div>
<div className="flex-grow">
<FormInput
name={"Product Price"}
label="Product Price"
placeholder="₦"
keyfilter={"pnum"}
inputMode="decimal"
onChange={(e) =>
handleFormattedAmountChange(
e,
"pricing.price",
props.setFieldValue
)
}
value={formatAmount(props.values.pricing?.price)}
touched={props.touched.pricing?.price}
message={props.errors.pricing?.price}
/>
</div>
<div className="flex-grow">
<FormInput
name={"DiscountPrice"}
placeholder="₦"
keyfilter={"pnum"}
inputMode="decimal"
label="Discount price (optional)"
onChange={(e) =>
handleFormattedAmountChange(
e,
"pricing.strikethroughPrice",
props.setFieldValue
)
}
value={formatAmount(
props.values.pricing?.strikethroughPrice
)}
touched={props.touched.pricing?.strikethroughPrice}
message={props.errors.pricing?.strikethroughPrice}
/>
</div>
</div>
</div>
<div className="mt-[24px] space-y-[16px]">
<p className="border-b border-grey-100 bg-grey-25 px-[4px] py-[8px] text-base font-[600] text-grey-900">
Variations
</p>
<div className="space-y-[12px]">
<p className="text-sm font-[500] text-grey-500">
Does this product have variations (options)? e.g
different tiers, packages, add-ons, sizes or colors
</p>
<RadioButtonInput
options={radioOptions}
name="addOption"
selectedValue={selected}
onChange={setSelected}
className="mt-4"
/>
</div>
{selected === "yes" && (
<div className="">
<div className="inline-flex">
<Button
label="Add Variant"
height={40}
icon={<FiPlus size={20} />}
onPress={handleShowDialog.bind(
null,
"add-variant"
)}
className=""
/>
</div>
<div className="mt-[24px] w-[644px] rounded-[12px] bg-grey-25 p-[18px]">
<div className="flex items-center justify-between border-b border-gray-200 pb-2">
<div className="w-40 flex-shrink-0">
<FormInput
className="w-full"
label="Variant Name"
placeholder="Enter Name"
{...props.getFieldProps(
"variants.[0].variantName"
)}
touched={
(
props.touched
.variants as FormikTouched<IVariant>[]
)?.[0]?.variantName
}
/>
</div>
<div className="flex gap-3">
<button className="rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50">
<FiEdit3
size={20}
className="text-gray-400 hover:text-gray-600"
/>
</button>
<button className="rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50">
<FaRegTrashAlt
size={20}
className="text-gray-400 hover:text-gray-600"
/>
</button>
</div>
</div>
<MediaUpload
text="Images (Recommended dimension 930px x 1136px)"
title="Variant Images"
loading={loading}
desc="Maximum of {maxFiles} images ({maxSize}MB) // File format: PNG and JPEG"
selectedFiles={featuredUrl}
setSelectedFiles={setFeaturedUrl}
/>
<div className="flex-grow pt-5">
<div className="fle flex items-center gap-[16px]">
<div className="h-24">
<FormInput
label="Option"
placeholder="Enter option"
{...props.getFieldProps(
"variants[0].variantOptions[0].option"
)}
touched={
props.touched.variants?.[0]
?.variantOptions?.[0]?.option
}
/>
</div>
<div className="h-24">
<FormInput
name="Additional Price"
keyfilter={"pnum"}
inputMode="decimal"
label="Additional Price"
placeholder="₦"
value={formatAmount(
props.values?.variants[0]
?.variantOptions?.[0]?.additionalPrice
)}
onChange={(e) =>
handleFormattedAmountChange(
e,
"variants[0].variantOptions[0].additionalPrice",
props.setFieldValue
)
}
touched={
props.touched.variants?.[0]
?.variantOptions?.[0]?.additionalPrice
}
/>
</div>
<button className="rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50">
<FaRegTrashAlt
size={20}
className="text-gray-400 hover:text-gray-600"
/>
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{Number(props.values.productType) ===
ProductTypeEnum.Physical && (
<>
<div className="space-y-[16px]">
<div className="flex items-center gap-[5px] border-b border-grey-100 bg-grey-25 px-[4px] py-[8px]">
<p className="text-base font-[600] text-grey-900">
Product Inventory
</p>
<IoIosInformationCircleOutline size={20} />
</div>
<div className="grid grid-cols-3 items-start gap-[20px]">
<div className="flex-grow">
<FormInput
label="Quantity"
inputMode="decimal"
placeholder="Type quantity"
keyfilter={"pnum"}
value={formatAmount(
props.values.productInventory?.quantity
)}
onChange={(e) =>
handleFormattedAmountChange(
e,
"productInventory.quantity",
props.setFieldValue
)
}
touched={props.touched.productInventory?.quantity}
message={props.errors.productInventory?.quantity}
name="productInventory.quantity"
/>
</div>
<div className="flex-grow">
<FormInput
keyfilter={"pnum"}
inputMode="decimal"
label="Minimum Inventory Level"
placeholder="Type Level"
name="Minimum Inventory Level"
value={formatAmount(
props.values.productInventory
?.minimumInventoryCount
)}
onChange={(e) =>
handleFormattedAmountChange(
e,
"productInventory.minimumInventoryCount",
props.setFieldValue
)
}
touched={
props.touched.productInventory
?.minimumInventoryCount
}
message={
props.errors.productInventory
?.minimumInventoryCount
}
/>
</div>
<div className="flex-grow">
<FormSelect
readOnly
filter={false}
hideTemplate
label="Weight (optional)"
placeholder="Kg"
name="weight"
value={props.values.weightType}
onChange={(value) =>
props.setFieldValue("weightType", value)
}
options={[
{
label: "Kg",
value: "kg",
},
{
label: "Gram",
value: "gram",
},
]}
/>
</div>
</div>
</div>
<div className="my-[24px] space-y-[16px]">
<div className="flex items-center gap-[5px] border-b border-grey-100 bg-grey-25 px-[4px] py-[8px]">
<p className="text-base font-[600] text-grey-900">
Order Quantity
</p>
<IoIosInformationCircleOutline size={20} />
</div>
<div className="grid grid-cols-2 items-start gap-[20px]">
<div className="flex-grow">
<FormInput
keyfilter={"pnum"}
label="Minimum Order Quantity"
placeholder="Type quantity"
name="Minimum Order Quantity"
value={formatAmount(
props.values.orderQuantity?.minimumOrderQuantity
)}
onChange={(e) =>
handleFormattedAmountChange(
e,
"orderQuantity.minimumOrderQuantity",
props.setFieldValue
)
}
touched={
props.touched.orderQuantity
?.minimumOrderQuantity
}
message={
props.errors.orderQuantity?.minimumOrderQuantity
}
/>
</div>
<div className="flex-grow">
<FormInput
keyfilter={"pnum"}
label="Maximum Order Quantity"
placeholder="Type quantity"
name="Maximum Order Quantity"
onChange={(e) =>
handleFormattedAmountChange(
e,
"orderQuantity.maximumOrderQuantity",
props.setFieldValue
)
}
value={formatAmount(
props.values.orderQuantity?.maximumOrderQuantity
)}
touched={
props.touched.orderQuantity
?.maximumOrderQuantity
}
message={
props.errors.orderQuantity?.maximumOrderQuantity
}
/>
</div>
</div>
</div>
</>
)}
<div className="my-[24px] space-y-[16px]">
<div className="border-b border-grey-100 bg-grey-25 px-[4px] py-[8px]">
<p className="text-base font-[600] text-grey-900">
Product Status
</p>
</div>
<div className="">
<p className="text-sm font-[400] text-grey-400">
Select 'Save as Draft' to save your product
but making it unavailable to customers. or choose
'Publish' to make your product visible to
customers and available for purchase."
</p>
</div>
<div className="my-[24px] space-y-[16px]">
<div className="space-y-[16px]">
<div className="flex items-center gap-[10px]">
<Field
type="checkbox"
name="publishStatus"
checked={props.values.publishStatus === 0}
onChange={() =>
props.setFieldValue("publishStatus", 0)
}
className="mr-2 mt-1 bg-grey-0 *:h-4 *:w-4 checked:bg-primary-300"
/>
<p className="cursor-pointer text-base font-[500] text-grey-900">
Save as draft
</p>
</div>
<div className="flex items-center gap-[10px]">
<Field
type="checkbox"
name="publishStatus"
checked={props.values.publishStatus === 1}
onChange={() =>
props.setFieldValue("publishStatus", 1)
}
className="mr-2 mt-1 bg-grey-0 *:h-4 *:w-4 checked:bg-primary-300"
/>
<p className="cursor-pointer text-base font-[500] text-grey-900">
Publish Product
</p>
</div>
<ErrorMessage
name="publishStatus"
component="span"
className="text-xs text-red-500"
/>
</div>
</div>
</div>
</div>
</SectionWrapper>
</Form>
);
}}
</Formik>
</>
);
};
export default ProductForm;
Editor is loading...
Leave a Comment