2024년 11월 14일
// ResetPassword.tsx
import { useForm } from 'react-hook-form';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { resetPassword, resetRequest } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';
import { SignupStyle } from './Signup';
export interface SignupProps {
email : string;
password : string;
}
const ResetPassword = () => {
const [resetRequested, setResetRequested] = useState(false);
const navigate = useNavigate();
const showAlert = useAlert();
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);
});
}
};
return (
<>
<Title size="large">비밀번호 초기화</Title>
<SignupStyle>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
{errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
</fieldset>
{
resetRequested && (
<fieldset>
<InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
{errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
</fieldset>
)
}
<fieldset>
<Button type="submit" size="medium" scheme="primary">
{
resetRequested ? "비밀번호 초기화" : "초기화 요청"
} </Button>
</fieldset>
<div className="info">
<Link to="/reset">비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
)
}
export default ResetPassword;
// api / auth.api.ts
import { SignupProps } from '../pages/Signup';
import { httpClient } from './http';
export const signUp = async (userData:SignupProps) => {
const response = await httpClient.post("/users/join", userData);
return response.data;
}
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;
}
// App.tsx
import Layout from './components/layout/Layout';
import Home from './pages/Home';
import ThemeSwitcher from './components/header/ThemeSwitcher';
import { BookStoreThemeProvider } from './context/themeContext';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Error from './components/common/Error';
import Signup from './pages/Signup';
import ResetPassword from './pages/ResetPassword';
const router = createBrowserRouter([
{
path: "/",
element: <Layout><Home /></Layout>,
errorElement: <Error />
},
{
path: "/books",
element: <Layout><div>도서 목록</div></Layout>,
errorElement: <Error />
},
{
path: "/signup",
element: <Layout><Signup /></Layout>,
},
{
path: "/reset",
element: <Layout><ResetPassword /></Layout>,
}
]);
... 생략 ...
// pages / Login.tsx
import { useForm } from 'react-hook-form';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { login } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';
import { SignupStyle } from './Signup';
export interface SignupProps {
email : string;
password : string;
}
const Login = () => {
const navigate = useNavigate();
const showAlert = useAlert();
const { register, handleSubmit, formState: {errors} } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
login(data).then((res) => {
console.log(res.token);
showAlert("로그인 완료되었습니다.");
navigate("/");
})
};
return (
<>
<Title size="large">로그인</Title>
<SignupStyle>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
{errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
</fieldset>
<fieldset>
<InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
{errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
</fieldset>
<fieldset>
<Button type="submit" size="medium" scheme="primary">
로그인
</Button>
</fieldset>
<div className="info">
<Link to="/reset">비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
)
}
export default Login;
// api / auth.api.ts
... 생략 ...
interface LoginResponse {
token: string;
}
export const login = async (data:SignupProps) => {
const response = await httpClient.post<LoginResponse>("/users/login", data);
return response.data;
}
// App.tsx
... 생략 ...
{
path: "/login",
element: <Layout><Login /></Layout>,
}
]);
... 생략 ...
전역 상태 만들고, 로그인 시키고나서 store에 저장시키기
스토어 생성
// store / authStore.ts
import { create } from 'zustand';
interface StoreState {
// Zustand는 상태 정보와 액션 함수를 같이 선언한다.
isloggedIn: boolean;
storeLogin: (token: string) => void;
storeLogout: () => void;
}
export const useAuthStore = create<StoreState>((set) => ({
isloggedIn: false, // 초기값
// 액션
storeLogin: (token: string) => {
set({isloggedIn: true});
},
storeLogout: () => {
set({isloggedIn: false});
}
}));
// pages / Login.tsx
import { useForm } from 'react-hook-form';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { login } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';
import { SignupStyle } from './Signup';
import { useAuthStore } from '../store/authStore';
export interface SignupProps {
email : string;
password : string;
}
const Login = () => {
const navigate = useNavigate();
const showAlert = useAlert();
const { isloggedIn, storeLogin, storeLogout } = useAuthStore();
const { register, handleSubmit, formState: {errors} } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
login(data).then((res) => {
// 상태 변화
storeLogin(res.token);
showAlert("로그인 완료되었습니다.");
navigate("/");
})
};
console.log(isloggedIn);
return (
<>
<Title size="large">로그인</Title>
<SignupStyle>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<InputText placeholder='이메일' inputType='email'
{...register("email", { required: true })} />
{errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
</fieldset>
<fieldset>
<InputText placeholder='비밀번호' inputType='password'
{...register("password", { required: true })} />
{errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
</fieldset>
<fieldset>
<Button type="submit" size="medium" scheme="primary">
로그인
</Button>
</fieldset>
<div className="info">
<Link to="/reset">비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
)
}
export default Login;
// store / authStore.ts
import { create } from 'zustand';
interface StoreState {
isloggedIn: boolean;
storeLogin: (token: string) => void;
storeLogout: () => void;
}
const getToken = () => {
const token = localStorage.getItem("token");
return token;
}
const setToken = (token: string) => {
localStorage.setItem("token", token);
}
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();
}
}));
// components / common / Header.tsx
import { styled } from 'styled-components';
import logo from "../../assets/logo.png";
import { FaSignInAlt, FaRegUser } from "react-icons/fa";
import { Link } from 'react-router-dom';
import { useCategory } from '../../hooks/useCategory';
import { useAuthStore } from '../../store/authStore';
const Header = () => {
const { category } = useCategory();
const { isloggedIn, storeLogout } = useAuthStore();
return (
<HeaderStyle>
<h1 className="logo">
<Link to="/">
<img src={logo} alt="book store"/ >
</Link>
</h1>
<nav className="category">
<ul>
{
category.map((item) => (
<li key={item.category_id}>
<Link to={item.category_id === null ? '/books' : `/books?category_id=${item.category_id}`}>
{item.category_name}
</Link>
</li>
))
}
</ul>
</nav>
<nav className="auth">
{
isloggedIn && (
<ul>
<li><Link to="/cart">장바구니</Link></li>
<li><Link to="/orderlist">주문 내역</Link></li>
<li><button onClick={storeLogout}>로그아웃</button></li>
</ul>
)
}
{
!isloggedIn && (
<ul>
<li>
<a href="/login"><FaSignInAlt />로그인</a>
</li>
<li>
<a href="/signup"><FaRegUser />회원가입</a>
</li>
</ul>
)
}
</nav>
</HeaderStyle>
)
}
... 생략 ...
// api / http.ts
import axios, { AxiosRequestConfig } from 'axios';
import { getToken } from '../store/authStore';
const BASE_URL = "http://127.0.0.1:9999";
const DEFAULT_TIMEOUT = 30000;
export const createClient = (config?: AxiosRequestConfig) => {
const axiosInstance = axios.create({
baseURL : BASE_URL,
timeout : DEFAULT_TIMEOUT,
headers : {
"content-type" : "application/json",
"authorization" : getToken() ? getToken() : "",
},
withCredentials: true,
...config,
});
axiosInstance.interceptors.response.use(
(response) => { return response },
(error) => {
// 로그인 만료 처리
if(error.response.status === 401) {
removeToken();
window.location.href = "/login";
return;
}
return Promise.reject(error)
}
);
return axiosInstance;
};
export const httpClient = createClient();
// pages / Login.tsx
... 생략 ...
const onSubmit = (data: SignupProps) => {
login(data).then((res) => {
// 상태 변화
storeLogin(res.token);
console.log(res.token);
showAlert("로그인 완료되었습니다.");
navigate("/");
}, (error) => {
showAlert("로그인이 실패했습니다.");
});
};
... 생략 ...
도서 목록 화면 요구 사항
도서 목록 페이지 구조
BookList
// App.tsx
... 생략 ...
import Books from './pages/Books';
const router = createBrowserRouter([
{
path: "/",
element: <Layout><Home /></Layout>,
errorElement: <Error />
},
{
path: "/books",
element: <Layout><Books /></Layout>,
errorElement: <Error />
},
... 생략 ...
// pages / Books.tsx
import styled from 'styled-components'
import Title from '../components/common/Title';
import BooksFilter from '../components/books/BooksFilter';
import BooksList from '../components/books/BooksList';
import BooksEmpty from '../components/books/BooksEmpty';
import Pagination from '../components/books/Pagination';
import BooksViewSwitcher from '../components/books/BooksViewSwitcher';
const Books = () => {
return (
<>
<Title size="large">도서 검색 결과</Title>
<BooksStyle>
<BooksFilter />
<BooksViewSwitcher />
<BooksList />
<BooksEmpty />
<Pagination />
</BooksStyle>
</>
)
}
const BooksStyle = styled.div`
`;
export default Books
// components / books / BooksList.tsx
import styled from 'styled-components'
import BookItem from './BookItem';
import { Book } from '../../models/book.model';
const dummyBook: Book = {
id: 1,
title: "Dummy Book",
img: 7,
category_id: 1,
summary: "Dummy Summary",
author: "Dummy Author",
price: 10000,
likes: 1,
form : "paperback",
isbn : "Dummy ISBN",
detail : "Dummy Detail",
pages: 100,
contents: "Dummy Contents",
pubDate: "2021-01-01",
}
const BooksList = () => {
return (
<BooksStyle>
<BookItem book={dummyBook}/>
</BooksStyle>
)
}
const BooksStyle = styled.div`
`;
export default BooksList
// components / books / BookItem.tsx
import styled from 'styled-components'
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import formatNumber from '../../utils/format';
import { FaHeart } from "react-icons/fa";
interface Props {
book: Book;
}
const BookItem = ({ book }: Props) => {
return (
<BookItemStyle>
<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>
)
}
const BookItemStyle = styled.div`
`;
export default BookItem
// utils / image.ts
export const getImgSrc = (id: number) => {
return `https://picsum.photos/id/${id}/200/200`;
}
// utils / format.ts
export const formatNumber = (number:number):string => {
return number.toLocaleString();
}
export default formatNumber;
// components / books / BookItem.tsx
import styled from 'styled-components'
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import formatNumber from '../../utils/format';
import { FaHeart } from "react-icons/fa";
interface Props {
book: Book;
}
const BookItem = ({ book }: Props) => {
return (
<BookItemStyle>
<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>
)
}
const BookItemStyle = styled.div`
display: flex;
flex-direction: column;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
.img {
border-radius: ${({theme}) => theme.borderRadius.default};
overflow: hidden;
img {
max-width: 100%;
}
}
.content {
padding: 16px;
position: relative;
.title {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 12px 0;
}
.summary {
font-size: 0.875rem;
color: ${({theme}) => theme.colors.secondary};
margin: 0 0 4px 0;
}
.author {
font-size: 0.875rem;
color: ${({theme}) => theme.colors.secondary};
margin: 0 0 4px 0;
}
.price {
font-size: 1rem;
color: ${({theme}) => theme.colors.secondary};
margin: 0 0 4px 0;
font-weight: 700;
}
.likes {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
color: ${({theme}) => theme.colors.primary};
margin: 0 0 4px 0;
font-weight: 700;
border: 1px solid ${({theme}) => theme.colors.border};
border-radius: ${({theme}) => theme.borderRadius.default};
padding: 4px 12px;
position: absolute;
bottom: 16px;
right: 16px;
svg {
color: ${({theme}) => theme.colors.primary};
}
}
}
`;
export default BookItem
// components / books/ BookItem.spec.tsx
import { render } from "@testing-library/react";
import BookItem from './BookItem';
import { BookStoreThemeProvider } from '../../context/themeContext';
import { Book } from '../../models/book.model';
const dummyBook: Book = {
id: 1,
title: "Dummy Book",
img: 7,
category_id: 1,
summary: "Dummy Summary",
author: "Dummy Author",
price: 10000,
likes: 1,
form : "paperback",
isbn : "Dummy ISBN",
detail : "Dummy Detail",
pages: 100,
contents: "Dummy Contents",
pubDate: "2021-01-01",
};
describe("BookItem 테스트", () => {
it("렌더 여부", () => {
const { getByText, getByAltText } = render(
<BookStoreThemeProvider>
<BookItem book={dummyBook} />
</BookStoreThemeProvider>
);
expect(getByText(dummyBook.title)).toBeInTheDocument();
expect(getByText(dummyBook.summary)).toBeInTheDocument();
expect(getByText(dummyBook.author)).toBeInTheDocument();
expect(getByText(dummyBook.likes)).toBeInTheDocument();
expect(getByAltText(dummyBook.title)).toHaveAttribute("src", `https://picsum.photos/id/${dummyBook.img}/200/200`);
})
})
// components / books / BooksEmpty.tsx
import styled from 'styled-components'
import { FaSmileWink } from 'react-icons/fa';
import Title from '../common/Title';
import { Link } from 'react-router-dom';
const BooksEmpty = () => {
return (
<BooksEmptyStyle>
<div className="icon">
<FaSmileWink />
</div>
<Title size="large" color="secondary">
검색 결과가 없습니다.
</Title>
<p>
<Link to="/books">전체 검색 결과로 이동</Link>
</p>
</BooksEmptyStyle>
)
}
const BooksEmptyStyle = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
padding: 120px 0;
.icon {
svg {
font-size: 4rem;
fill: #ccc;
}
}
`;
export default BooksEmpty
// components / books / BooksFilter.tsx
import styled from 'styled-components'
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
const BooksFilter = () => {
const { category } = useCategory();
return (
<BooksFilterStyle>
<div className="category">
{
category.map((item) => (
<Button size="medium" scheme="normal" key={item.category_id}>
{item.category_name}
</Button>
))
}
</div>
<div className="new">
<Button size="medium" scheme="normal">신간</Button>
</div>
</BooksFilterStyle>
)
}
const BooksFilterStyle = styled.div`
display: flex;
gap: 24px;
.category {
display: flex;
gap: 8px;
}
`;
export default BooksFilter
// 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';
const BooksFilter = () => {
const { category } = useCategory();
const [searchParams, setSearchParams] = useSearchParams();
// 쿼리 스트링을 사용할 수 있게됨
const handleCategory = (id: number | null) => {
const newSearchParams = new URLSearchParams(searchParams);
// 인스턴스를 생성해 그 인스턴스로 하여금 쿼리스트링의 내용에 access 하거나
// set을 할 수 있는 도구들 제공
if(id === null) {
newSearchParams.delete("category_id");
} else {
newSearchParams.set("category_id", id.toString());
}
setSearchParams(newSearchParams);
}
const currentCategory = searchParams.get("category_id");
return (
<BooksFilterStyle>
<div className="category">
{
category.map((item) => (
<Button size="medium" scheme={currentCategory ===
item.category_id?.toString() ? 'primary' : 'normal'}
key={item.category_id} onClick={()=>
handleCategory(item.category_id)}>
{item.category_name}
</Button>
))
}
</div>
<div className="new">
<Button size="medium" scheme="normal">신간</Button>
</div>
</BooksFilterStyle>
)
}
const BooksFilterStyle = styled.div`
display: flex;
gap: 24px;
.category {
display: flex;
gap: 8px;
}
`;
export default BooksFilter
그런데 코드의 장황함 생김 → 카테고리의 searchParams를 표시할 인디케이터가 있을때마다 분기하는것이 불필요하다고 생각 됨 → 리펙토링
useCategory(훅)안에 active를 넣는다면, 필터와 헤더에서도 사용할 수 있을 것임
훅에 active가 되는 로직 넣기
// models / category.model.ts
export interface Category {
category_id: number | null;
category_name: string;
isActive?: boolean;
}
// hooks / useCategory.ts
import { useState, useEffect } from 'react';
import { Category } from '../models/category.model';
import { fetchCategory } from '../api/category.api';
import { useLocation } from 'react-router-dom';
export const useCategory = () => {
const location = useLocation();
// location 안에 여러 url값 중, search라는 값을 사용할 예정임
const [category, setCategory] = useState<Category[]>([]);
const setActive = () => {
const params = new URLSearchParams(location.search);
if(params.get('category_id')) {
setCategory((prev) => {
return prev.map((item) => {
return {...item, isActive: item.category_id === Number(params.get('category_id'))}
})
})
} else {
setCategory((prev) => {
return prev.map((item) => {
return {...item, isActive: false}
})
})
}
}
useEffect(() =>{
fetchCategory().then((category) => {
if(!category) return;
const categoryWithAll = [
{
category_id: null,
category_name: '전체',
},
...category,
]
setCategory(categoryWithAll);
setActive();
});
}, []);
useEffect(() => {
setActive();
}, [location.search]);
return { category };
}
// componentes / books / BooksFilter.tsx
import styled from 'styled-components'
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
const BooksFilter = () => {
const { category } = useCategory();
const [searchParams, setSearchParams] = useSearchParams();
const handleCategory = (id: number | null) => {
const newSearchParams = new URLSearchParams(searchParams);
if(id === null) {
newSearchParams.delete("category_id");
} else {
newSearchParams.set("category_id", id.toString());
}
setSearchParams(newSearchParams);
}
const handleNews = () => {
const newSearchParams = new URLSearchParams(searchParams);
if(newSearchParams.get('news')) {
newSearchParams.delete("news");
} else {
newSearchParams.set("news", "true");
}
setSearchParams(newSearchParams);
}
return (
<BooksFilterStyle>
<div className="category">
{
category.map((item) => (
<Button size="medium" scheme={item.isActive ? "primary" : "normal"} key={item.category_id} onClick={()=> handleCategory(item.category_id)}>
{item.category_name}
</Button>
))
}
</div>
<div className="new">
<Button size="medium" scheme={searchParams.get('news') ?
"primary" : "normal"} onClick={()=> handleNews()}>신간</Button>
</div>
</BooksFilterStyle>
)
}
const BooksFilterStyle = styled.div`
display: flex;
gap: 24px;
.category {
display: flex;
gap: 8px;
}
`;
export default BooksFilter
// constants / querystring.ts
export const QUERYSTRING = {
CATEGORY_ID : "category_id",
NEWS : "news",
PAGE: "page",
}
// 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';
const 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);
}
... 생략 ...
// api / books.api.ts
import { Book } from '../models/book.model';
import { Pagination } from '../models/pagination.model';
import { httpClient } from './http';
interface FetchBooksParams {
category_id?: number;
news?: boolean;
currentPage?: number;
limit: number;
}
interface FetchBooksResponse {
books : Book[],
pagination: Pagination;
}
export const fetchBooks = async (params:FetchBooksParams) => {
try {
const response = await httpClient.get<FetchBooksResponse>("/books", {
params: params,
});
return response.data;
} catch(error) {
return {
books: [],
pagination: {
totalCount: 0,
currentPage: 1,
},
};
}
}
// constants / pagination.ts
export const LIMIT = 8;
// hooks / useBooks.ts
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom'
import { Book } from '../models/book.model';
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);
// books의 배열이 위에서 빈 배열이 기본값이므로 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,
currentPage: 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
import styled from 'styled-components'
import Title from '../components/common/Title';
import BooksFilter from '../components/books/BooksFilter';
import BooksList from '../components/books/BooksList';
import BooksEmpty from '../components/books/BooksEmpty';
import Pagination from '../components/books/Pagination';
import BooksViewSwitcher from '../components/books/BooksViewSwitcher';
import { useBooks } from '../hooks/useBooks';
const Books = () => {
const { books, pagination, isEmpty } = useBooks();
return (
<>
<Title size="large">도서 검색 결과</Title>
<BooksStyle>
<div className="filter">
<BooksFilter />
<BooksViewSwitcher />
</div>
{ !isEmpty && <BooksList books={books} /> }
{ isEmpty && <BooksEmpty /> }
{ !isEmpty && <Pagination /> }
</BooksStyle>
</>
)
}
const BooksStyle = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 24px;
.filter {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
}
`;
export default Books
// components / books / BookList.tsx
import styled from 'styled-components'
import BookItem from './BookItem';
import { Book } from '../../models/book.model';
interface Props {
books: Book[];
}
const BooksList = ({books}: Props) => {
return (
<BooksStyle>
{
books.map((item) => (<BookItem key={item.id} book={item} />))
}
</BooksStyle>
)
}
const BooksStyle = styled.div`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 24px;
`;
export default BooksList
*그리드 관련 글 참조 : https://studiomeal.com/archives/533
// pages / Books.tsx
... 생략 ...
{ !isEmpty && <BooksList books={books} /> }
{ isEmpty && <BooksEmpty /> }
{ !isEmpty && <Pagination pagination={pagination} /> }
</BooksStyle>
... 생략 ...
// 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;
}
const 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 key={index} size="small"
scheme={index + 1 === currentPage ? "primary" : "normal"}
onClick={() => {handleClickPage(index+1)} }>
{index + 1}</Button>
</li>
))
}
</ol>
)
}
</PaginationStyle>
)
}
const PaginationStyle = styled.div`
display: flex;
justify-content: start;
align-items: center;
padding: 24px;
ol {
list-style: none;
display: flex;
gap: 8px;
padding: 0;
margin: 0;
}
`;
export default Pagination
*Array.fill().map() 관련 글 : https://velog.io/@reasonz/2022.08.15-Array.fill.map-%EC%B2%B4%EC%9D%B4%EB%8B%9D-%ED%95%B4%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
// constants / querysting.ts
export const QUERYSTRING = {
CATEGORY_ID : "category_id",
NEWS : "news",
PAGE: "page",
VIEW: "view",
}
// comonents / books / BooksViewSwitcher.tsx
import styled from 'styled-components'
import Button from '../common/Button';
import { FaList, FaTh } from 'react-icons/fa';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
import { useEffect } from 'react';
const viewOptions = [
{
value: "list",
icon: <FaList />
},
{
value: "grid",
icon: <FaTh />
}
]
export type ViewMode = "grid" | "list";
const 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");
}
}, []);
return (
<BooksViewSwitcherStyle>
{
viewOptions.map((option) => (
<Button key={option.value} size="medium"
scheme={searchParams.get(QUERYSTRING.VIEW) === option.value ? "primary" : "normal"}
onClick={()=>{
handleSwitch(option.value as ViewMode)
}}>{option.icon}</Button>
))
}
</BooksViewSwitcherStyle>
)
}
const BooksViewSwitcherStyle = styled.div`
display: flex;
gap: 8px;
svg {
fill: #fff;
}
`;
export default BooksViewSwitcher
// 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 { useEffect, useState } from 'react';
import { QUERYSTRING } from '../../constants/querystring';
import { ViewMode } from './BooksViewSwitcher';
interface Props {
books: Book[];
}
const BooksList = ({books}: Props) => {
const location = useLocation();
const [view, setView] = useState<ViewMode>("grid");
useEffect(()=>{
const params = new URLSearchParams(location.search);
if(params.get(QUERYSTRING.VIEW)) {
setView(params.get(QUERYSTRING.VIEW) as ViewMode);
}
}, [location.search]);
return (
<BooksListStyle view={view}>
{
books?.map((item) => (<BookItem key={item.id} book={item} view={view} />))
}
</BooksListStyle>
)
}
interface BooksListStyleProps {
view: ViewMode;
}
const BooksListStyle = styled.div<BooksListStyleProps>`
display: grid;
grid-template-columns: ${({view}) => (
view === "grid" ? "repeat(5, 1fr);" : "repeat(1, 1fr);"
)};
gap: 24px;
`;
export default BooksList
// components / books / BookItem.tsx
import styled from 'styled-components'
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import formatNumber from '../../utils/format';
import { FaHeart } from "react-icons/fa";
import { ViewMode } from './BooksViewSwitcher';
interface Props {
book: Book;
view?: ViewMode;
}
const 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>
)
}
const BookItemStyle = styled.div<Pick<Props, 'view'>>`
display: flex;
flex-direction: ${({view}) => view === 'grid' ? "column" : "row"};
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
.img {
border-radius: ${({theme}) => theme.borderRadius.default};
overflow: hidden;
width: ${({view}) => view === 'grid' ? "auto" : "160px"};
img {
max-width: 100%;
}
}
.content {
padding: 16px;
position: relative;
flex: ${({view}) => view === 'grid' ? "0" : "1"};
.title {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 12px 0;
}
.summary {
font-size: 0.875rem;
color: ${({theme}) => theme.colors.secondary};
margin: 0 0 4px 0;
}
.author {
font-size: 0.875rem;
color: ${({theme}) => theme.colors.secondary};
margin: 0 0 4px 0;
}
.price {
font-size: 1rem;
color: ${({theme}) => theme.colors.secondary};
margin: 0 0 4px 0;
font-weight: 700;
}
.likes {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
color: ${({theme}) => theme.colors.primary};
margin: 0 0 4px 0;
font-weight: 700;
border: 1px solid ${({theme}) => theme.colors.border};
border-radius: ${({theme}) => theme.borderRadius.default};
padding: 4px 12px;
position: absolute;
bottom: 16px;
right: 16px;
svg {
color: ${({theme}) => theme.colors.primary};
}
}
}
`;
export default BookItem
**궁금한 것 : 카테고리별로 버튼을 눌렀을 때, 책 목록이 잘 나오는 것 확인했음!
신간 클릭시, 쿼리 스트링에 의해 쿼리 달라져서 신간+카테고리별 도서 목록 나오는 것 확인했지만,
카테고리별 책에서 신간이 아닌 것을 토글하면 오류 발생함.. -> 이부분 쿼리 바꿔보고 몇시간 찾아봤지만 해결하지 못함 ㅠㅠㅠㅠ 멘토님께 여쭤봐야겠다...