Untitled
unknown
plain_text
9 months ago
18 kB
7
Indexable
'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;
Editor is loading...
Leave a Comment