[Projects/회원가입연습] 답안코드 뼈 발라 먹기
답안코드: https://github.com/DevCamp-TeamSparta/frontend-material?tab=readme-ov-file
폴더 구성
📦src
┣ 📂components
┃ ┣ 📂ui
┃ ┃ ┣ 📜button.tsx
┃ ┃ ┣ 📜card.tsx
┃ ┃ ┣ 📜dropdown-menu.tsx
┃ ┃ ┣ 📜form.tsx
┃ ┃ ┣ 📜input.tsx
┃ ┃ ┣ 📜label.tsx
┃ ┃ ┣ 📜select.tsx
┃ ┃ ┣ 📜switch.tsx
┃ ┃ ┣ 📜toast.tsx
┃ ┃ ┣ 📜toaster.tsx
┃ ┃ ┗ 📜use-toast.ts
┃ ┣ 📜mode-toggle.tsx
┃ ┗ 📜theme-provider.tsx
┣ 📂lib
┃ ┗ 📜utils.ts
┣ 📂pages
┃ ┣ 📂api
┃ ┃ ┗ 📜hello.ts
┃ ┣ 📜_app.tsx
┃ ┣ 📜_document.tsx
┃ ┗ 📜index.tsx
┣ 📂styles
┃ ┗ 📜globals.css
┗ 📂validators
┃ ┗ 📜auth.ts
index.tsx 뼈 발라먹기
import
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useToast } from "@/components/ui/use-toast"; import { motion } from "framer-motion"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { registerSchema } from "@/validators/auth"; import { z } from "zod"; import { useState } from "react"; import { ArrowRight } from "lucide-react";
components/ui 폴더에 있는 것들은 schad/ui를 활용한 것으로 터미널에서 설치를하면 자동으로 components/ui 폴더에 컴포넌트 형태로 만들어진다.
motion: 카드 변경 시 표현되는 에니메이션을 위한 라이브러리
cn: 카드가 변할 때 버튼이 같이 변하도록 추가 클래스 설정을 위한 component
useForm, zodResolver: 유효성 검사를 위한 라이브러리
zod: 데이터타입 설정
useState: 상태관리(step)
ArrowRight: Icon
type RegisterInput = z.infer<typeof registerSchema>;
RegisterInput의 type을 registerSchema로 할당.
registerSchema는 validators/auth.ts에 작성되어 있다.
//auth.ts
import { z } from "zod";
const passwordRegex =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
const phoneRegex = /^010\d{8}$/;
export const registerSchema = z.object({
email: z.string().email({ message: "올바른 이메일을 입력해주세요." }),
phone: z
.string()
.min(11, "연락처는 11자리여야 합니다.")
.max(11, "연락처는 11자리여야 합니다.")
.refine(
(value) => phoneRegex.test(value),
"010으로 시작하는 11자리 숫자를 입력해주세요",
),
username: z
.string()
.min(2, { message: "이름은 2글자 이상이어야 합니다." })
.max(100, { message: "이름은 100글자 이하이어야 합니다." }),
role: z.string().min(2, { message: "역할을 선택해주세요." }),
password: z
.string()
.min(6, "비밀번호는 최소 6자리 이상이어야 합니다.")
.max(100, "비밀번호는 100자리 이하이어야 합니다.")
.refine(
(value) => passwordRegex.test(value),
"비밀번호는 최소 6자리 이상, 영문, 숫자, 특수문자를 포함해야 합니다.",
),
confirmPassword: z
.string()
.min(6, "비밀번호는 최소 6자리 이상이어야 합니다.")
.max(100, "비밀번호는 100자리 이하이어야 합니다.")
.refine(
(value) => passwordRegex.test(value),
"비밀번호는 최소 6자리 이상, 영문, 숫자, 특수문자를 포함해야 합니다.",
),
});
zod에 유효성검사 조건과 멘트를 설정해줌
export default function Home() {
const [step, setStep] = useState<number>(0);
const { toast } = useToast();
const form = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
defaultValues: {
phone: "",
email: "",
role: "",
username: "",
password: "",
confirmPassword: "",
},
});
// log the form data whenever it changes
console.log(form.watch());
function onSubmit(data: RegisterInput) {
const { password, confirmPassword } = data;
if (password !== confirmPassword) {
toast({
title: "비밀번호가 일치하지 않습니다.",
variant: "destructive",
duration: 1000,
});
return;
}
alert(JSON.stringify(data, null, 4));
}
Home() 함수 살펴보기
- [step, setStep]을 초기값이 0인 number type의 상태로 설정
- useToast() 훅 사용 시, {useToast, toast}가 반환되는데, 그 중 {toast}를 추출
toast는 함수로, 메시지를 update하거나 내용을 추가할 수 있음 - useForm 훅을 사용하여, Input 값들의 dafaultValue를 설정함.
resolver는 유효성 검사를 위해 Zod스키마를 사용한다. - .watch()는 해당 필드의 값이 변경될 때마다 새로운 값을 반환함
- function onSubmit
RegisterInput 타입의 data를 입력받아, 해당 data 중, password, confirmPassword를 추출한다.
두 값이 다르면, toast함수를 실행하여 title을 duration의 기간동안 노출한다. variant는 destructive와 default 2가지가 있다.
password와 confirmPassword가 같으면 return을 하며, toast를 띄우지 않는다.
alert창에 Input값 정보들(data)를 나타낸다. null은 별도의 필터링을 하지 않음을 의미하고, 4는 alert 창의 공백을 의미한다.
return (
<div className='absolute -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2'>
<Card className={cn("w-[380px]")}>
<CardHeader>
<CardTitle>계정을 생성합니다</CardTitle>
<CardDescription>필수 정보를 입력헤볼게요.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='relative space-y-3 overflow-x-hidden'
>
<motion.div
className={cn("space-y-3")}
animate={{ translateX: `${step * -100}%` }}
transition={{ ease: "easeInOut" }}
>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>이름</FormLabel>
<FormControl>
<Input placeholder='홍길동' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>이메일</FormLabel>
<FormControl>
<Input
placeholder='hello@sparta-devcamp.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='phone'
render={({ field }) => (
<FormItem>
<FormLabel>연락처</FormLabel>
<FormControl>
<Input placeholder='01000000000' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='role'
render={({ field }) => (
<FormItem>
<FormLabel>역할</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='역할을 선택해주세요' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='admin'>관리자</SelectItem>
<SelectItem value='user'>일반사용자</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</motion.div>
<motion.div
className={cn("space-y-3 absolute top-0 left-0 right-0")}
animate={{ translateX: `${(1 - step) * 100}%` }}
style={{ translateX: `${(1 - step) * 100}%` }}
transition={{
ease: "easeInOut",
}}
>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>비밀번호</FormLabel>
<FormControl>
<Input type={"password"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>비밀번호 확인</FormLabel>
<FormControl>
<Input type={"password"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</motion.div>
<div className={"flex gap-2"}>
<Button className={cn({ hidden: step === 0 })} type='submit'>
계정 등록하기
</Button>
<Button
type='button'
className={cn({ hidden: step === 1 })}
onClick={() => {
form.trigger(["phone", "email", "username", "role"]);
const phoneState = form.getFieldState("phone");
const emailState = form.getFieldState("email");
const usernameState = form.getFieldState("username");
const roleState = form.getFieldState("role");
if (!phoneState.isDirty || phoneState.invalid) return;
if (!emailState.isDirty || emailState.invalid) return;
if (!usernameState.isDirty || usernameState.invalid) return;
if (!roleState.isDirty || roleState.invalid) return;
setStep(1);
}}
>
다음 단계로
<ArrowRight className='w-4 h-4 ml-2' />
</Button>
<Button
type='button'
variant={"ghost"}
className={cn({ hidden: step === 0 })}
onClick={() => {
setStep(0);
}}
>
이전 단계로
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
- 랜더링 파트
전체 코드를 감싸는 div tag absolute 뒷 부분은 화면의 정가운데 절대 위치를 나타냄