티스토리 뷰
💥 도서 상세 화면 구현하기
- 도서 상세 화면 요구 사항
1) 각 도서 상세 정보 노출
2) 좋아요 버튼 클릭 시, 좋아요 또는 취소 기능
3) 수량을 입력하여 장바구니 담기
1. BookDetail.tsx
import { 생략 }
const bookInfoList = [
{
label: "카테고리",
key: "category_name",
filter: (book: IBookDetail) => (
<Link to={`/books?category_id=${book.category_id}`}>{book.category_name}</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">
<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>
<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>
<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>
)
}
const BookDetailStyle = styled.div`
{ 생략 }
`
export default BookDetail
2. LikeButton.tsx
import { 생략 }
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>
)
}
const LikeButtonStyle = styled(Button)`
{ 생략 }
`
export default LikeButton
3. AddToCart.tsx
import { 생략 }
interface Props {
book: BookDetail
}
function AddToCart({ book }: Props) {
const [quantity, setQuantity] = useState<number>(1)
const { addToCart, cartAdded } = useBook(book.id.toString())
const showAlert = useAlert()
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuantity(Number(e.target.value))
}
const handleIncrese = () => {
setQuantity(quantity + 1)
}
const handleDecrese = () => {
if (quantity === 1){
showAlert("최소 수량은 1개 입니다.")
return
}
setQuantity(quantity - 1)
}
return (
<AddToCartStyle $added={cartAdded}>
<div>
<InputText inputType="number" value={quantity} onChange={handleChange} />
<Button size="medium" scheme="normal" onClick={handleIncrese}>
+
</Button>
<Button size="medium" scheme="normal" onClick={handleDecrese} >
-
</Button>
</div>
<Button size="medium" scheme="primary" onClick={() => addToCart(quantity)}>
장바구니 담기
</Button>
<div className="added">
<p>장바구니에 추가되었습니다.</p>
<Link to="/cart">장바구니로 이동</Link>
</div>
</AddToCartStyle>
)
}
interface AddToCartStyleProps {
$added: boolean
}
const AddToCartStyle = styled.div<AddToCartStyleProps>`
{ 생략 }
`
export default AddToCart
4. useBook.ts
import { 생략 }
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 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,
})
})
}
}
const addToCart = (quantity: number) => {
if (!isloggedIn) {
showAlert('로그인이 필요합니다.')
navigate('/login')
return
}
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 }
}
5. books.api.ts (추가)
export const fetchBook = async (bookId: string) => {
const response = await httpClient.get<BookDetail>(`/books/${bookId}`)
return response.data
}
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
}
6. 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
}
7. EllipsisBox.tsx
import { 생략 }
interface Props {
children: React.ReactNode
linelimit: number
}
function 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 {
padding: 10px 0 0 0;
display: flex;
justify-content: end;
svg {
transform: ${({ $expanded }) => $expanded ? 'rotate(180deg)' : 'rotate(0)' };
}
button {
display: flex;
align-items: center;
}
}
`
export default EllipsisBox
💥 상세 도서 화면 결과물
이번에 상세 도서 화면에서 중점적인 기능은 '좋아요 삭제, 추가 기능'과 '장바구니 기능' 이었다.
장바구니든 좋아요든 로그인 후에 가능하게끔 만들었다.
근데 서버쪽에서 또 그걸 따로 안 해놔서.. ㅋㅋㅋ 401 에러가 아닌 400 에러가 떠서 뭐지 싶었는데 알고 보니 안 했던 것^^...
그래서 그냥 클라이언트 측에서 설정해주었다.
로그아웃인 상태엔, 좋아요&장바구니 기능을 사용할 시,
"로그인이 필요합니다." 라는 알림창을 띄운 후, 로그인 화면으로 이동하게끔 만들었다.
- 장바구니 기능
장바구니 기능에서 - 를 눌렀을 때, 1 이하로 떨어지면 안되게 설정도 해놓았다.
강사님께서는 그냥 0 으로 못 내려가게 바로 return 하셨지만, 괜시리 알림창 띄우고 싶었다..ㅎ
그래서 이렇게 1 인 상태에서 - 버튼을 누르면 "최소 수량은 1개 입니다." 라는 알림창을 띄우게 만들었다.
그리고 수량 선택 후, 장바구니 담기를 누르면 아래에 떴다가 3초 뒤에 사라지는 텍스트도 만들었다.
따로 디자인에 힘을 주진 않았지만, 나중에 시간나면 더 예쁘게 디자인을 바꿔봐도 좋을 것 같다 :)
저 텍스트는, 3초 뒤에 사라지게 하기 위해 setTimeout 을 사용했다.
장바구니로 이동 -> 이라는 링크는 누르면 /cart 로 넘어가게끔 Link 설정을 해주었다.
- 좋아요 기능
좋아요 기능은 상세 도서 페이지에서만 가능한 기능이다.
liked가 true라면 클릭을 했을 때, liked가 false로 바뀌고, likes는 내려가면서 회색+검정 조합으로 바뀌고,
liked가 false라면 클릭을 했을 때, liked가 true로 바뀌고, likes는 올라가면서 코랄+흰색 조합으로 바뀐다.
- 펼치기, 접기 버튼
상세 설명은 보다 긴 텍스트 조합이기 때문에, 한 번에 다 보여주기엔 미관상 좋지 않고 가독성도 떨어진다.
그래서 펼치기 및 접기 버튼을 만들었다. 필요할 때마다 사용하기 위해서 따로 파일을 만들어서 쓰기로 했다.
-webkit-line-clamp 라는 css로 보여주고싶은 줄만큼만 보여지게 할 수 있다.
또한 text-overflow: ellipsis 라는 css를 이용해서 말줄임표 (...)로 마무리할 수 있게 하였다.
'펼치기' 텍스트일 땐 화살표가 ↓밑을 향하게 하였고, transform: rotate를 사용해서 '접기' 텍스트일 땐, ↑위로 향하게 하였다.
드디어 서버쪽 문제들을 다 고쳐내고 데이터 타입도 서버와 클라이언트 똑같이 설정해놓았다.
그래서인지 드디어 서버쪽 이유인 에러는 뜨지않았고, 단 하나.. 비로그인 시 401 뜨게 하는 것만 빼놓았다..ㅎ
그 부분도 오늘 다 작업을 해놓아야 내일 강의에서도 덜 거슬릴 것 같다!
에러가 안 뜨다보니 진도도 쭉쭉 나가고 화면도 잘 나와서 시간 가는 줄 모르고 강의들으며 작업했던 것 같다!
확실히 디자인을 했던 사람이라 그런 지, 디자인할 때 재밌긴 하다.. 물론 내가 직접 디자인 하는 건 스트레스지만..^^
'웹 개발 공부하기' 카테고리의 다른 글
[02.10] 스니펫(Snippet) (0) | 2025.02.15 |
---|---|
[02.07] 장바구니, 주문서 화면 구현해보기🤗 (2) | 2025.02.15 |
[02.05] 로그인 API, 도서 API - Front (1) | 2025.02.13 |
[02.04] 라우트 작성과 회원가입 API (3) | 2025.02.07 |
[02.03] 기본 컴포넌트 작성하기🤔 (1) | 2025.02.06 |