Untitled
'use client'; import { RichTextEditor, TextEditorMethods, } from '@/components/rich-text-editor'; import { productSharedFields, productVariantSchema, } from '@/zod-schemas/product'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { FormResetButton } from '@/components/form-reset-button'; import { ProductAssetsManager } from './product-assets-manager'; import { InputWrapper } from '@/components/input-wrapper'; import { zodResolver } from '@hookform/resolvers/zod'; import { Checkbox } from '@/components/ui/checkbox'; import { InputDesc } from '@/components/input-desc'; import { queryKeys } from '@/constants/query-keys'; import { Button } from '@/components/ui/button'; import { showToastOnError } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Loader2Icon } from 'lucide-react'; import { ServerBaseError } from '@/types'; import { toast } from 'react-toastify'; import { Product } from '@/types/db'; import mergeRefs from 'merge-refs'; import React from 'react'; import { z } from 'zod'; const assetSchema = z .array( z.object({ type: z.enum(['video', 'image']), name: z.string(), id: z.number(), url: z.string(), assetId: z.string(), publicId: z.string(), blurDataUrl: z.string(), key: z.string(), }), ) .min(1, 'At least one asset is required.'); const rootSchema = z.object({ assets: assetSchema, name: z.string().trim().min(1, 'Name is required.'), description: z .string() .trim() .refine( (arg) => (arg ? arg.length >= 100 : true), 'At least 100 characters are required.', ), brand: z .object({ id: z.number(), name: z.string() }) .nullable() .refine((arg) => arg, 'Brand is required.'), category: z .object({ id: z.number(), name: z.string() }) .nullable() .refine((arg) => arg, 'Category is required.'), returnPolicy: z .object({ id: z.number(), name: z.string() }) .nullable() .refine((arg) => arg, 'Return Policy is required.'), featured: z.boolean(), active: z.boolean(), haveOptions: z.boolean(), }); const haveOptionsSchema = rootSchema.extend({ variants: z .array( productVariantSchema .omit({ assets: true, combination: true }) .extend({ assets: assetSchema, combination: z .array(z.object({ id: z.number(), value: z.string() })) .min(1, 'Combination is required.'), }), ) .min(1, 'At least one variant is required.'), options: z .array( z.object({ name: z .object({ id: z.number(), name: z.string(), isColor: z.boolean(), }) .nullable() .refine((arg) => arg, 'Name is required.'), choices: z .array(z.object({ id: z.number(), value: z.string() })) .min(1, 'At least one choice is required.'), }), ) .min(1, 'At least one option is required.'), }); const noOptionsSchema = rootSchema.merge(productSharedFields); type HaveOptions = z.infer<typeof haveOptionsSchema>; type NoOptions = z.infer<typeof noOptionsSchema>; export type FormValues = HaveOptions & NoOptions; export const sharedFromDefaults = { assets: [], sku: '', weight: '0', price: 0, discountPercentage: 0, stockQuantity: 0, minimumStock: 0, backOrdered: false, active: true, }; const formDefaults = { haveOptions: false, name: '', description: '', brand: null, category: null, returnPolicy: null, featured: false, ...sharedFromDefaults, options: [], variants: [], }; interface ProductFormProps { id?: string; brandSelect: React.ReactNode; categorySelect: React.ReactNode; returnPolicySelect: React.ReactNode; productOptions: React.ReactNode; productVariants: React.ReactNode; defaultValues?: FormValues; children?: React.ReactNode; } const displayName = 'ProductForm'; export const ProductForm = (props: ProductFormProps) => { const { brandSelect, categorySelect, returnPolicySelect, defaultValues, productOptions, id, children, productVariants, } = props; const optionsDescId = React.useId(); const editorRef = React.useRef<TextEditorMethods>(null); const formReturn = useForm<FormValues>({ resolver: (values, context, options) => { if (values.haveOptions) { return zodResolver(haveOptionsSchema)( values, context, options, ); } return zodResolver(noOptionsSchema)(values, context, options); }, defaultValues: defaultValues || formDefaults, }); const { control, handleSubmit, reset, register, resetField, formState: { isDirty, errors, isSubmitting }, } = formReturn; const resetForm = () => { if (!editorRef.current) throw new Error('Unexpected editorRef.current=null'); reset(formDefaults); editorRef.current.reset(); }; const queryClient = useQueryClient(); const mutation = useMutation<Product, ServerBaseError, unknown>({ mutationFn: async (values) => { const res = await fetch(`/api/products/${id ? id : ''}`, { method: id ? 'PATCH' : 'POST', body: JSON.stringify(values), headers: { 'Content-Type': 'application/json', }, }); if (!res.ok) throw await res.json(); const { data, message } = await res.json(); toast.success(message); return data; }, onError: showToastOnError, onSuccess: async () => { if (!id) { resetForm(); } await queryClient.invalidateQueries({ queryKey: queryKeys.PRODUCTS, exact: true, }); }, }); const onSubmit = async (values: FormValues) => { if ( !editorRef.current || !values.brand || !values.category || !values.returnPolicy ) throw new Error('Something went wrong internally.'); const rootFields = { assets: values.assets.map((ele) => ele.id), name: values.name, description: values.description, brandId: values.brand.id, categoryId: values.category.id, returnPolicyId: values.returnPolicy.id, featured: values.featured, active: values.active, }; let body; if (values.haveOptions) { body = { ...rootFields, haveOptions: true, variants: values.variants.map((variant) => ({ ...variant, combination: variant.combination.map((comb) => comb.id), assets: variant.assets.map((ele) => ele.id), })), options: values.options.map((ele) => { if (!ele.name) throw new Error('Something went wrong internally.'); return { id: ele.name.id, choices: ele.choices.map((ele) => ele.id), }; }), }; } if (!values.haveOptions) { body = { ...rootFields, haveOptions: false, sku: values.sku, weight: values.weight, price: values.price, discountPercentage: values.discountPercentage, stockQuantity: values.stockQuantity, minimumStock: values.minimumStock, backOrdered: values.backOrdered, }; } await mutation.mutateAsync(body).catch(); }; return ( <FormProvider {...formReturn}> <form onSubmit={handleSubmit(onSubmit)} className="mx-auto mt-7 grid w-full max-w-xl grid-cols-1 gap-x-5 gap-y-1 md:grid-cols-2" > <ProductAssetsManager /> <InputWrapper error={errors.name}> {({ descProps, inputProps, labelProps, errorMessage }) => ( <> <Label {...labelProps}>Name</Label> <Input disabled={isSubmitting} {...inputProps} {...register('name')} autoComplete="off" /> <InputDesc {...descProps}>{errorMessage}</InputDesc> </> )} </InputWrapper> {brandSelect} {categorySelect} {returnPolicySelect} <InputWrapper className="col-start-1 -col-end-1"> {({ labelProps, descId, inputId }) => ( <> <Label {...labelProps}>Description</Label> <Controller control={control} name="description" disabled={isSubmitting} render={({ field: { name, onBlur, onChange, ref, value, disabled, }, fieldState: { error }, }) => ( <> <RichTextEditor aria-describedby={descId} aria-invalid={!!error} id={inputId} content={value} ref={mergeRefs(ref, editorRef)} disabled={disabled} onBlur={onBlur} onUpdate={({ editor }) => { onChange({ target: { name, value: editor.getText() }, type: 'onChange', }); }} editorClassName="min-h-96 max-h-[600px]" /> <InputDesc id={descId} isError={!!error}> {error?.message} </InputDesc> </> )} /> </> )} </InputWrapper> <div className="col-start-1 -col-end-1 mt-1 space-y-3"> {[ { name: 'active' as const, label: 'Display this product to users ?', }, { name: 'featured' as const, label: 'Feature this product on the landing page ?', }, ].map(({ name, label }, i) => ( <InputWrapper key={i} className="flex items-center gap-2 space-y-0" > {({ inputId }) => ( <> <Controller control={control} name={name} disabled={isSubmitting} render={({ field: { value, onChange, ...field }, }) => ( <Checkbox id={inputId} checked={value} onCheckedChange={(checked) => { onChange({ target: { value: checked } }); }} {...field} /> )} /> <Label htmlFor={inputId} className="whitespace-normal" > {label} </Label> </> )} </InputWrapper> ))} </div> <Controller control={control} name="haveOptions" disabled={isSubmitting} render={({ field: { value, onChange, ...field } }) => ( <> <InputWrapper className="col-start-1 -col-end-1 mt-2 flex items-center gap-2 space-y-0"> {({ inputId }) => ( <> <Checkbox id={inputId} checked={value} onCheckedChange={(checked) => { onChange({ target: { value: checked } }); resetField('options', { defaultValue: [] }); resetField('variants', { defaultValue: [] }); resetField('backOrdered', { defaultValue: false, }); }} {...field} /> <Label htmlFor={inputId} className="whitespace-normal" > Does this product come with options{' '} <span className="text-sm text-muted-foreground"> ( e.g., size, color ) </span>{' '} ? </Label> </> )} </InputWrapper> {!value && ( <> <InputWrapper className="col-start-1 -col-end-1 mb-5 mt-2 flex items-center gap-2 space-y-0"> {({ inputId }) => ( <> <Controller control={control} name="backOrdered" disabled={isSubmitting} render={({ field: { value, onChange, ...field }, }) => ( <Checkbox id={inputId} checked={value} onCheckedChange={(checked) => { onChange({ target: { value: checked }, }); }} {...field} /> )} /> <Label htmlFor={inputId} className="whitespace-normal" > Accept orders when the product is out of stock ? </Label> </> )} </InputWrapper> {[ { name: 'sku' as const, label: 'Sku' }, { name: 'weight' as const, label: 'Weight (kg)' }, { name: 'price' as const, label: 'Price' }, { name: 'discountPercentage' as const, label: 'Discount Percentage', }, { name: 'stockQuantity' as const, label: 'Stock Quantity', }, { name: 'minimumStock' as const, label: 'Minimum Stock', }, ].map((ele) => ( <InputWrapper key={ele.name} error={errors[ele.name]} > {({ descProps, inputProps, labelProps, errorMessage, }) => ( <> <Label {...labelProps} className="inline-block first-letter:uppercase" > {ele.label} </Label> <Input disabled={isSubmitting} {...inputProps} {...register(ele.name)} autoComplete="off" /> <InputDesc {...descProps}> {errorMessage} </InputDesc> </> )} </InputWrapper> ))} </> )} {value && ( <> <div className="col-start-1 -col-end-1 mb-5 mt-5 space-y-1.5 rounded-md bg-muted p-6"> <h2 aria-describedby={optionsDescId} className="text-lg font-semibold leading-none tracking-tight" > Product Options </h2> <p id={optionsDescId} className="text-sm text-muted-foreground" > Manage the options this product comes in. <br /> Ensure the option is conventional, such as color, size, flavor, etc. </p> </div> {productOptions} </> )} </> )} /> {productVariants} <div className="mt-5 flex items-center justify-end gap-3 md:col-span-2"> <FormResetButton disabled={!isDirty || isSubmitting} onReset={resetForm} /> <Button type="submit" disabled={isSubmitting}> {isSubmitting && <Loader2Icon className="animate-spin" />} <span>Submit</span> </Button> </div> </form> {children} </FormProvider> ); }; ProductForm.displayName = displayName;
Leave a Comment