개발일기

NextJs 로그인/회원가입 폼 만들기2 (feat. tailwind, shandcn) 본문

devCamp(NextJs)

NextJs 로그인/회원가입 폼 만들기2 (feat. tailwind, shandcn)

황대성 2024. 5. 23. 03:01

1. 다크모드 적용

@/components/theme-provider.tsx 파일 추가 하기

"use client"
 
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
 
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

 

@/components/mode-toggle.tsx 파일 추가 하기

"use client"
 
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
 
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
 
export function ModeToggle() {
  const { setTheme } = useTheme()
 
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

 

위 두개의 파일을 @/components 파일에 추가 한뒤 사용 해주면 된다. shadcn 공식문서에서는 App Router 방식의 설명을 해주고 있다. 나는 Page Router 방식을 사용하기 때문에 알맞게 사용해야 했다.

 

@/_app.tsx

import { ModeToggle } from "@/components/mode-toggle";
import { ThemeProvider } from "@/components/theme-provider";
import "@/styles/globals.css";
import type { AppProps } from "next/app";

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider attribute="class" defaultTheme="light">
      <div className="min-h-screen">
        <Component {...pageProps} />
      </div>
      <ModeToggle className={"absolute top-6 right-6"} />
    </ThemeProvider>
  );
}

 

shadcn 공식 문서에 나온 것 처럼 ThemeProvider로 감싸주고 ModeToggle을 import해서 사용해 주면 App 전체에 다크모드를 적용할 수 있다. 공식문서에는 DropdownMenu를 사용해서 다크모드를 선택할 수 있게 해두었지만, 나는 토글처럼 클릭시 다크모드 적용/다크모드 미적용으로 사용하고 싶어서 커스터마이징 해보았다.

 

@/components/mode-toggle.tsx

"use client";

import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";

import { Button } from "@/components/ui/button";

export function ModeToggle({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  const { theme, setTheme } = useTheme();
  
  const toggletheme = () => {
    if (theme === "light") {
      setTheme("dark");
    } else {
      setTheme("light");
    }
  };

  return (
    <div className={className} {...props}>
      <Button variant="outline" size="icon" onClick={toggletheme}>
        <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
        <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
        <span className="sr-only">Toggle theme</span>
      </Button>
    </div>
  );
}

 

정말 간단하게 버튼을 클릭하면 다크모드가 적용되고, 다시 클릭시 다크모드가 미적용되도록 구현했다.

 

 

2. components 사용

나는 shadcn에서 대표적으로 Card, Button, Form, Input 등을 사용했고, 사용법이 간단해서 금방 사용 가능했다.

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

return (
    <Card
      className={cn(
        "w-[350px] absolute -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2"
      )}
    >
      <CardHeader>
        <CardTitle className={cn("text-3xl")}>Sign In</CardTitle>
        <CardDescription>어서오세요, 로그인 해주세요.</CardDescription>
      </CardHeader>
      <CardContent>
        <Form {...form}>
          <form
            onSubmit={form.handleSubmit(onSubmit)}
            className="space-y-2 mb-4"
          >
          //...JSX code
           </form>
          </Form>
      </CardContent>
    </Card>

 

사용하고자 하는 component를 inport한 후 사용하면 된다. 하지만 shadcn에서 제공하는 Form에서는 react-hook-form과 zod를 함께 사용하기 때문에 양식이 생각보다 까다롭다. 간단하게 Form과 Input으로만 설명하겠다.

 

import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { registerSchema } from "@/validators/auth";
import { zodResolver } from "@hookform/resolvers/zod";

type RegisterInput = z.infer<typeof registerSchema>;

const SignIn = () => {

  const form = useForm<RegisterInput>({
    resolver: zodResolver(registerSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const onSubmit = (data: RegisterInput) => {
    alert(JSON.stringify(data, null, 2));
  };

return (
  <Form {...form}>
          <form
            onSubmit={form.handleSubmit(onSubmit)}
            className="space-y-2 mb-4"
          >
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormControl>
                    <Input placeholder="아이디" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
              <Button type="submit" size={"lg"}>로그인</Button>
            </form>
   </Form>
    )
 };
 
 export default SignIn;

 

가장 많이 사용하는 구조가 아닐까 한다. 하나하나 설명 해보겠다.

 

  const form = useForm<RegisterInput>({
    resolver: zodResolver(registerSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

 

유효성검사 및 초기값을 input의 초기값을 설정하고, form을 활용하여 여러 데이터를 관리할 수 있다.

유효성검사를 하는 registerSchema 로직은 따로 분리해 두었다. 코드는 이렇다.

 

import { z } from "zod";

const PASSWORD_REGEX = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$/;

export const registerSchema = z.object({
  email: z.string().email("이메일 형식을 입력해주세요."),
  password: z
    .string()
    .min(8, { message: "8자리 이상 입력해주세요." })
    .max(15, { message: "15자리 이하로 입력해주세요." })
    .refine(
      (value) => PASSWORD_REGEX.test(value),
      "영문, 숫자, 특수문자를 포함해야 합니다."
    ),
});

 

이메일은 string 타입으로 받으며, email형식이 아닐시에는 "이메일 형식을 입력해주세요."라는 문구가 나오도록 했다. 

비밀번호 역시 sting 타입으로 받고, 최소 8자리 이상, 15자리 이하의 비밀번호를 입력할 수 있고, 각각 오류 메세지를 반환한다. 또한 비밀번호 정규식을 사용하여 정규식에 어긋날 시 오류 메세지를 보여준다. 오류 메세지를 FormMessage를 통해 사용자에게 보여진다.

 

            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormControl>
                    <Input placeholder="아이디" {...field} />
                  </FormControl>
                  <FormMessage /> //유효성 검사 미통과시 오류 메세지 반환
                </FormItem>
              )}
            />

 

  const onSubmit = (data: RegisterInput) => {
    alert(JSON.stringify(data, null, 2));
  };

 

모든 유효성 검사를 통과하고 버튼 클릭시 실행되는 부분이다. 나는 alert로 Input에 입력한 값이 보여지도록 했다.

 

3. Next page 만들기

Framer Motion을 사용해서 회원가입 입력 양식을 단계로 나누어서 진행했다. 코드를 살펴보자.

importing

yarn add framer-motion

 

usage

import { motion } from "framer-motion";

const SignUp = () => {

const [step, setStep] = useState<number>(0);

return (
         <motion.div
                className={cn("space-y-2  p-1")}
                animate={{ translateX: `${step * -100}%` }}
                transition={{
                  ease: "easeInOut",
                }}
            >
                //...JSX Code
            </motion.div>
          <motion.div
                className={cn("space-y-2 absolute top-0 left-0 right-0  p-1")}
                animate={{ translateX: `${(1 - step) * 100}%` }}
                transition={{
                  ease: "easeInOut",
                }}
             >
                //...JSX Code
           </motion.div>
           <div className="flex justify-end gap-2">
                <Button
                  type="button"
                  variant="outline"
                  size={"lg"}
                  className={cn({ hidden: step === 1 })}
                  onClick={() => router.push("/")}
                >
                  Home
                </Button>
                <Button
                  type="button"
                  size={"lg"}
                  className={cn({ hidden: step === 1 })}
                  onClick={validateAndMoveToNextStep}
                >
                  Next
                </Button>
                <div className="flex justify-end gap-2">
                  <Button type="submit" className={cn({ hidden: step === 0 })}>
                    계정 등록하기
                  </Button>
                  <Button
                    type="button"
                    className={cn({ hidden: step === 0 })}
                    onClick={() => setStep(0)}
                  >
                    Prev
                  </Button>
              </div>
        )
}

 

motion을 import한 후 motion.div와 같이 모션을 줄 부분에 알맞은 속성을 사용하면 된다. step과 animate의 translateX 속성을 활용하여 next, prev 버튼을 클릭시 다음페이지, 이전페이지로 넘어갈 수 있도록 구현 했다. 또한 페이지에 맞는 버튼이 표시될 수 있도록 hidden속성과 step을 활용하였다.

validator

const validateAndMoveToNextStep = () => {
    form.trigger(["email", "lastName", "firstName", "phone", "gender"]);

    const emailState = form.getFieldState("email");
    const lastNameState = form.getFieldState("lastName");
    const firstNameState = form.getFieldState("firstName");
    const phoneState = form.getFieldState("phone");
    const genderState = form.getFieldState("gender");

    if (!emailState.isDirty || emailState.invalid) return;
    if (!lastNameState.isDirty || lastNameState.invalid) return;
    if (!firstNameState.isDirty || firstNameState.invalid) return;
    if (!phoneState.isDirty || phoneState.invalid) return;
    if (!genderState.isDirty || genderState.invalid) return;

    setStep(1);
  };

trigger는 React Hook Form에서 제공하는 함수 중 하나. 이 함수를 사용하면, 특정 필드나 전체 폼에 대한 유효성 검사를 수행할 수 있다. getFieldState는 현재의 인풋의 상태를 가져올 수 있다.isDirty는 인풋필드에 값이 있을 때를 나타낸다. 즉, clean한 상태의 반대. invalid는 유효성을 체크한다. 위 조건이 맞지 않다면 다음 페이지로 넘어가지 않도록 구현했다.

 

마무리

shadcn을 사용하면서 너무 쉽게 코드를 작성하고, 스타일링 할 수 있어서 쉽게만 생각 했지만, 계속 사용해보면서 응용하고, 발전 시키려고 하면서 점점 라이브러리의 한계를 느끼는 순간이 오기도 했고, 어쩌면 내가 이 라이브러리를 잘 활용하고 있지 못한다는 느낌을 받고 있을 때도 있었다. 하지만 확실한 건 익숙해 질수록 개발 비용을 단축 시킬 수 있다는 느낌을 받았으며, 또 하나의 지식을 얻을 수 있었던 것 같다. 다음은 Nextjs와 shadcn, 토스페이먼츠를 활용한 결제 페이지를 작성해 보려고 한다.

추가로 vercel로 배포까지 했으니 사용하고 싶은 사람은 아래 링크로 들어가서 사용해보면 된다.

링크 : https://signform.vercel.app