비밀번호 초기화
api/auth.api.ts
비밀번호 초기화와 회원가입에 관련된 api를 정의한다.
import { httpClient } from "./http";
import { SignupProps } from "../pages/Signup";
export const resetRequest = async (data: SignupProps) => {
const response = await httpClient.post('/users/reset', data);
return response.data;
}
export const resetPassword = async (data: SignupProps) => {
const response = await httpClient.put('/users/reset', data);
return response.data;
}
pages/ResetPassword.tsx
비밀번호 초기화를 위해 이메일을 입력하는 화면인지 실제로 비밀번호를 초기화하는 화면인지에 따라 요청을 보낸다.
비밀번호를 초기화하면 로그인 페이지로 리다이렉트 한다.
function ResetPassword() {
const navigate = useNavigate();
const showAlert = useAlert();
const [resetRequested, setResetRequested] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
if (resetRequested) {
// 초기화
resetPassword(data).then(() => {
showAlert('비밀번호가 초기화 되었습니다.');
navigate('/login');
})
} else {
// 요청
resetRequest(data).then(() => {
setResetRequested(true);
});
}
}
...
}
회원가입
api/auth.api.ts
아래 함수를 사용하여 회원가입 요청을 보낸다.
export const signup = async (userData: SignupProps) => {
const response = await httpClient.post('/users/join', userData);
return response.data;
}
pages/Signup.tsx
회원 가입을 완료하면 로그인 페이지로 리다이렉트 한다.
...
export interface SignupProps {
email: string;
password: string;
}
function Signup() {
const navigate = useNavigate();
const showAlert = useAlert();
const { register, handleSubmit, formState: { errors } } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
signup(data).then((res) => {
showAlert('회원가입 완료');
navigate('/login');
})
}
...
}
...
로그인
로그인 상태를 전역에서 관리하기 위해 zustand 패키지를 설치한다.
npm i zustand --save
store/authStore.ts
토큰으로 로그인 여부를 판단하는 코드를 작성한다.
백엔드에서 body에도 토큰을 보내주도록 수정했다.
import { create } from "zustand";
interface StoreState {
isLoggedIn: boolean;
storeLogin: (token: string) => void;
storeLogout: () => void;
}
export const getToken = () => {
const token = localStorage.getItem('token');
return token;
};
const setToken = (token: string) => {
localStorage.setItem('token', token);
}
export const removeToken = () => {
localStorage.removeItem('token');
}
export const useAuthStore = create<StoreState>((set) => ({
isLoggedIn: getToken() ? true : false, // 초기값
storeLogin: (token: string) => {
set({ isLoggedIn: true });
setToken(token);
},
storeLogout: () => {
set({ isLoggedIn: false });
removeToken();
}
}))
api/auth.api.ts
해당 파일에서 login 함수를 정의하여 로그인 요청을 보낸다.
interface LoginResponse {
token: string;
}
export const login = async (data: SignupProps) => {
const response = await httpClient.post<LoginResponse>('/users/login', data);
return response.data;
}
pages/Login.tsx
로그인에 성공한 경우 현재 로그인 여부를 true로 설정하고, 응답으로 받은 토큰을 로컬 스토리지에 저장한다.
...
export interface SignupProps {
email: string;
password: string;
}
function Login() {
const navigate = useNavigate();
const showAlert = useAlert();
const { storeLogin } = useAuthStore();
const { register, handleSubmit, formState: { errors } } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
login(data).then((res) => {
// 상태 변환
storeLogin(res.token);
showAlert('로그인 완료되었습니다.');
navigate('/');
}, (error) => {
showAlert('로그인이 실패했습니다.');
});
};
...
}
export default Login;
도서 목록 페이지
도서 목록 페이지는 다음과 같은 구조를 가진다.
<Books>
<BookFilter />
<BooksViewSwitcher />
<BookList>
<BookItem />
...
<BookItem />
</BookList>
<BookEmpty />
<Pagination />
</Books>
hooks/useBooks.ts
도서 목록을 얻어올 때 사용할 훅을 정의한다.
import { useLocation } from "react-router-dom"
import { Book } from "../models/book.model";
import { useEffect, useState } from "react";
import { Pagination } from "../models/pagination.model";
import { fetchBooks } from "../api/books.api";
import { QUERYSTRING } from "../constants/querystring";
import { LIMIT } from "../constants/pagination";
export const useBooks = () => {
const location = useLocation();
const [books, setBooks] = useState<Book[]>([]);
const [pagination, setPagination] = useState<Pagination>({
totalCount: 0,
currentPage: 1
});
const [isEmpty, setIsEmpty] = useState(true);
useEffect(() => {
const params = new URLSearchParams(location.search);
fetchBooks({
category_id: params.get(QUERYSTRING.CATEGORY_ID) ?
Number(params.get(QUERYSTRING.CATEGORY_ID)) : undefined,
news: params.get(QUERYSTRING.NEWS) ? true : undefined,
current_page: params.get(QUERYSTRING.PAGE) ?
Number(params.get(QUERYSTRING.PAGE)) : 1,
limit: LIMIT,
}).then(({ books, pagination }) => {
setBooks(books);
setPagination(pagination);
setIsEmpty(books.length === 0);
})
}, [location.search]);
return { books, pagination, isEmpty };
}
pages/Books.tsx
...
function Books() {
const { books, pagination, isEmpty } = useBooks();
return (
<>
<Title size='large'>도서 검색 결과</Title>
<BooksStyle>
<div className='filter'>
<BooksFilter />
<BooksViewSwitcher />
</div>
{
!isEmpty
?
<BooksList books={books} />
:
<BooksEmpty />
}
{
!isEmpty && <Pagination pagination={pagination} />
}
</BooksStyle>
</>
)
}
...
components/books/BooksFilter.tsx
import styled from 'styled-components';
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
function BooksFilter() {
const { category } = useCategory();
const [searchParams, setSearchParams] = useSearchParams();
const handleCategory = (id: number | null) => {
const newSearchParams = new URLSearchParams(searchParams);
if (id === null) {
newSearchParams.delete(QUERYSTRING.CATEGORY_ID);
} else {
newSearchParams.set(QUERYSTRING.CATEGORY_ID, id.toString());
}
setSearchParams(newSearchParams);
};
const handleNews = () => {
const newSearchParams = new URLSearchParams(searchParams);
if (newSearchParams.get(QUERYSTRING.NEWS)) {
newSearchParams.delete(QUERYSTRING.NEWS);
} else {
newSearchParams.set(QUERYSTRING.NEWS, 'true');
}
setSearchParams(newSearchParams);
}
...
}
...
components/books/BooksViewSwitcher.tsx
...
const viewOptions = [
{
value: 'list',
icon: <FaList />
},
{
value: 'grid',
icon: <FaTh />
}
];
export type ViewMode = 'grid' | 'list';
function BooksViewSwitcher() {
const [searchParams, setSearchParams] = useSearchParams();
const handleSwitch = (value: ViewMode) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set(QUERYSTRING.VIEW, value);
setSearchParams(newSearchParams);
}
useEffect(() => {
if (!searchParams.get(QUERYSTRING.VIEW)) {
handleSwitch('grid');
}
}, [])
...
}
...
components/books/BooksList.tsx
import styled from 'styled-components';
import BookItem from './BookItem';
import { Book } from '../../models/book.model';
import { useLocation } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
import { useEffect, useState } from 'react';
import { ViewMode } from './BooksViewSwitcher';
interface Props {
books: Book[];
}
function BooksList({ books }: Props) {
const [view, setView] = useState('grid');
const location = useLocation();
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get(QUERYSTRING.VIEW)) {
setView(params.get(QUERYSTRING.VIEW) as ViewMode);
}
}, [location.search]);
...
}
...
components/books/BookItem.tsx
...
interface Props {
book: Book;
view?: ViewMode
}
function BookItem({ book, view }: Props) {
return (
<BookItemStyle view={view}>
<div className='img'>
<img
src={getImgSrc(book.img)}
alt={book.title}
/>
</div>
<div className='content'>
<h2 className='title'>{book.title}</h2>
<p className='summary'>{book.summary}</p>
<p className='author'>{book.author}</p>
<p className='price'>{formatNumber(book.price)}원</p>
<div className='likes'>
<FaHeart />
<span>{book.likes}</span>
</div>
</div>
</BookItemStyle>
)
}
...
components/books/BooksEmpty.tsx
...
function BooksEmpty() {
return (
<BooksEmptyStyle>
<div className='icon'>
<FaSmileWink />
</div>
<Title size='large' color='secondary'>
검색 결과가 없습니다.
</Title>
<p>
<Link to='/books'>전체 검색 결과로 이동</Link>
</p>
</BooksEmptyStyle>
)
}
...
components/books/Pagination.tsx
import styled from 'styled-components';
import { Pagination as IPagination } from '../../models/pagination.model';
import { LIMIT } from '../../constants/pagination';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
interface Props {
pagination: IPagination;
}
function Pagination({ pagination }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const { totalCount, currentPage } = pagination;
const pages: number = Math.ceil(totalCount / LIMIT);
const handleClickPage = (page: number) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set(QUERYSTRING.PAGE, page.toString());
setSearchParams(newSearchParams);
}
return (
<PaginationStyle>
{
pages > 0 && (
<ol>
{
Array(pages).fill(0).map((_, index) => (
<li>
<Button
size='small'
scheme={
index + 1 === currentPage ?
'primary' : 'normal'
}
key={index}
onClick={() => handleClickPage(index + 1)}
>
{index + 1}
</Button>
</li>
))
}
</ol>
)
}
</PaginationStyle>
)
}
...
배운 점
- 프론트 단에서 로컬 스토리지에 저장된 토큰을 이용해 로그인을 처리하는 방법에 대해 배웠다.
- useNavigate() 훅으로 리다이렉트 할 수 있다.
- useSearchParams를 이용하여 쿼리 문자열을 설정하는 방법에 대해 배웠다.
'데브코스' 카테고리의 다른 글
[15주차 - DAY2] 도서 정보 사이트 - 장바구니 목록 (0) | 2024.06.04 |
---|---|
[15주차 - DAY1] 도서 정보 사이트 - 도서 상세 페이지 (0) | 2024.06.03 |
[14주차 주간 발표] CORS (0) | 2024.05.30 |
[14주차 - DAY4] 도서 정보 사이트 - 라우팅 (0) | 2024.05.30 |
[14주차 - DAY3] 도서 정보 사이트 - 테마 스위치 (0) | 2024.05.29 |