Untitled

 avatar
unknown
plain_text
8 days ago
18 kB
4
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;
Leave a Comment