2023.06.11 - [Project/Airbnb Clone] - [Airbnb 클론코딩] 환경설정 및 Navbar UI(With. Youtube)
[Airbnb 클론코딩] 환경설정 및 Navbar UI(With. Youtube)
유튜브를 보던 중에 Airbnb 클론코딩을 하는 것을 보았다. 그래서 Next.js를 찍먹해보려고 한다. 환경설정 해당 영상에서는 Next.js 13, React, Tailwind, Prisma, MongoDB를 사용한다. 먼저 Next 프로젝트 폴더를
hu-bris.tistory.com
Auth UI
에어비앤비의 로그인과 회원가입 페이지는 Modal 창을 이용해서 구현되어 있다.
그래서 모달 창을 만들어주기 위해 Modal 컴포넌트를 만들어준다.
'use client';
import { useCallback, useEffect, useState } from "react";
import {IoMdClose} from 'react-icons/io'
import Button from "../Button";
interface ModalProps {
isOpen?: boolean;
onClose: () => void;
onSubmit: () => void;
title?: string;
body?: React.ReactElement;
footer?: React.ReactElement;
actionLabel: string;
disabled?: boolean;
secondaryAction?: () => void;
secondaryActionLabel?: string;
}
const Modal: React.FC<ModalProps> = ({
isOpen, // 열림 유무
onClose, // Modal 창 닫힘
onSubmit, // 제출
title, // 폼 제목
body, // 폼 내용
footer, // 폼 풋터
actionLabel, // 버튼 라벨
disabled,
secondaryAction, // 보조 버튼
secondaryActionLabel // 보조 버튼 라벨
}) => {
const [showModal, setShowModal] = useState(isOpen)
useEffect(() => { // 컴포넌트가 렌더링될 때마다 특정 동작을 수행하도록하는 hook.
setShowModal(isOpen);
}, [isOpen]) // isOpen이 변경될 때마다 수행함.
const handleClose = useCallback(() => { // 함수를 메모리제이션하여 함수를 재사용
if (disabled) {
return
}
setShowModal(false)
setTimeout(() => {
onClose()
}, 300)
}, [disabled, onClose]) // disabled와 onClose를 의존함
const handleSubmit = useCallback(() => {
if (disabled) {
return
}
onSubmit()
}, [disabled, onSubmit])
const handleSecondaryAction = useCallback(() => {
if (disabled || !secondaryAction) {
return
}
secondaryAction()
}, [disabled, secondaryAction])
if (!isOpen) {
return null;
}
return (
<>
<div
className="
justify-center
items-center
flex
overflow-x-hidden
overflow-y-auto
fixed
inset-0
z-50
outline-none
focus:outline-none
bg-neutral-800/70
"
>
<div className="
relative
w-full
md:w-4/6
lg:w-3/6
xl:w-2/5
my-6
mx-auto
h-full
lg:h-auto
md:h-auto
"
>
{/*content*/}
<div className={`
translate
duration-300
h-full
${showModal ? 'translate-y-0' : 'translate-y-full'}
${showModal ? 'opacity-100' : 'opacity-0'}
`}>
<div className="
translate
h-full
lg:h-auto
md:h-auto
border-0
rounded-lg
shadow-lg
relative
flex
flex-col
w-full
bg-white
outline-none
focus:outline-none
"
>
{/*header*/}
<div className="
flex
items-center
p-6
rounded-t
justify-center
relative
border-b-[1px]
"
>
<button
className="
p-1
border-0
hover:opacity-70
transition
absolute
left-9
"
onClick={handleClose}
>
<IoMdClose size={18} />
</button>
<div className="text-lg font-semibold">
{title}
</div>
</div>
{/*body*/}
<div className="relative p-6 flex-auto">
{body}
</div>
{/*footer*/}
<div className="flex flex-col gap-2 p-6">
<div
className="
flex
flex-row
items-center
gap-4
w-full
"
>
{secondaryAction && secondaryActionLabel && (
<Button
disabled={disabled}
label={secondaryActionLabel}
onClick={handleSecondaryAction}
outline
/>
)}
<Button
disabled={disabled}
label={actionLabel}
onClick={handleSubmit}
/>
</div>
{footer}
</div>
</div>
</div>
</div>
</div>
</>
);
};
export default Modal;
이렇게 하게 되면 모달 창의 전체적인 틀이 만들어지게 된다.
버튼 폼을 컴포넌트로 규정해놓는다.
Button Component
'use client';
import { IconType } from 'react-icons';
interface ButtonProps {
label: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
outline?: boolean;
small?: boolean;
icon?: IconType;
}
const Button: React.FC<ButtonProps> = ({
label,
onClick,
disabled,
outline,
small,
icon: Icon
}) => {
return (
<button
disabled={disabled}
onClick={onClick}
className={`
relative
disabled:opacity-70
disabled:cursor-not-allowed
rounded-lg
hover:opacity-80
transition
w-full
${outline ? 'bg-white' : 'bg-rose-500'}
${outline ? 'border-black' : 'border-rose-500'}
${outline ? 'text-black' : 'text-white'}
${small ? 'text-sm' : 'text-md'}
${small ? 'py-1' : 'py-3'}
${small ? 'font-light' : 'font-semibold'}
${small ? 'border-[1px]' : 'border-2'}
`}
>
{Icon && (
<Icon
size={24}
className="
absolute
left-4
top-3
"
/>
)}
{label}
</button>
);
};
export default Button;
버튼의 아이콘이 있으면 아이콘을 넣어주게 되고 라벨을 넣어주게 된다. 버튼의 Props로 small이 true이면 작은 버전의 버튼을 만들 수 있도록 한다.
그러면 이제 로그인과 회원가입 폼을 만들어야하는데 로그인과 회원가입을 비슷하므로 회원가입 폼만 하겠다.
Register Form
회원가입 폼을 만들기 전에 필요한 hook을 커스텀한다.
Hook이란 함수형 컴포넌트에서도 클래스형 컴포넌트의 기능을 사용할 수 있게 하는 기능이다.
useRegisterModal
폴더 구조는 아래와 같다.
📦app
┣ 📂components
┃ ┣ 📂inputs
┃ ┃ ┗ 📜Input.tsx
┃ ┣ 📂modals
┃ ┃ ┣ 📜Modal.tsx
┃ ┃ ┗ 📜RegisterModal.tsx
┃ ┣ 📂navbar
┃ ┃ ┣ 📜Logo.tsx
┃ ┃ ┣ 📜MenuItem.tsx
┃ ┃ ┣ 📜Navbar.tsx
┃ ┃ ┣ 📜Search.tsx
┃ ┃ ┗ 📜UserMenu.tsx
┃ ┣ 📜Avatar.tsx
┃ ┣ 📜Button.tsx
┃ ┣ 📜Container.tsx
┃ ┗ 📜Heading.tsx
┣ 📂hooks
┃ ┗ 📜useRegisterModal.ts
┣ 📂providers
┃ ┗ 📜ToasterProvider.tsx
┣ 📜favicon.ico
┣ 📜globals.css
┣ 📜layout.tsx
┗ 📜page.tsx
이전에 필요한 라이브러리를 설치해야한다.
zustand
zustand는 독일어로 '상태'라는 뜻으로 상태 관리 라이브러리이다.
스토어를 만들 때는 create 함수를 이용하여 상태와 그 상태를 변경한다.
이거는 추후에 자세히 사용해 볼 일이 생기면 얘기해보도록 하겠다.
import { create } from "zustand";
interface RegisterModalStore {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
const useRegisterModal = create<RegisterModalStore>((set) => ({
isOpen: false,
onOpen: () => set({ isOpen: true }),
onClose: () => set({ isOpen: false }),
}))
export default useRegisterModal;
이렇게 해서 onOpen 함수는 isOpen을 true로 변경하고 onClose 함수는 isOpen을 false로 변경하게 한다.
isOpen이 true가 되어 있으면 Modal 컴포넌트에서 showModal을 통해 모달 창이 켜져있는 것이 디폴트가 되기 때문에 false로 해놓는다.
이제 회원가입 모달 폼을 만들어보자.
RegisterModal
이번에도 필요한 라이브러리가 있다. 서버와 통신할 axios와 알림을 띄울 toast 그리고 form의 유효성 검증을 도와주는 react-hook-form을 설치해야한다.
npm install axios react-hot-toast react-hook-form
커맨드를 입력해 라이브러리를 설치하면 된다.
'use client';
import axios from 'axios';
import { AiFillGithub } from 'react-icons/ai';
import { FcGoogle } from 'react-icons/fc';
import {
FieldValues,
SubmitHandler,
useForm
} from 'react-hook-form';
import useRegisterModal from '@/app/hooks/useRegisterModal';
import Modal from './Modal';
import { useState } from 'react';
import Heading from '../Heading';
import Input from '../inputs/Input';
import { toast } from 'react-hot-toast';
import Button from '../Button';
const RegisterModal = () => {
const registerModal = useRegisterModal()
const [isLoading, setIsLoading] = useState(false) // 로딩하는 중일 때 버튼 비활성화를 위해 사용
const {
register, // 회원가입 유효성 검증
handleSubmit, // 제출 유효성 검증
formState: { // form의 상태에 대한 정보가 포함되어 있다.
errors,
}
} = useForm<FieldValues>({
defaultValues: { // form의 디폴트 값을 빈 값으로 설정
name: '',
email: '',
password: ''
}
})
const onSubmit: SubmitHandler<FieldValues> = (data) => {
setIsLoading(true)
axios.post('/api/register', data)
.then(() => {
registerModal.onClose()
})
.catch((error) => {
toast.error('Error')
})
.finally(() => {
setIsLoading(false)
})
}
const bodyContent = (
<div className="flex flex-col gap-4">
<Heading
title="Welcome to Airbnb"
subtitle="Create an account!"
/>
<Input
id="email"
label="Email"
disabled={isLoading}
register={register}
errors={errors}
required
/>
<Input
id="name"
label="Name"
disabled={isLoading}
register={register}
errors={errors}
required
/>
<Input
id="password"
type="password"
label="Password"
disabled={isLoading}
register={register}
errors={errors}
required
/>
</div>
)
const footerContent = (
<div className="flex flex-col gap-4 mt-3">
<hr />
<Button
outline
label="Continue with Google"
icon={FcGoogle}
onClick={() => {}}
/>
<Button
outline
label="Continue with Github"
icon={AiFillGithub}
onClick={() => {}}
/>
<div
className="
text-neutral-500
text-center
mt-4
font-light
"
>
<div className="justify-center flex flex-row items-center gap-2">
<div>
Already have an account?
</div>
<div
onClick={registerModal.onClose}
className="
text-neutral-800
cursor-pointer
hover:underline
"
>
Log in
</div>
</div>
</div>
</div>
)
return (
<Modal
disabled={isLoading}
isOpen={registerModal.isOpen}
title="Sign up"
body={bodyContent}
footer={footerContent}
actionLabel="Continue"
onClose={registerModal.onClose} // isOpen이 false로 변경되면서 모달 창 닫힘
onSubmit={handleSubmit(onSubmit)}
/>
)
}
export default RegisterModal;
간다하게 주석을 달아놨기 때문에 따로 설명을 하지 않아도 될 것 같아 하지 않겠다.
RegisterModal을 보면 Heading과 Input을 컴포넌트로 만들어놨다.
Heading
'use client';
interface HeadingProps {
title: string;
subtitle?: string;
center?: boolean;
}
const Heading: React.FC<HeadingProps> = ({
title,
subtitle,
center
}) => {
return (
<div className={center ? 'text-center' : 'text-start'}>
<div className="text-2xl font-bold">
{title}
</div>
<div className="font-light text-neutral-500 mt-2">
{subtitle}
</div>
</div>
);
};
export default Heading;
그냥 제목과 부제를 받는 컴포넌트이다.
Input
'use client';
import { FieldErrors, FieldValues, UseFormRegister } from "react-hook-form";
import { BiDollar } from "react-icons/bi";
interface InputProps {
id: string;
label: string;
type?: string;
disabled?: boolean;
formatPrice?: boolean;
required?: boolean;
register: UseFormRegister<FieldValues>;
errors: FieldErrors
}
const Input: React.FC<InputProps> = ({
id,
label,
type = "text",
disabled,
formatPrice,
required,
register,
errors
}) => {
return (
<div className="w-full relative">
{formatPrice && (
<BiDollar
size={24}
className="
text-neutral-700
absolut
top-5
left-2
"
/>
)}
<input
id={id}
disabled={disabled}
{...register(id, { required })}
placeholder=" "
type={type}
className={`
peer
w-full
p-4
pt-6
font-light
bg-white
rounded-md
border-2
outline-none
transition
disabled:opacity-70
disabled:cursor-not-allowed
${formatPrice ? 'pl-9' : 'pl-4'}
${errors[id] ? 'border-rose-500' : 'border-neutral-300'}
${errors[id] ? 'focus:border-rose-500' : 'focus:border-black'}
`}
/>
<label
className={`
absolute
text-md
duration-150
transform
-translate-y-3
top-5
z-10
origin-[0]
${formatPrice ? 'left-9' : 'left-4'}
peer-placeholder-shown:scale-100
peer-placeholder-shown:translate-y-0
peer-focus:scale-75
peer-focus:-translate-y-4
${errors[id] ? 'text-rose-500' : 'text-zinc-400'}
`}
>
{label}
</label>
</div>
);
};
export default Input;
ToasterProvider
영상에서는 ToasterProvider 파일을 만들어 사용한다. 에러가 발생한다고..
하지만 나는 그런 에러가 나지 않아 그냥 Toaster를 사용했다.
그리고 toast는 회원가입을 할 때, 입력 값이 이상하여 데이터가 정상적으로 서버에 보내지지 않을 때 알림을 띄울 수 있도록 하기 위해 사용했다.
마무리
로그인 페이지는 회원가입 로직을 다 구현하고나면 진행할 것 같다. 이제 DB를 MongoDB와 Prisma를 이용하여 셋업할 것이다.
'Project > Airbnb Clone' 카테고리의 다른 글
[Airbnb 클론코딩] Fetching listings whith server components (0) | 2023.08.07 |
---|---|
[Airbnb 클론코딩] Listing creation (0) | 2023.07.11 |
[Airbnb 클론코딩] Category UI (0) | 2023.07.02 |
[Airbnb 클론코딩] Register & Login functionality (0) | 2023.06.25 |
[Airbnb 클론코딩] 환경설정 및 Navbar UI(With. Youtube) (0) | 2023.06.11 |