2024년 11월 15일
도서 상세 화면 요구 사항
BookDetail
// App.tsx
... 생략 ...
const router = createBrowserRouter([
... 생략 ...
},
{
path: "/books/:bookId",
element: <Layout><BookDetail /></Layout>
}
]);
... 생략 ...
// components / books / BookItem.tsx
... 생략 ...
const BookItem = ({ book, view }: Props) => {
return (
<BookItemStyle view={view}>
<Link to={`/books/${book.id}`}>
<div className="img">
... 생략 ...
// api / books.api.ts
... 생략 ...
export const fetchBook = async (bookId: string) => {
const response = await httpClient.get<BookDetail>(`/books/${bookId}`);
return response.data;
}
// pages / BookDetail.tsx
import { useParams } from 'react-router-dom';
import styled from 'styled-components'
import { useBook } from '../hooks/useBook';
const BookDetail = () => {
const { bookId } = useParams();
const { book } = useBook(bookId);
if(!book) return null;
return (
<BookDetailStyle>{book.title}</BookDetailStyle>
)
}
const BookDetailStyle = styled.div`
`;
export default BookDetail
// hooks / useBook.ts
import { useState, useEffect } from 'react'
import { BookDetail } from '../models/book.model';
import { fetchBook } from '../api/books.api';
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
useEffect(()=>{
if(!bookId) return;
fetchBook(bookId).then((book) => {setBook(book)})
}, [bookId]);
return { book };
}
// pages / BookDetail.tsx
import { useParams } from 'react-router-dom';
import styled from 'styled-components'
import { useBook } from '../hooks/useBook';
import Title from '../components/common/Title';
import { getImgSrc } from '../utils/image';
import { BookDetail as IBookDetail} from '../models/book.model';
import {formatNumber, formatDate } from '../utils/format';
import { Link } from 'react-router-dom';
const bookInfoList = [
{
label: "카테고리",
key: "category_name",
filter: (book:IBookDetail) => {
return <Link to={`/books?category_id=${book.category_id}`}>
{book.category_name}</Link>
}
},
{
label: "포맷",
key: "form"
},
{
label: "페이지",
key: "pages"
},
{
label: "ISBN",
key: "isbn"
},
{
label: "출간일",
key: "pub_date",
filter: (book: IBookDetail) => {
return formatDate(book.pub_date);
}
},
{
label: "가격",
key: "price",
filter: (book: IBookDetail) => {
return `${formatNumber(book.price)} 원`;
}
}
]
const BookDetail = () => {
const { bookId } = useParams();
const { book } = useBook(bookId);
if(!book) return null;
return (
<BookDetailStyle>
<header className="header">
<div className="img">
<img src={getImgSrc(book.img)} alt={book.title} />
</div>
<div className="info">
<Title size="large" color="text">{book.title}</Title>
{
bookInfoList.map((item) =>(
<dl key={item.label}>
<dt>{item.label}</dt>
<dt>{item.filter ? item.filter(book) :
book[item.key as keyof IBookDetail]}</dt>
</dl>
))
}
<p className="summary">{book.summary}</p>
<div className="lik">라이크</div>
<div className="add-cart">장바구니 넣기</div>
</div>
</header>
<div className="content"></div>
</BookDetailStyle>
)
}
const BookDetailStyle = styled.div`
.header {
display: flex;
align-items: start;
gap: 24px;
padding: 0 0 24px 0;
.img {
flex: 1;
img {
width: 100%;
height: auto;
}
}
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
dl {
display: flex;
margin: 0;
dt {
width: 80px;
color: ${({theme}) => theme.colors.secondary};
}
a {
color: ${({theme}) => theme.colors.primary};
}
}
}
}
`;
export default BookDetail
// utils / format.ts
import dayjs from "dayjs";
export const formatNumber = (number:number):string => {
return number.toLocaleString();
};
export const formatDate = (date: string, format?: string) => {
return dayjs(date).format(format ? format : "YYYY년 MM월 DD일");
};
// components / common / EllipsisBox.tsx
import React, { useState } from 'react'
import styled from 'styled-components'
import Button from './Button';
import { FaAngleDown } from "react-icons/fa";
interface Props {
children: React.ReactNode;
linelimit: number;
}
const EllipsisBox = ({ children, linelimit } : Props ) => {
const [expanded, setExpanded] = useState(false);
return (
<EllipsisBoxStyle linelimit={linelimit} $expanded={expanded}>
{/* 스타일 컴포넌트와 일반적인 어트리뷰트를 구분할 수 없어서 오류 생김 : $를 붙이면 됨 */}
<p>{children}</p>
<div className="toggle">
<Button size="small" scheme="normal" onClick={()=>{
setExpanded(!expanded)
}}>{expanded ? "접기" : "펼치기"} <FaAngleDown /></Button>
</div>
</EllipsisBoxStyle>
)
}
interface EllipsisBoxStyleProps {
linelimit: number;
$expanded: boolean;
}
const EllipsisBoxStyle = styled.div<EllipsisBoxStyleProps>`
p {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: ${({linelimit, $expanded}) =>
($expanded ? "none" : linelimit)};
-webkit-box-orient: vertical;
padding: 20px 0 0 0;
margin: 0;
}
.toggle {
display: flex;
justify-content: end;
svg {
transform: ${({$expanded}) => ($expanded ?
"rotate(180deg)" : "rotate(0)")};
}
}
`;
export default EllipsisBox
// pages / BookDetail.tsx
... 생략 ...
</header>
<div className="content">
<Title size="medium">상세 설명</Title>
<EllipsisBox linelimit={2}>{book.detail}</EllipsisBox>
<Title size="medium">목차</Title>
<p className="index">{book.contents}</p>
</div>
</BookDetailStyle>
)
}
... 생략 ...
// components / book / LikeButton.tsx
import styled from 'styled-components'
import { BookDetail } from '../../models/book.model';
import Button from '../common/Button';
import { FaHeart } from "react-icons/fa";
interface Props {
book: BookDetail;
onClick: () => void;
}
const LikeButton = ({book, onClick} : Props) => {
return (
<LikeButtonStyle size="medium" scheme={book.liked ?
"like" : "normal"} onClick={onClick}>
<FaHeart />
{book.likes}
</LikeButtonStyle>
);
}
const LikeButtonStyle = styled(Button)`
display: flex;
gap: 6px;
svg {
color: inherit;
* {
color: inherit;
}
}
`;
export default LikeButton;
// components / common / Button.tsx
import { styled } from 'styled-components'
import { ButtonScheme, ButtonSize } from '../../style/theme';
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement>{
children: React.ReactNode
size: ButtonSize;
scheme: ButtonScheme;
disabled?: boolean;
isLoading?: boolean;
onClick?: () => void;
className?: string;
}
const Button = ({ children, size, scheme, disabled, isLoading, className, onClick }:Props) => {
return (
<ButtonStyle className={className} onClick={onClick} size={size} scheme={scheme} disabled={disabled} isLoading={isLoading}>{children}</ButtonStyle>
)
}
... 생략 ...
const Button = ({ children, size, scheme,
disabled, isLoading, ...props }:Props) => {
return (
<ButtonStyle {...props} size={size} scheme={scheme} disabled={disabled}
isLoading={isLoading}>{children}</ButtonStyle>
)
}
// style / theme.ts
... 생략 ...
export type ButtonScheme = "primary" | "normal" | "like";
... 생략 ...
buttonScheme: {
primary : {
color : "white",
backgroundColor : "midnightblue"
},
normal : {
color : "black",
backgroundColor: "lightgrey"
},
like: {
color: "white",
backgroundColor: "coral",
}
},
... 생략 ...
// pages / BookDetail.tsx
... 생략 ...
const BookDetail = () => {
const { bookId } = useParams();
const { book, likeToggle } = useBook(bookId);
if(!book) return null;
return (
<BookDetailStyle>
... 생략 ...
<div className="like">
<LikeButton book={book} onClick={likeToggle} />
</div>
... 생략 ...
// hooks / useBook.ts
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react'
import { BookDetail } from '../models/book.model';
import { fetchBook, likeBook, unlikeBook } from '../api/books.api';
import { useAuthStore } from '../store/authStore';
import { useAlert } from './useAlert';
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
const {isloggedIn} = useAuthStore();
const showAlert = useAlert();
const navigate = useNavigate();
const likeToggle = () => {
// 권한 확인
if(!isloggedIn) {
showAlert('로그인이 필요합니다.');
navigate('/login');
return;
}
if(!book) return;
if(book.liked) {
// 라이크 상태 -> 언라이크 실행
unlikeBook(book.id).then(() => {
setBook({
...book,
liked: false,
likes: book.likes - 1,
})
})
} else {
//언라이크 상태 -> 라이크 실행
likeBook(book.id).then(()=>{
// 성공 처리
setBook({
...book,
liked: true,
likes: book.likes + 1,
})
})
}
};
useEffect(()=>{
if(!bookId) return;
fetchBook(bookId).then((book) => {setBook(book)})
}, [bookId]);
return { book, likeToggle };
}
// api / books.api.ts
... 생략 ...
export const fetchBook = async (bookId: string) => {
const response = await httpClient.get<BookDetail>(`/books/${bookId}`);
return response.data;
}
// string으로 props를 받는 이유는 리액트 라우터를 통해 bookId를 사용하면 문자열로 변환되기 때문
// 밑에서는 직접 요청하므로 number 타입이 넘어감
export const likeBook = async (bookId: number) => {
const response = await httpClient.post(`/likes/${bookId}`);
return response.data;
}
export const unlikeBook = async (bookId: number) => {
const response = await httpClient.delete(`/likes/${bookId}`);
return response.data;
}
... 생략 ...
장바구니 담기 기능
기본적으로 quantity를 업데이트 하는 UI 작성
// components / book / AddToCart.tsx
import styled from 'styled-components'
import { BookDetail } from '../../models/book.model';
import InputText from '../common/InputText';
import Button from '../common/Button';
import React, { useState } from 'react';
interface Props {
book: BookDetail;
}
const AddToCart = ({ book } : Props) => {
const [quantity, setQuantity] = useState<number>(1);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuantity(Number(e.target.value));
}
const handleIncrease = () => {
setQuantity(quantity + 1);
}
const handleDecrease = () => {
if(quantity === 1) return;
setQuantity(quantity - 1);
}
return (
<AddToCartStyle>
<div>
<InputText inputType='number' value={quantity} onChange={handleChange}/>
<Button size="medium" scheme="normal" onClick={handleIncrease}>+</Button>
<Button size="medium" scheme="normal" onClick={handleDecrease}>-</Button>
</div>
<Button size="medium" scheme="primary">장바구니 담기</Button>
</AddToCartStyle>
)
}
const AddToCartStyle = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
export default AddToCart
// pages / BookDetail.tsx
... 생략 ...
<div className="add-cart">
<AddToCart book={book} />
</div>
... 생략 ...
// carts.api.ts
import { httpClient } from './http';
interface AddCartParams {
book_id: number;
quantity: number;
}
export const addCart = async(params: AddCartParams) => {
const response = await httpClient.post("/carts", params);
return response.data;
}
// components / book / AddToCart.tsx
... 생략 ...
const showAlert = useAlert();
const [cartAdded, setCartAdded] = useState(false);
... 생략 ...
const addToCart = () => {
addCart({
book_id: book.id,
quantity: quantity
}).then(() => {
// showAlert("장바구니에 추가되었습니다.");
setCartAdded(true);
})
}
return (
<AddToCartStyle>
<div>
<InputText inputType='number' value={quantity} onChange={handleChange}/>
<Button size="medium" scheme="normal" onClick={handleIncrease}>+</Button>
<Button size="medium" scheme="normal" onClick={handleDecrease}>-</Button>
</div>
<Button size="medium" scheme="primary" onClick={addToCart}>장바구니 담기</Button>
{
cartAdded && (
<div className="added">
<p>장바구니에 추가되었습니다.</p>
<Link to="/cart">장바구니로 이동</Link>
</div>
)
}
</AddToCartStyle>
)
}
const AddToCartStyle = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
.added {
position: absolute;
right: 0;
bottom: -90px;
background: ${({theme}) => theme.colors.background};
border-radius: ${({theme}) => theme.borderRadius.default};
padding: 8px 12px;
p {
padding: 0 0 8px 0;
margin: 0;
}
}
`;
export default AddToCart
// components / book / AddToCart.tsx
import styled from 'styled-components'
import { BookDetail } from '../../models/book.model';
import InputText from '../common/InputText';
import Button from '../common/Button';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useBook } from '../../hooks/useBook';
interface Props {
book: BookDetail;
}
const AddToCart = ({ book } : Props) => {
const [quantity, setQuantity] = useState<number>(1);
const {addToCart, cartAdded} = useBook(book.id.toString());
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuantity(Number(e.target.value));
}
const handleIncrease = () => {
setQuantity(quantity + 1);
}
const handleDecrease = () => {
if(quantity === 1) return;
setQuantity(quantity - 1);
}
return (
<AddToCartStyle $added={cartAdded}>
<div>
<InputText inputType='number' value={quantity}
onChange={handleChange}/>
<Button size="medium" scheme="normal"
onClick={handleIncrease}>+</Button>
<Button size="medium" scheme="normal"
onClick={handleDecrease}>-</Button>
</div>
<Button size="medium" scheme="primary"
onClick={() => addToCart(quantity)}>장바구니 담기</Button>
<div className="added">
<p>장바구니에 추가되었습니다.</p>
<Link to="/cart">장바구니로 이동</Link>
</div>
</AddToCartStyle>
)
}
interface AddCartStyleProps {
$added: boolean;
// 일반 html 요소의 어트리뷰트는 문자열로 제한하기 때문에 해당 내용을 $을 붙여 회피시킨다
}
const AddToCartStyle = styled.div<AddCartStyleProps>`
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
.added {
position: absolute;
right: 0;
bottom: -90px;
background: ${({theme}) => theme.colors.background};
border-radius: ${({theme}) => theme.borderRadius.default};
padding: 8px 12px;
opacity: ${({$added}) => ($added ? "1" : "0") };
transition: all 0.5s ease;
p {
padding: 0 0 8px 0;
margin: 0;
}
}
`;
export default AddToCart
// hooks / useBook.ts
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react'
import { BookDetail } from '../models/book.model';
import { fetchBook, likeBook, unlikeBook } from '../api/books.api';
import { useAuthStore } from '../store/authStore';
import { useAlert } from './useAlert';
import { addCart } from '../api/carts.api';
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
const {isloggedIn} = useAuthStore();
const showAlert = useAlert();
const navigate = useNavigate();
const [cartAdded, setCartAdded] = useState(false);
... 생략 ...
const addToCart = (quantity: number) => {
if(!book) return;
addCart({
book_id: book.id,
quantity: quantity
}).then(() => {
setCartAdded(true);
setTimeout(()=> {
setCartAdded(false);
}, 3000);
})
}
useEffect(()=>{
if(!bookId) return;
fetchBook(bookId).then((book) => {setBook(book)})
}, [bookId]);
return { book, likeToggle, addToCart, cartAdded };
}