✎ Archive

E-commerce + Dashboard 개발 프로젝트 - 1

nerowiki 2024. 1. 20. 19:24
728x90
본 프로젝트는 https://www.codewithantonio.com/ 의 강의를 바탕으로 진행하였습니다.

 

온라인 쇼핑몰 웹사이트를 구현하는 사이드 프로젝트를 진행하였습니다.

해당 개발은 Admin 사이트와 Store 사이트로 구분하여 개발됩니다.

기본 구현은 해당 강의를 바탕으로 진행하며, 강의를 따라가면서 몰랐던 개념을 정리합니다.

그 밖에 에러 및 주요 서비스 문제 해결 과정은 개인 개발 블로그에 남겼습니다.   

 

Admin Page

초기 환경 설정

npx create-next-app@latest ecommerce-app --typescript --tailwind --eslint
npx shadcn-ui@latest init

 

Next.JS 13

Next.JS 공식 문서 : https://nextjs.org/docs

v13 App route 방식으로 구현

기존에 pages/ 디렉토리에서 라우팅 되던 방식과 다르게 Next 13 버전은 app/ 디렉토리에서 라우팅힙니다.

app/ 디렉토리에 기존 파일 시스템 라우팅을 구현할 수 있으며, page.tsx가 해당 경로의 페이지 컴포넌트입니다.

app 디렉토리 내부에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트로 동작합니다. 

만약 app directory 내부에서 클라이언트 컴포넌트를 사용하고 싶다면 파일 최상단에 아래의 directive를 명시합니다.

"use client";

 

Dashboard 구현

Shadcn/ui 공식 문서 : https://ui.shadcn.com/docs/components/button

 

shadcn-ui 의 button, dialog, form, input 기능을 추가하면

components/ui 디렉토리에 각각의 해당 컴포넌트 파일이 생성된 것을 확인할 수 있습니다.

npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add form
npx shadcn-ui@latest add input

 

1. Modal Components

components/ui 디렉토리에 Modal.tsx 를 생성합니다. 
"use client";

import { 
    Dialog, 
    DialogContent, 
    DialogDescription, 
    DialogHeader, 
    DialogTitle 
} from "@/components/ui/dialog";

interface ModalProps {
    title: string;
    description: string;
    isOpen: boolean;
    onClose: () => void;
    children?: React.ReactNode;
};

export const Modal: React.FC<ModalProps> = ({
    title,
    description,
    isOpen,
    onClose,
    children
}) => {
    const onChange = (open: boolean) => {
        if (!open) {
            onClose();
        }
    };

    return (
        <Dialog open={isOpen} onOpenChange={onChange}>
            <DialogContent>
                <DialogHeader>
                    <DialogTitle>{title}</DialogTitle>
                    <DialogDescription>
                        {description}
                    </DialogDescription>
                </DialogHeader>
                <div>
                    {children}
                </div>
            </DialogContent>
        </Dialog>
    )
}

 

상태 관리 라이브러리인 Zustand를 설치합니다. 
npm install zustand

 

hooks/use-store-modal.tsx 파일을 다음과 같이 생성합니다.
import { create } from "zustand";

interface useStoreModalStore {
    isOpen: boolean;
    onOpen: () => void;
    onClose: () => void;
};

export const useStoreModal = create<useStoreModalStore>((set) => ({
    isOpen: false,
    onOpen: () => set({ isOpen: true}),
    onClose: () => set({ isOpen: false }),
}));

 

components/modals/store-modal.tsx 파일을 다음과 같이 생성합니다.
"use client";

import { useStoreModal } from "@/hooks/use-store-modal";
import { Modal } from "@/components/ui/modal";

export const StoreModal = () => {
    const storeModal = useStoreModal();

    return(
        <Modal
            title = "가게 생성하기"
            description="제품과 카테고리를 관리하기 위한 새로운 스토어 추가하기"
            isOpen= {storeModal.isOpen}
            onClose={storeModal.onClose}
        >
            Future Create Store Form
        </Modal>
    );
}

 

providers/modal-provider.tsx 파일을 다음과 같이 생성합니다. 
"use client";

import { StoreModal } from "@/components/modals/store-modal";
import { useEffect, useState } from "react";

export const ModalProvider = () => {
    const [isMounted, setIsMounted] = useState(false);

    useEffect(() => {
        setIsMounted(true);
    }, []);


    if (!isMounted) {
        return null;
    }

    return (
        <>
            <StoreModal />
        </>
    );
};

 

마지막으로 app 디렉토리의 layout.tsx에 다음과 같이 ModalProvider를 렌더링합니다.
...
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className={inter.className}>
          <ModalProvider />
          {children}
        </body>
      </html>
    </ClerkProvider>
  );
}

 

app/(root)/page.tsx 를 다음과 같이 수정합니다.
"use client";

import { useEffect } from "react";
import { useStoreModal } from "@/hooks/use-store-modal";


const SetupPage = () => {
  const onOpen = useStoreModal((state) => state.onOpen);
  const isOpen = useStoreModal((state) => state.isOpen);

  useEffect(() => {
    if(!isOpen) {
      onOpen();
    }
  }, [onOpen, isOpen]);

  return (
    <div className="p-4">
      Root Page
    </div>
  );
}

export default SetupPage;

 

2. Form Components

modals/store-modal.tsx 파일을 다음과 같이 수정합니다.
"use client";

import * as z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { useStoreModal } from "@/hooks/use-store-modal";
import { Modal } from "@/components/ui/modal";
import { 
    Form, 
    FormControl, 
    FormField, 
    FormItem, 
    FormLabel, 
    FormMessage 
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";


const formSchema = z.object({
    name: z.string().min(1),
});

export const StoreModal = () => {
    const storeModal = useStoreModal();

    const form = useForm<z.infer<typeof formSchema>> ({
        resolver: zodResolver(formSchema),
        defaultValues: {
            name: "",
        },
    });

    const onSubmit = async (values: z.infer<typeof formSchema>) => {
        console.log(values);
        // TODO: Create Store
    }

    return(
        <Modal
            title = "가게 생성하기"
            description="제품과 카테고리를 관리하기 위한 새로운 스토어 추가하기"
            isOpen= {storeModal.isOpen}
            onClose={storeModal.onClose}
        >
            <div>
                <div className="space-y-4 py-2 pb-4">
                    <Form {...form}>
                        <form onSubmit={form.handleSubmit(onSubmit)}>
                            <FormField
                                control={form.control}
                                name="name"
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>이름</FormLabel>
                                        <FormControl>
                                            <Input placeholder="E-Commerce" {...field}/>
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />
                            <div className="pt-6 space-x-2 flex items-center justify-end w-full">
                                <Button 
                                    variant="outline"
                                    onClick={storeModal.onClose}>
                                    취소하기
                                </Button>
                                <Button type="submit">
                                    계속하기
                                </Button>
                            </div>
                        </form>
                    </Form>
                </div>
            </div>
        </Modal>
    );
}

 

 

성공적으로 작성했을 경우 다음과 같은 Form Components를 확인할 수 있습니다.

 

계속하기를 눌렀을 때 디버깅으로 통해 서버가 성공적으로 전달받고 있음을 확인하였습니다.

 

간편 로그인 구현

Clerk 공식 문서 : https://clerk.com/docs

 

.env 파일을 생성하고 Clerk Dashboard에서 제공하는 API Keys 데이터를 넣어줍니다.

(private key 이므로 gitignore에 추가하고 보안에 유의하기!)

npm install @clerk/nextjs

 

Clerk의 편리한 점 중 하나는 공식 문서에 App Router와 Pages Router 두 가지 예시를 제공한다는 것입니다.
아래 코드와 같이 app 디렉토리의 RootLayout을 ClerkProvider로 감싸줍니다.
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Admin Dashboard",
  description: "Admin Dashboard",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    </ClerkProvider>
  );
}

 

루트 디렉토리에 middleware.ts 파일을 추가합니다.
middleware.ts 파일은 클라이언트를 sign up 페이지로 리다이렉트해줍니다.
import { authMiddleware } from "@clerk/nextjs";
 
export default authMiddleware({});
 
export const config = {
  matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};

 

아래와 같은 파일 구조로 (auth) 디렉토리를 생성하여 Sign up, Sign-in 페이지를 만들어 줍니다.

 

.env 파일에 signIn, signUp, afterSignup, afterSignIn 경로를 추가해 줍니다.
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

 

정상적으로 설정이 완료되었다면 아래와 같은 화면을 확인할 수 있습니다.
URI를 통해 올바르게 redirect 되었다는 것을 확인합니다.
(auth) 디렉토리의 Authlayout을 생성해 로그인 화면이 중앙에 위치할 수 있도록 수정했습니다.

 

마지막으로 아래와 같이 (root) 디렉토리의 page.tsx 파일을 수정해 로그인 이후 유저 버튼을 만들어 줍니다.
import { UserButton } from "@clerk/nextjs";

const SetupPage = () => {
  return (
    <div className="p-4">
      <UserButton afterSignOutUrl="/" />
    </div>
  );
}

export default SetupPage;

 

저는 간편 로그인을 통해 구글로 Sign in 하였습니다. 아래와 같이 메인 화면에서 유저 버튼이 생성된 것을 확인할 수 있습니다.

 

Database 생성

planet scale 공식 문서 : https://planetscale.com/docs

 

먼저 터미널을 통해 다음 명령어로 prisma 를 설치합니다.
npm i -D prisma
npm install @prisma/client

 

아래 명령어를 통해 prisma 초기 설정을 진행하고 다음 순서에 따라 환경설정을 진행합니다.
$ npx prisma init
✔ Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database. More information in our documentation: https://pris.ly/d/getting-started

 

.env 파일에 다음과 같은 DATABASE_URL이 추가된 것을 확인할 수 있습니다. 
(이 URL은 이후 PlanetScale로 제공받은 DB 링크로 수정할 것입니다.)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY={NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
CLERK_SECRET_KEY={CLERK_SECRET_KEY}

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

# This was inserted by `prisma init`:
# 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="{DATABASE_URL}"

 

libs/prismadb.ts 파일을 다음과 같이 생성합니다.
이 코드의 목적은 전역으로 PrismaClient 인스턴스를 유지하고,
개발환경에서만 해당 인스턴스를 전역변수에 할당하는 것입니다.
import { PrismaClient } from "@prisma/client";

declare global {
    var prisma: PrismaClient | undefined
};

const prismadb = globalThis.prisma || new PrismaClient();
if(process.env.NODE_ENV !== 'production') globalThis.prisma = prismadb;

export default prismadb;

 

이제 PlanetScale 홈페이지에서 로그인 이후 'Create a new database'를 통해 DB 설정을 진행합니다.

 

PlanetScale 홈페이지에서 DB가 Initialize 되는동안 제공하는 .env 파일의 DATABASE_URL을
PlanetScale에서 제공받은 DB 링크로 수정합니다. 그리고 prisma/schema.prisma 파일을 다음과 같이 수정합니다.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
  relationMode = "prisma"
}

model Store {
  id        String  @id @default(uuid())
  name      String
  userId    String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

 

이후 다음 명령어를 통해 prisma를 generate 후 db push를 진행합니다.
정상적으로 진행되었다면 PlanetScale에 다음과 같은 Store 테이블이 생성됩니다.
npx prisma generate
npx prisma db push

 

 

app/api/stores/route.ts 파일을 다음과 같이 생성합니다.
import { NextResponse } from "next/server";
import { auth } from '@clerk/nextjs';
import prismadb from "@/lib/prismadb";

export async function POST (
    req: Request,
) {
    try {
        const { userId } = auth();
        const body = await req.json();

        const { name } = body;

        if (!userId) {
            return new NextResponse("Unauthorized", { status: 401});
        }

        if (!name) {
            return new NextResponse("Name is required", { status: 400 });
        }

        const store = await prismadb.store.create({
            data: {
                name,
                userId
            }
        });

        return NextResponse.json(store);
    } catch (error) {
        console.log('[STORES_POST]', error);
        return new NextResponse("Internal error", { status: 500 });
    }
}

 

components/modals/store-modal.tsx 파일을 다음과 같이 수정합니다.
...
export const StoreModal = () => {
	...
    const [loading, setLoading] = useState(false);
    ...
    return (
    	...
        <Input
        	disabled={loading}
            ...
        />
        ...
        <Button
        	disabled={loading}
            ...>
        	취소하기
        </Button>
        <Button
            disabled={loading}
            ...>
            계속하기
        </Button>
    )
}

 

npm i axios

 

components/modals/store-modal.tsx 파일에서 '계속하기' 버튼을 눌렀을 때 onSubmit 를 다음과 같이 수정합니다. 
axios로 서버에 데이터가 올바르게 POST 되는지 확인합니다.
...
export const StoreModal = () => {
	...
    const onSubmit = async (values: z.infer<typeof formSchema>) => {
        try {
            setLoading(true);

            const response = await axios.post('/api/stores', values);

            console.log(response.data);
        } catch (error) {
            console.log(error);
        } finally {
            setLoading(false);
        }
    }
    ...
}

 

다음과 같이 '계속하기'를 누르면 서버에 데이터가 전송되어 console 창에 올바르게 출력되는 것을 알 수 있습니다.

 

npm i react-hot-toast

 

providers/toast-provider.tsx 파일을 생성합니다.
"use client";

import { Toaster } from "react-hot-toast";

export const ToastProvider = () => {
    return <Toaster />;
};

 

app/layout.tsx 파일에 다음과 같이 추가합니다.
...
export default function RootLayout({
	...
    return (
    	<ClerkProvider>
        	...
            	<ToastProvider />
                <ModalProvider />
                {children}
            ...
        </ClerkProvider>
	);
}

 

components/modals/store-modal의 toast 코드를 수정합니다.
...


export const StoreModal = () => {
	...
	const onSubmit = async (values: z.infer<typeof formSchema>) => {
        try {
            setLoading(true);

            const response = await axios.post('/api/stores', values);

            toast.success("Store created.");
        } catch (error) {
            toast.error("Something went wrong.");
        } finally {
            setLoading(false);
        }
    }
    ...
}