
π― λμ μμΈ νμ΄μ§λ₯Ό μ μν©λλ€.
import { useEffect, useState } 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 [cartAdded, setCartAdded] = useState(false);
const likeToggle = () => {
if (!isLoggedIn) {
showAlert('λ‘κ·ΈμΈμ΄ νμν©λλ€.');
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 (!book) return;
addCart({
bookId: 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 };
};
μ±
μ 보(book), μ’μμ μΆκ°(likeToggle), μ₯λ°κ΅¬λ μΆκ° (addToCart), μ₯λ°κ΅¬λμ μΆκ° λμλμ§μ μ¬λΆ(cartAdded)λ₯Ό λ΄λ³΄λ΄λ λμ μν κ΄λ¦¬ 컀μ€ν
ν
μ
λλ€.
book : νμ¬ λμ μ 보λ₯Ό μ μ₯ν©λλ€. (BookDetail | null νμ
)
isLoggedIn : μ¬μ©μκ° λ‘κ·ΈμΈν μνμΈμ§ μ¬λΆλ₯Ό κ°μ Έμ΅λλ€. (useAuthStore ν
μ¬μ©)
showAlert : μλ¦Όμ νμνλ ν¨μμ
λλ€. (λ‘κ·ΈμΈ νμν λ μ¬μ©)
cartAdded : μ₯λ°κ΅¬λ μΆκ° μλ£ μ¬λΆλ₯Ό μ μ₯ν©λλ€. (true / false)
likeToggle : λμμ μ’μμ μνλ₯Ό ν κΈνλ ν¨μμ
λλ€.
μ΄λ―Έ μ’μμν κ²½μ° unlikeBook νΈμΆ ( μ’μμ - 1 )
μ’μμνμ§ μμ κ²½μ° likeBook νΈμΆ ( μ’μμ + 1 )
addToCart : νμ¬ λμλ₯Ό μ₯λ°κ΅¬λμ μ§μ μλλ§νΌ μΆκ°νλ ν¨μμ
λλ€.
μΆκ° μλ£ μ cartAddedλ₯Ό trueλ‘ λ°κΎΈκ³ 3μ΄ λ€ falseλ‘ μ΄κΈ°νν©λλ€.
useEffect : bookIdκ° μμ λ λμ μμΈ μ 보λ₯Ό APIλ‘ λΆλ¬μμ bookμ μ μ₯ν©λλ€.
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import { useBook } from '../hooks/useBook';
import { getImgSrc } from '../utils/image';
import Title from '../components/common/Title';
import { BookDetail as IBookDetail } from '../models/book.model';
import { formatDate, formatNumber } from '../utils/format';
import { Link } from 'react-router-dom';
import { Theme } from '../style/theme';
import EllipsisBox from '../components/common/EllipsisBox';
import LikeButton from '../components/book/LikeButton';
import AddToCart from '../components/book/AddToCart';
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)}μ`;
},
},
];
export default function BookDetail() {
const { bookId } = useParams();
const { book, likeToggle } = useBook(bookId);
if (!book) return null;
return (
<StyledBookDetail>
<header className='header'>
<div className='content'>
<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>
))}
<dl>
<dt>μΉ΄ν
κ³ λ¦¬</dt>
<dd>{book.categoryName}</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={2}>{book.detail}</EllipsisBox>
<Title size='medium'>λͺ©μ°¨</Title>
<p className='index'>{book.contents}</p>
</div>
</StyledBookDetail>
);
}
const StyledBookDetail = styled.div<{ theme: Theme }>`
.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.color.background};
}
a {
color: ${({ theme }) => theme.color.primary};
}
}
}
`;
λμ μμΈ μ 보λ₯Ό 보μ¬μ£Όλ νμ΄μ§ μ»΄ν¬λνΈμ λλ€.
bookInfoList : λμ κΈ°λ³Έ μ 보λ₯Ό νμν νλͺ© 리μ€νΈμ
λλ€.
bookId : URL νλΌλ―Έν°μμ κ°μ Έμ¨ λμ IDμ
λλ€. (useParams μ¬μ©)
book, likeToggle : useBook ν
μ ν΅ν΄ κ°μ Έμ¨ νμ¬ λμ μ 보μ μ’μμ ν ν΄ ν¨μμ
λλ€.
headerμ μ΄λ―Έμ§μ μ± μ΄λ¦, μΉ΄ν κ³ λ¦¬, ν¬λ§·, νμ΄μ§, ISBN, μΆκ°μΌ κ°κ²©μ μκ°νκ³ , contentμλ μμΈμ€λͺ κ³Ό λͺ©μ°¨λ₯Ό μ€λͺ ν©λλ€.
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;
}
export default function LikeButton({ book, onClick }: Props) {
return (
<StyledLikeButton
size='medium'
scheme={book.liked ? 'like' : 'normal'}
onClick={onClick}
>
<FaHeart />
{book.likes}
</StyledLikeButton>
);
}
const StyledLikeButton = styled(Button)`
display: flex;
gap: 6px;
svg {
color: inherit;
* {
color: inherit;
}
}
`;
μ±
μ 보(book)μ ν΄λ¦ μ ν¨μ(onClick)λ₯Ό λ겨 λ°μμ μ€νμν€λ μ’μμ λ²νΌ μ»΄ν¬λνΈμ
λλ€.
import styled from 'styled-components';
import { BookDetail } from '../../models/book.model';
import InputText from '../common/InputText';
import Button from '../common/Button';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Theme } from '../../style/theme';
import { useBook } from '../../hooks/useBook';
interface Props {
book: BookDetail;
}
export default 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 (
<StyledAddToCart $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='/carts'>μ₯λ°κ΅¬λλ‘ μ΄λ</Link>
</div>
</StyledAddToCart>
);
}
interface AddToCartStyleProps {
$added: boolean;
}
const StyledAddToCart = styled.div<AddToCartStyleProps>`
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
.added {
position: absolute;
right: 0;
bottom: -90px;
background: ${({ theme }) => (theme as Theme).color.secondary};
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
padding: 8px 12px;
opacity: ${({ $added }) => ($added ? '1' : '0')};
transition: all 0.5s ease;
p {
padding: 0 0 8px 0;
margin: 0;
}
}
`;
μ₯λ°κ΅¬λ μΆκ° κΈ°λ₯μ μ 곡νλ μ»΄ν¬λνΈμ λλ€.
handleChange : μλ μ
λ ₯μ°½μ κ°μ λ³κ²½ν λ νΈμΆνλ ν¨μμ
λλ€.
handleIncrease : μλμ 1 μ¦κ°μν€λ ν¨μμ
λλ€.
handleDecrease : μλμ΄ 1λ³΄λ€ ν¬λ©΄ 1 κ°μμν€λ ν¨μμ
λλ€.
import { 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;
}
export default function EllipsisBox({ children, lineLimit }: Props) {
const [expanded, setExpanded] = useState(false);
return (
<StyledEllipsisBox lineLimit={lineLimit} $expanded={expanded}>
<p>{children}</p>
<div className='toggle'>
<Button
size='small'
scheme='normal'
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'μ κΈ°' : 'νΌμΉκΈ°'} <FaAngleDown />
</Button>
</div>
</StyledEllipsisBox>
);
}
interface EllipsisBoxStyleProps {
lineLimit: number;
$expanded: boolean;
}
const StyledEllipsisBox = 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)'};
}
}
`;
νΌμΉκΈ°/μ κΈ° κΈ°λ₯μ μ 곡νλ ν μ€νΈ λ°μ€μ λλ€.
children : μ»΄ν¬λνΈ λ΄λΆμ νμν ν
μ€νΈ λλ μ½ν
μΈ μ
λλ€.
lineLimit : κΈ°λ³ΈμΌλ‘ 보μ¬μ€ ν
μ€νΈ μ€μ κ°μμ
λλ€.
expanded : νμ¬ λ°μ€κ° νΌμ³μ Έ μλμ§ μ¬λΆμ
λλ€. (true / false)
text-overflow: ellipsis; : ν
μ€νΈκ° μ릴 λ λ§μ€μν(...)λ₯Ό νμν λ μ¬μ©νλ μμ±μ
λλ€.
display: -webkit-box; : flexboxμ²λΌ λ°μ€λ₯Ό λ€λ£¨λ λ μ΄μμ λ°©μμ
λλ€.
π€ μ
-webkitμ μ¨μΌν κΉ?ν μ€νΈ μ€ μλ₯Ό μ ν(
clamp)λ₯Ό νλ €λ©΄ κΈ°λ³Έ CSS μ€νμλ μκΈ° λλ¬Έμ λΈλΌμ°μ λ μ κΈ°μ μ μ¨μΌν©λλ€.
λΈλΌμ°μ λ§λ€ λ€λ₯΄κ² μ§μνκΈ° λλ¬Έμ-webkit-μ λΆμ¬μΌ ν¬λ‘¬, μ¬ν리 κ°μ λΈλΌμ°μ μμ λμν μ μμ΅λλ€.

κΈ°λ³Έ CSSμμ μ 곡νμ§ μλ κΈ°μ λ€μ λΈλΌμ°μ λ μ κΈ°μ λ‘ μ¬μ©νλ λ°©λ²μ΄ μλ μ€ λͺ°λλ€. λΈλΌμ°μ λ μ κΈ°μ μ΄λ€λ³΄λ μμ μ μ΄μ§ μκΈ° λλ¬Έμ W3Cμμλ λ€μν CSS κΈ°μ λ€μ΄ μ§μλμμΌλ©΄ μ’κ² λ€.