T</>TahuCoding

Part 6 - Tutorial Next.js 13 Typescript Ecommerce Cart dengan Payment Gateway - Tambah Products dengan Supabase Storage

Loading...

Pada kesempatan kali ini kita akan belajar bagaimana cara menambahkan product, kita akan belajar bagaimana cara upload, mutasi data hingga cara merelasikan category yang sudah ada dengan product yang ditambahkan.

Step 1: Setup Supabase Storage

Agar dapat mengupload file ada baiknya kita menggunakan sebuah wadah penampungan terpisah agar load pada server kita tidak terlalu tinggi, dalam hal ini kita akan menggunakan supabase storage.

Buka admin supabase kemudian pada menu storage click tombol New bucket, kemudian isikan seperti berikut:

Create Bucket

Jika sudah buatlah sebuah folder baru dengan name products hingga tampilan menjadi seperti berikut:

Products Storage

Agar dapat mengakses storage public kita perlu menambahkan policies bagaimana user dapat berinteraksi dengan storage, klik menu configuration-policies kemudian pilih New policy lalu pilih For full customization dan isikan dengan konfigurasi berikut:

New Policy

Untuk menhindari kompleksitas sementara author centang semua permission, jika kamu ingin lebih aman pastikan centang sesuai kebutuhan.

Jika sudah berhasil maka tampilan policy-mu akan menjadi seperti berikut:

Policies

Setelah berhasil mengkonfigurasi supabase storage kembali ke project, kemudian tambahkan constant baru:

src\constant\index.ts
export const APP_URL = process.env.NEXTAUTH_URL as string;
export const fallBackImage =
  "data:image/png;base64,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";
export const SUPABASE_URL = process.env.SUPABASE_URL as string;
export const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY as string;
export const PRODUCT_IMAGE_PATH =
  SUPABASE_URL + "/storage/v1/object/public/storage/products/";

Agar value pada constant terhubung tambahkan key berikut pada .env:

.env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
 
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
 
DATABASE_URL="YOUR_DATABASE_URL"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="RANDOM_STRING_FOR_NEXT_AUTH_SECRET"
SUPABASE_URL="YOUR_SUPABASE_URL"
SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"

Kamu dapat menemukan supabase URL dan ANON KEY pada menu settings-API seperti berikut:

Setting

Selanjutnya tambahkan file supabase.ts pada folder lib:

Install libarary supbase.js:

npm i @supabase/supabase-js

Kemudian init supabase client:

src\lib\supabase.ts
import { SUPABASE_ANON_KEY, SUPABASE_URL } from "@/constant";
import { createClient } from "@supabase/supabase-js";
 
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

Step 2: Membuat POST Product API

Agar dapat menambahkan product pada database, tentunya kita harus membuat sebuah API yang bertanggung jawab untuk melakukan hal itu, pada dasarnya code berikut dapat kamu eksekusi pada sisi FE. Author tidak merekomendasikan menulis logic yang berhubungan dengan database secara langsung pada sisi FE demi alasan keamanan dan perfoma.

Pertama, buatlah sebuah api baru pada api\dashboard\products dengan method POST dan jangan lupa tambahkan path baru ketika GET products:

src\app\api\dashboard\products\route.ts
import { NextResponse, NextRequest } from "next/server";
import prisma from "@/lib/prisma";
import slugify from "slugify";
 
import { supabase } from "@/lib/supabase";
import { CreateProductDto } from "./dto";
import { PRODUCT_IMAGE_PATH } from "@/constant";
import { getToken } from "next-auth/jwt";
 
const { json: jsonResponse } = NextResponse;
 
export const GET = async (request: NextRequest) => {
    //...previous code
 
    return jsonResponse(
      {
        message: "Successfully get products",
        data: {
          products: products.map((product) => ({
            ...product,
            image: PRODUCT_IMAGE_PATH + product.image,
          })),
          totalCount,
          totalPages,
        },
      },
      {
        status: 200,
      }
    );
  } catch (_error) {
    return jsonResponse(
      {
        message: "Server Error",
      },
      {
        status: 500, //server error status code
      }
    );
  }
};
 
export const POST = async (request: NextRequest) => {
  try {
    const session = await getToken({
      req: request,
      secret: process.env.NEXTAUTH_SECRET,
    });
 
    const formData = await request.formData();
 
    const response = CreateProductDto.safeParse(formData);
 
    const image = formData.get("image") as File;
 
    const maxSizeInBytes = 5 * 1024 * 1024; // 5MB
 
    if (image && image.size > maxSizeInBytes) {
      return jsonResponse(
        {
          message: "Invalid request",
          errors: [{ message: "max image is 5MB" }],
        },
        {
          status: 400,
        }
      );
    }
 
    if (!response.success) {
      const { errors } = response.error;
 
      return jsonResponse(
        {
          message: "Invalid request",
          errors,
        },
        {
          status: 400,
        }
      );
    }
 
    const { name, description, categoryId, price, qty } = response.data;
    const fileExtension = `.${image.name.split(".").pop()}` || "";
 
    //upload image to server
    const { data: uploadData, error } = await supabase.storage
      .from("storage/products")
      .upload(
        `product-${slugify(name, { lower: true })}-${Date.now()}-${
          session?.id
        }` + fileExtension,
        image
      );
    if (error) {
      return jsonResponse(
        {
          message: "Failed to upload image",
          errors: [error],
        },
        {
          status: 400,
        }
      );
    } else {
      let slug = slugify(name, { lower: true });
 
      const existingRecord = await prisma.product.findUnique({
        where: { slug },
      });
 
      // Check if the slug already exists
      if (existingRecord) {
        // Generate a unique slug by appending a counter
        let counter = 1;
        let uniqueSlug = `${slug}-${counter}`;
 
        // Keep incrementing the counter until a unique slug is found
        while (
          await prisma.product.findUnique({ where: { slug: uniqueSlug } })
        ) {
          counter++;
          uniqueSlug = `${slug}-${counter}`;
        }
 
        // Set the unique slug
        slug = uniqueSlug;
      }
 
      //create product with new slug and image path
      const product = await prisma.product.create({
        data: {
          name,
          slug,
          description,
          categoryId,
          price,
          qty,
          image: uploadData.path,
        },
      });
 
      return jsonResponse(
        {
          message: "Successfully created product",
          data: {
            product: {
              ...product,
              image: PRODUCT_IMAGE_PATH + product.image,
            },
          },
        },
        {
          status: 201, //success created status code
        }
      );
    }
  } catch (_error) {
    return jsonResponse(
      {
        message: "Server Error",
      },
      {
        status: 500, //server error status code
      }
    );
  }
};

Selanjutnya, install library slugify agar kita dapat menggenarate slug based on nama prodcut:

npm i slugify

Selanjutnya karena kita menggunakan session dan butuh id dari sana maka modifikasi api next auth menjadi seperti berikut:

src\app\api\auth[...nextauth]\route.ts
import prisma from "@/lib/prisma";
import type { NextAuthOptions } from "next-auth";
import { compare } from "bcrypt";
 
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
 
export const OPTIONS: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "credentials", //use this for custom login
      credentials: {
        username: {
          label: "Username",
          type: "text",
          placeholder: "crusherblack",
        },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const loginErrorMessage = "Invalid email or password";
 
        //check if email already exist or not
        const isUserExisted = await prisma.user.findUnique({
          where: {
            email: credentials?.username,
          },
        });
 
        //if not throw error
        if (!isUserExisted) {
          throw Error(loginErrorMessage);
        }
 
        //check if password that client input is them sama with hash password from db
        if (
          isUserExisted &&
          credentials?.password &&
          (await compare(credentials.password, isUserExisted.password))
        ) {
          const { password: _, ...restUser } = isUserExisted;
          //if email and password valid continue
          return restUser;
        }
 
        //throw error if password not valid
        throw Error(loginErrorMessage);
      },
    }),
  ],
  pages: {
    signIn: "/login", //tell next auth, this is url for custom login page
  },
  session: {
    strategy: "jwt", //make sure to set it to jwt
  },
  jwt: {
    secret: process.env.NEXTAUTH_SECRET, //fill with your own secret from .env
  },
  callbacks: {
    session: async ({ session, token }) => {
      if (session?.user) {
        session.user.id = token.id;
        session.user.role = token.role;
      }
      return session;
    },
    jwt: async ({ user, token }) => {
      if (user) {
        token = {
          ...user,
          ...token,
        };
      }
      return token;
    },
  },
};
 
const handler = NextAuth(OPTIONS);
 
export { handler as GET, handler as POST };

Tambahkan juga custom type pada folder lib:

src\lib\next-auth.d.ts
import NextAuth, { DefaultUser } from "next-auth";
import type { User } from "prisma/prisma-client";
 
type AuthUser = Omit<User, "password">;
 
declare module "next-auth" {
  interface Session {
    user: AuthUser;
  }
}
 
import { JWT } from "next-auth/jwt";
 
declare module "next-auth/jwt" {
  interface JWT extends AuthUser {}
}

Selanjutnya, install libarary zod sebagai validator:

npm i zod
npm i zod-form-data

Note: upgrade ke node versi 20 agar bisa menggunakan validasi zod.file()

Kemudian, buatlah sebuah DTO (Data Transfer Object) yang bertujuan untuk melakukan validasi pada setiap request pada API Product nantinya:

src\app\api\dashboard\products\dto.ts
import { zfd } from "zod-form-data";
import { z } from "zod";
 
//data transfer object with validation using form data
export const CreateProductDto = zfd.formData({
  name: zfd.text(
    z
      .string({
        required_error: "Name is required",
      })
      .trim()
      .min(1, "Name cannot be empty")
  ),
  description: zfd.text(
    z
      .string({
        required_error: "Description is required",
      })
      .trim()
      .min(1, "Email cannot be empty")
  ),
  price: zfd.numeric(
    z.number({
      required_error: "Price is required",
    })
  ),
  qty: zfd.numeric(
    z.number({
      required_error: "Qty is required",
    })
  ),
  categoryId: zfd.text(
    z
      .string({
        required_error: "Category ID is required",
      })
      .trim()
      .min(1, "Name cannot be empty")
  ),
});

Penjelasan code:

Pertama kita mengambil data user yg login menggunakan function getToken, data tersebut akan digunakan nanti ketika proses pembuatan nama file yang unik setiap kali upload image.

Perlu diperhatikan, pada kesempatan kali ini kita tidak menggunakan JSON sebagai body dari request kita, alasannya karena kita ingin mengirim file dan data product disaat bersamaan, maka dari itu kita menggunakan method formData agar bisa mendapatkan body dari request.

Setelah itu kita akan melakukan validasi menggunakan CreateProductDto, jika gagal maka kita return invalid request beserta errors validasi.

Selanjutnya jika validasi aman maka kita akan mengupload file menggunakan library supabase client dengan nama unik agar tidak terjadi colution antar file pada storage.

Jika berhasil, kemudian kita akan membuat sebuah slug based on nama product, kemudian cek apakah slug tersebut sudah ada atau tidak, jika sudah ada maka kita akan menambahkan counter pada slug hingga slug menjadi unik.

Note: slug digunakan sebagai url dari product nantinya.

Jika proses create slug selesai, simpan product ke database dan kembalikan response product yang sudah dibuat.

Step 3: Membuat GET Categories API

Tambakan code berikut pada api\dashboard\categories untuk GET categories:

src\app\api\dashboard\categories\route.ts
import { NextResponse, NextRequest } from "next/server";
import prisma from "@/lib/prisma";
 
const { json: jsonResponse } = NextResponse;
 
export const GET = async (_request: NextRequest) => {
  try {
    const categories = await prisma.category.findMany({
      orderBy: {
        createdAt: "desc",
      },
    });
 
    return jsonResponse(
      {
        message: "Successfully get categories",
        data: {
          categories,
        },
      },
      {
        status: 200,
      }
    );
  } catch (_error) {
    return jsonResponse(
      {
        message: "Server Error",
      },
      {
        status: 500, //server error status code
      }
    );
  }
};

Step 4: Tambahkan Services

Agar dapat melakukan mutasi data, tambahkan method addProduct pada service product:

src\services\product.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { Product } from "@prisma/client";
import { APP_URL } from "@/constant";
import { GeneralResponse } from "./type";
import { addSearchParams } from "@/utils/url";
 
type ProductWithPagination = {
  products: Product[];
  totalCount: number;
  totalPages: number;
};
 
type ProductPaginationParams = {
  page: number;
  limit: number;
  filter?: {
    name?: string;
  };
};
 
export type ProductFields = Omit<Product, "image"> & {
  image: File | null | string;
};
 
export const productApi = createApi({
  reducerPath: "productApi",
  baseQuery: fetchBaseQuery({ baseUrl: APP_URL }),
  endpoints: (builder) => ({
    getProducts: builder.query<
      GeneralResponse<ProductWithPagination>,
      ProductPaginationParams
    >({
      query: ({ page, limit, filter }) => {
        const queryParams = addSearchParams([
          { name: "page", value: page.toString() },
          { name: "limit", value: limit.toString() },
          { name: "name", value: filter?.name },
        ]);
 
        return `/api/dashboard/products${queryParams}`;
      },
    }),
    addProduct: builder.mutation<GeneralResponse<Product>, ProductFields>({
      query: (product) => {
        const formData = new FormData();
        formData.append("name", product.name);
        formData.append("description", product.description);
        formData.append("categoryId", product.categoryId);
        formData.append("price", product.price.toString());
        formData.append("qty", product.qty.toString());
 
        if (product.image) {
          formData.append("image", product.image);
        }
 
        return {
          url: "/api/dashboard/products",
          method: "POST",
          body: formData,
          formData: true,
        };
      },
    }),
  }),
});
 
export const {
  useGetProductsQuery,
  useAddProductMutation,
} = productApi;

Selanjutnya, kita perlu membuat sebuah service baru untuk "Category" agar nanti ketika mengisi form user dapat memilih category yang sesuai dengan product yang ditambahkan:

src\services\category.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { Category } from "@prisma/client";
import { APP_URL } from "@/constant";
import { GeneralResponse } from "./type";
 
export const categoryApi = createApi({
  reducerPath: "categoryApi",
  baseQuery: fetchBaseQuery({ baseUrl: APP_URL }),
  endpoints: (builder) => ({
    getCategories: builder.query<
      GeneralResponse<{
        categories: Category[];
      }>,
      null
    >({
      query: (id) => ({
        url: `/api/dashboard/categories`,
        method: "GET",
      }),
    }),
  }),
});
 
export const { useGetCategoriesQuery } = categoryApi;

Agar service category dapat diakses pastikan kamu sudah menambahkannya pada redux store:

src\store\index.ts
import { categoryApi } from "@/services/category";
import { productApi } from "@/services/product";
import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/dist/query";
 
export const store = configureStore({
  reducer: {
    [productApi.reducerPath]: productApi.reducer,
    [categoryApi.reducerPath]: categoryApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(
      productApi.middleware,
      categoryApi.middleware
    ),
});
 
setupListeners(store.dispatch);
 
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;

Step 5: Membangun Form Product

Sebelum masuk ke form kita perlu modifikasi page product dengan menambahkan button Create Product, form object dan load component ProductForm:

src\app\dashboard\products\page.tsx
"use client";
 
import { Button, Form, Table, Input } from "antd";
import { useEffect, useMemo, useState } from "react";
import { TablePaginationConfig } from "antd/es/table";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { PlusOutlined } from "@ant-design/icons";
 
import ProductForm from "./form";
import { columns } from "./column";
 
import {
  ProductFields,
  useGetProductsQuery,
} from "@/services/product";
import { addSearchParams } from "@/utils/url";
 
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
 
const Products = () => {
  const [isFormVisible, setIsFormVisible] = useState(false);
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
 
  const page = parseInt(searchParams.get("page") as string) || DEFAULT_PAGE;
  const limit = parseInt(searchParams.get("limit") as string) || DEFAULT_LIMIT;
  const searchName = searchParams.get("name") || "";
 
 
  // need to init this on the page level to easily handle form when edit and save
  const [form] = Form.useForm<ProductFields>();
 
  const { data, isFetching, refetch } = useGetProductsQuery({
    page,
    limit,
    filter: {
      name: searchName,
    },
  });
 
  const dataSource = useMemo(() => data?.data.products || [], [data]);
  const totalDataCount = useMemo(() => data?.data.totalCount || 0, [data]);
 
  useEffect(() => {
    // Fetch todos when the page, limit or filter changes
    refetch();
  }, [page, limit, searchName, refetch]);
 
  const handleTableChange = (pagination: TablePaginationConfig) => {
    router.push(
      pathname +
        addSearchParams([
          { name: "page", value: pagination.current?.toString() },
          { name: "limit", value: pagination.pageSize?.toString() },
          { name: "name", value: searchName },
        ])
    );
  };
 
  const handleShowModal = () => {
    setIsFormVisible(true);
 
    form.resetFields();
  };
 
  const handleCloseModal = () => {
    setIsFormVisible(false);
  };
 
 
  return (
    <div>
      <div className="mb-6 flex items-center justify-between">
        <Button
          type="primary"
          onClick={handleShowModal}
          size="large"
          icon={<PlusOutlined />}
        >
          Create Product
        </Button>
        <div>
          <Input.Search
            defaultValue={searchName}
            size="large"
            placeholder="Search Product..."
            onSearch={(value) =>
              router.push(
                pathname +
                  `?page=${DEFAULT_PAGE}&limit=${DEFAULT_LIMIT}&name=${value}`
              )
            }
          />
        </div>
      </div>
      <Table
        columns={columns}
        rowKey={(record) => record.id}
        dataSource={dataSource}
        pagination={{
          current: page,
          pageSize: limit,
          total: totalDataCount,
          showSizeChanger: true,
          pageSizeOptions: ["5", "10", "20", "50"],
          showTotal: (total, [num1, num2]) => {
            return `Showing: ${num1} to ${num2} from ${total} rows`;
          },
        }}
        loading={isFetching}
        onChange={handleTableChange}
      />
      <ProductForm
        form={form}
        isFormVisible={isFormVisible}
        handleCloseModal={handleCloseModal}
        refetch={refetch}
      />
    </div>
  );
};
 
export default Products;

Modifikasi column pada table agar image tidak terlalu besar:

src\app\dashboard\products\column.tsx
import { fallBackImage } from "@/constant";
import convertToRupiah from "@/utils/rupiah";
import { Product } from "@prisma/client";
import { Image } from "antd";
import { ColumnsType } from "antd/es/table";
 
export const columns: ColumnsType<Product> = [
  {
    title: "Product Name",
    render: (value) => value.name,
  },
  {
    title: "Description",
    render: (value) => value.description,
    width: "30%",
  },
  {
    title: "Category",
    render: (value) => value.category.name,
  },
  {
    title: "Price",
    render: (value) => convertToRupiah(value.price),
  },
  {
    title: "Qty",
    render: (value) => value.qty,
  },
  {
    title: "Image",
    render: (value) => (
      <Image
        width={60}
        height={40}
        src={value.image}
        style={{
          objectFit: "cover",
          borderRadius: 5,
        }}
        fallback={fallBackImage}
      />
    ),
  },
];

Jika sudah maka tampilan dari UI kita akan menjadi seperti berikut:

Products Page

Tambahkan component Product Form:

src\app\dashboard\products\form.tsx
"use client";
 
import {
  Modal,
  Form,
  Input,
  Button,
  FormInstance,
  Upload,
  Select,
  InputNumber,
} from "antd";
import { PlusOutlined } from "@ant-design/icons";
import useNotification from "@/hooks/useNotification";
import {
  ProductFields,
  useAddProductMutation,
} from "@/services/product";
import { useWatch } from "antd/es/form/Form";
import { useGetCategoriesQuery } from "@/services/category";
import { useMemo } from "react";
 
type FieldType = ProductField   s;
 
type Props = {
  form: FormInstance<FieldType>;
  isFormVisible: boolean;
  handleCloseModal: () => void;
  refetch: () => void;
};
 
const ProductForm = ({
  form,
  isFormVisible,
  handleCloseModal = () => {},
  refetch = () => {},
}: Props) => {
  const { openNotification } = useNotification();
  const { data } = useGetCategoriesQuery(null);
 
  const categories = useMemo(() => data?.data.categories || [], [data]);
 
  const [addProduct, { isLoading }] = useAddProductMutation();
 
  const imageField = useWatch("image", form);
 
  const onFinish = async (values: FieldType) => {
    try {
        await addProduct({ ...values, image: imageField }).unwrap();
 
        openNotification({
            type: "success",
            options: {
            message: `Product Created Succesfully`,
            },
        });
 
        form.resetFields();
        refetch();
        handleCloseModal();
    } catch (error) {
        console.log(error);
    }
  };
 
  return (
    <Modal
      title={`Create New Product`}
      open={isFormVisible}
      footer={() => <div />}
      onCancel={handleCloseModal}
    >
      <div className="mt-6" />
      <Form
        name="basic"
        initialValues={{ remember: true }}
        onFinish={onFinish}
        autoComplete="off"
        layout="vertical"
        form={form}
      >
        <div className="hidden">
          <Form.Item<FieldType> name="image">
            <Input type="hidden" />
          </Form.Item>
        </div>
 
        <Form.Item<FieldType>
          label="Name"
          name="name"
          rules={[
            {
              message: "Please input your Name!",
              min: 3,
              max: 50,
              required: true,
            },
          ]}
        >
          <Input placeholder="Input your Name" size="large" />
        </Form.Item>
 
        <Form.Item<FieldType>
          label="Description"
          name="description"
          rules={[
            {
              required: true,
              message: "Please input product description!",
            },
          ]}
        >
          <Input placeholder="Input product description" size="large" />
        </Form.Item>
 
        <Form.Item<FieldType>
          label="Category"
          name="categoryId"
          rules={[
            {
              required: true,
              message: "Please input product category!",
            },
          ]}
        >
          <Select placeholder="Select your Category" size="large" showSearch>
            {categories.map((category) => (
              <Select.Option key={category.id} value={category.id}>
                {category.name}
              </Select.Option>
            ))}
          </Select>
        </Form.Item>
 
        <Form.Item<FieldType>
          label="Price"
          name="price"
          rules={[
            {
              required: true,
              message: "Please input product price!",
            },
          ]}
        >
          <InputNumber
            formatter={(value) =>
              `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ".")
            }
            parser={(value) => value!.replace(/\./g, "")}
            placeholder="Input product price"
            size="large"
            style={{ width: "100%" }}
          />
        </Form.Item>
 
        <Form.Item<FieldType>
          label="Qty"
          name="qty"
          rules={[
            {
              required: true,
              message: "Please input product qty!",
            },
          ]}
        >
          <InputNumber
            formatter={(value) =>
              `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ".")
            }
            parser={(value) => value!.replace(/\./g, "")}
            placeholder="Input your Price"
            size="large"
            style={{ width: "100%" }}
          />
        </Form.Item>
 
        <Form.Item<FieldType> label="Image">
          <Upload
            accept="image/png, image/jpeg"
            listType="picture-card"
            maxCount={1}
            beforeUpload={(file) => {
              form.setFieldValue("image", file);
 
              return false;
            }}
            onPreview={() => false}
            onRemove={() => {
              form.setFieldValue("image", null);
            }}
            fileList={
              imageField
                ? [
                    {
                      uid: "local-image",
                      name: "local-image.png",
                      url:
                        typeof imageField === "string"
                          ? imageField
                          : URL.createObjectURL(imageField),
                    },
                  ]
                : []
            }
          >
            {!imageField && (
              <div>
                <PlusOutlined />
                <div style={{ marginTop: 8 }}>Upload</div>
              </div>
            )}
          </Upload>
        </Form.Item>
 
        <Form.Item>
          <Button
            type="primary"
            htmlType="submit"
            className="w-full mt-4"
            size="large"
            loading={isLoading}
          >
            Save Product
          </Button>
        </Form.Item>
      </Form>
    </Modal>
  );
};
 
export default ProductForm;

Pada code diatas kita menggunakan sebuah hook yaitu useNotification yang berguna untuk menghandle toast notification.

Tambahkan sebuah context baru dengan nama notificationContext:

src\context\notificationContext.tsx
"use client";
 
import { notification } from "antd";
import React, { createContext } from "react";
 
export const notificationContext = createContext<{
  openNotification: ({}: openNotificationParams) => void;
}>({
  openNotification: ({}) => {},
});
 
type NotificationType = "success" | "info" | "warning" | "error";
 
type openNotificationParams = {
  type: NotificationType;
  options: {
    message: string;
    description?: string;
  };
};
 
const NotificationContext = ({ children }: { children: React.ReactNode }) => {
  const [api, contextHolder] = notification.useNotification();
 
  const openNotification = ({
    type = "success",
    options,
  }: openNotificationParams) => {
    api[type]({
      message: options.message,
      description: options.description,
    });
  };
 
  return (
    <notificationContext.Provider
      value={{
        openNotification,
      }}
    >
      {children}
      {contextHolder}
    </notificationContext.Provider>
  );
};
 
export default NotificationContext;

Agar context dapat digunakan pada Layout Dashboard panggil context tersebut:

src\app\dashboard\layout.tsx
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { OPTIONS } from "../api/auth/[...nextauth]/route";
import Main from "./components/main";
import prisma from "@/lib/prisma";
import { userItems, items } from "./components/menu";
import NotificationContext from "@/context/notificationContext";
 
export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};
 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode,
}) {
  const session = await getServerSession(OPTIONS);
 
  const user = await prisma.user.findUnique({
    where: {
      email: session?.user?.email ?? "",
    },
    select: {
      id: true,
      name: true,
      email: true,
      image: true,
      role: true,
    },
  });
 
  //need to run this on server to make sure no one can change the role on frontend to see admin menu
  const menu = user?.role === "user" ? userItems : items;
 
  return (
    <NotificationContext>
      <Main user={user} menu={menu}>
        {children}
      </Main>
    </NotificationContext>
  );
}

Kemudian, buatlah notification hook:

src\hooks\useNotification.tsx
import { useContext } from "react";
 
import { notificationContext } from "@/context/notificationContext";
 
const useNotification = () => {
  const { openNotification } = useContext(notificationContext);
 
  return { openNotification };
};
 
export default useNotification;

Jika sudah berhasil klik tombol Create Product maka tampilannya akan seperti berikut:

Add Product

Kamu dapat menambahkan sebuah product baru seperti berikut:

Add Product 1

Klik simpan - maka product akan bertambah pada table products.

Product List

Selamat kamu sudah selesai membuat fungsi create product.

Pada tutorial berikutnya kita akan belajar bagaimana caranya menambahkan fungsi update dan delete product.

Code untuk tutorial kali ini link repo, jangan lupa star di repo ya!

Stay tune dan jangan lupa share & support Tahu Coding, Terima Kasih!