라우팅 경로 추가
App.tsx
app.tsx 파일에 도서 상세 페이지 경로를 추가해 준다.
const router = createBrowserRouter([
...
{
path: '/book/:bookId',
element: (
<Layout>
<BookDetail />
</Layout>
)
}
])
도서 상세 페이지 컴포넌트
Header
api/books.api.ts
도서 상세 정보 조회 api로 http request를 보내는 함수를 생성한다.
export const fetchBook = async (bookId: string | undefined) => {
const response = await httpClient.get<BookDetail>(`/books/${bookId}`);
return response.data;
}
hooks/useBook.tsx
도서 상세 정보를 조회하는 훅을 생성한다.
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
useEffect(() => {
fetchBook(bookId).then((book) => {
setBook(book);
})
}, [bookId]);
return { book };
}
BookDetail.tsx
useParams()를 이용하여 url에서 도서 아이디인 bookId를 가져온다. 해당 bookId를 useBook의 파라미터로 넣어서 http request를 보내고, 도서 상세 정보를 받아와서 book에 저장한다. 그리고 bookInfoList를 이용하여 각 항목을 화면에 표시한다.
const bookInfoList = [
{
label: '카테고리',
key: 'categoryName',
filter: (book: IBookDetail) => (
<Link to={`/books?category_id=${book.category_id}`}>
{book.categoryName}
</Link>
)
},
{
label: '포맷',
key: 'form'
},
{
label: '페이지',
key: 'pages'
},
{
label: 'ISBN',
key: 'isbn'
},
{
label: '출간일',
key: 'pubDate',
filter: (book: IBookDetail) => {
return formatDate(book.pubDate);
}
},
{
label: '가격',
key: 'price',
filter: (book: IBookDetail) => {
return `${formatNumber(book.price)}원`
}
}
]
function BookDetail() {
const { bookId } = useParams();
const { book, likeToggle } = useBook(bookId);
if (!book) return null;
return (
<BookDetailStyle>
<header className="header">
<img className="img" src={getImgSrc(book.img)} alt={book.title} />
<div className="info">
<Title size="large" color="text">
{book.title}
</Title>
{
bookInfoList.map((item) => (
<dl>
<dt>{item.label}</dt>
<dd>{
item.filter ?
item.filter(book) :
book[item.key as keyof IBookDetail]
}</dd>
</dl>
))
}
<p className="summary">{book.summary}</p>
<div className="like">
<LikeButton book={book} onClick={likeToggle} />
</div>
<div className="add-cart">
<AddToCart book={book} />
</div>
</div>
</header>
...
</BookDetailStyle>
);
}
좋아요 버튼
로그인되어 있을 때 좋아요 버튼을 누르면 좋아요 여부가 토글 되어야 한다.
api/books.api.ts
좋아요와 좋아요 취소 api로 http request를 보내는 함수를 정의한다.
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;
}
hooks/useBook.ts
해당 파일을 다음과 같이 수정한다.
좋아요 버튼이 눌리면 likeToggle 함수를 실행한다. 이때 총 좋아요 수는 +-1로 바로 업데이트하는데, 이것을 낙관적 업데이트라고 하며, 불필요한 요청을 줄일 수 있다.
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
const { isLoggedIn } = useAuthStore();
const showAlert = useAlert();
const likeToggle = () => {
if (!isLoggedIn) {
showAlert('로그인이 필요합니다.');
return;
}
if (!book) return;
if (book.liked) {
// unlike 실행
unlikeBook(book.id).then(() => {
setBook({
...book,
liked: false,
likes: book.likes - 1
})
})
} else {
// like 실행
likeBook(book.id).then(() => {
setBook({
...book,
liked: true,
likes: book.likes + 1
})
})
}
};
useEffect(() => {
fetchBook(bookId).then((book) => {
setBook(book);
})
}, [bookId]);
return { book, likeToggle };
}
pages/BookDetail.tsx
likeToggle 함수를 가져와서 좋아요 버튼이 눌릴 때 실행되도록 설정한다.
function BookDetail() {
const { bookId } = useParams();
const { book, likeToggle } = useBook(bookId);
if (!book) return null;
return (
<BookDetailStyle>
<header className="header">
<img className="img" src={getImgSrc(book.img)} alt={book.title} />
<div className="info">
...
<div className="like">
<LikeButton book={book} onClick={likeToggle} />
</div>
...
</div>
</header>
...
</BookDetailStyle>
);
}
components/book/LikeButton.tsx
interface Props {
book: BookDetail;
onClick: () => void;
}
function LikeButton({ book, onClick }: Props) {
return (
<LikeButtonStyle
size="medium"
scheme={book.liked ? 'like' : 'normal'}
onClick={onClick}
>
<FaHeart />
{book.likes}
</LikeButtonStyle>
)
}
장바구니 추가
pages/BookDetail.tsx
좋아요 버튼 밑에 장바구니에 추가하는 버튼을 만든다.
function BookDetail() {
...
return (
<BookDetailStyle>
<header className="header">
...
<div className="info">
...
<div className="like">
<LikeButton book={book} onClick={likeToggle} />
</div>
<div className="add-cart">
<AddToCart book={book} />
</div>
</div>
</header>
...
</BookDetailStyle>
);
}
api/carts.api.ts
장바구니 추가 api로 요청을 보내는 함수를 생성한다.
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;
}
hooks/useBook.ts
도서를 장바구니에 추가하는 함수를 생성한다. quantity는 AddToCart 컴포넌트에서 알고 있으므로 인자로 넣어준다.
3초로 설정한 setTimer는 장바구니 담기를 클릭하면 3초 동안 장바구니에 상품이 담겼음을 알려주는 메시지와 장바구니로 가는 링크를 표시해 주는 역할을 한다.
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
const [cartAdded, setCartAdded] = useState(false);
const { isLoggedIn } = useAuthStore();
const showAlert = useAlert();
const likeToggle = () => {
...
};
const addToCart = (quantity: number) => {
if (!book) return;
addCart({
book_id: book.id,
quantity: quantity
}).then(() => {
setCartAdded(true);
setTimeout(() => {
setCartAdded(false);
}, 3000);
})
}
useEffect(() => {
...
}, [bookId]);
return { book, likeToggle, addToCart, cartAdded };
}
components/book/AddToCart.tsx
수량을 +1 또는 -1 할 수 있는 버튼을 만들고, 해당하는 함수를 각각 연결해 준다.
수량이 음수가 되면 안 되므로 수량이 1인 경우에 마이너스 버튼이 눌리면 무시한다.
cartAdded는 장바구니에 상품이 담겼는지를 의미하는 변수로 boolean 자료형이다. 장바구니에 상품을 담은 후 3초 동안 메시지를 보여준다.
added 앞에 $를 붙이는 이유는 일반 html에 들어가는 어트리뷰트는 string으로 제한하고 있기 때문에 $를 붙이면 회피할 수 있기 때문이다.
...
interface Props {
book: BookDetail;
}
function 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="small" scheme="normal" onClick={handleIncrease}>
+
</Button>
<Button size="small" scheme="normal" onClick={handleDecrease}>
-
</Button>
</div>
<Button size="medium" scheme="primary" onClick={() => addToCart(quantity)}>
장바구니 담기
</Button>
{
cartAdded &&
<div className="added">
<p>장바구니에 추가되었습니다.</p>
<Link to='/cart'>장바구니로 이동</Link>
</div>
}
</AddToCartStyle>
)
}
interface AddToCartStyleProps {
$added: boolean;
}
const AddToCartStyle = styled.div<AddToCartStyleProps>`
...
`;
export default AddToCart;
content
pages/BookDetail.tsx
도서의 상세 정보를 보여준다.
상세 설명이 길면 더 보기 버튼을 사용할 수 있도록 EllipsisBox 컴포넌트를 사용하여 더 보기 버튼을 누르기 전에 보여줄 라인 수를 지정한다.
function BookDetail() {
...
return (
<BookDetailStyle>
<header className="header">
...
</header>
<div className="content">
<Title size='medium'>상세 설명</Title>
<EllipsisBox linelimit={4}>
{book.detail}
</EllipsisBox>
<Title size='medium'>목차</Title>
<p className="index">{book.contents}</p>
</div>
</BookDetailStyle>
);
}
components/common/EllipsisBox.tsx
limitline에 맞게 보여줄 라인 수를 조절하는 것은 css를 사용한다.
expended 변수는 현재 상세 정보가 확장되어 표시되고 있는지에 대한 여부를 나타낸다.
function EllipsisBox({ children, linelimit }: Props) {
const [expended, setExpended] = useState(false);
return (
<EllipsisBoxStyle linelimit={linelimit} $expended={expended}>
<p>{children}</p>
<div className="toggle">
<Button
size="small"
scheme="normal"
onClick={() => setExpended(!expended)}
>
{expended ? '접기' : '펼치기'} <FaAngleDown />
</Button>
</div>
</EllipsisBoxStyle>
)
}
interface EllipsisBoxStyleProps {
linelimit: number;
$expended: boolean;
}
const EllipsisBoxStyle = styled.div<EllipsisBoxStyleProps>`
p {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: ${({ linelimit, $expended }) =>
($expended ? 'none' : linelimit)};
-webkit-box-orientL vertical;
padding: 20px 0;
margin: 0;
}
.toggle {
display: flex;
justify-content: end;
svg {
transform: ${({ $expended }) =>
($expended ? 'rotate(180deg)' : 'rotate(0deg)')};
}
}
`;
배운 점
- 낙관적 업데이트를 하면 불필요한 요청을 하지 않아도 된다.
- 일반 html에 들어가는 어트리뷰트는 string으로 제한하고 있기 때문에 $를 붙이면 회피할 수 있다.
- ellipsis와 -webkit-box로 텍스트의 일부분과 전체를 표시하는 방법을 배웠다.
'데브코스' 카테고리의 다른 글
[15주차 - DAY3] 도서 정보 사이트 - 주문서 (0) | 2024.06.05 |
---|---|
[15주차 - DAY2] 도서 정보 사이트 - 장바구니 목록 (0) | 2024.06.04 |
[14주차 - DAY5] 도서 정보 사이트 - 로그인과 도서 목록 페이지 (0) | 2024.05.31 |
[14주차 주간 발표] CORS (0) | 2024.05.30 |
[14주차 - DAY4] 도서 정보 사이트 - 라우팅 (0) | 2024.05.30 |