리뷰 section
mock/review.ts
메인 화면에 리뷰를 표시하기 위한 가상의 api를 만든다.
export const reviewForMain = http.get('http://localhost:3000/reviews', () => {
return HttpResponse.json(mockReviewData, {
status: 200
})
})
mock/browser.ts
앞서 만들었던 api를 등록한다.
const handlers = [... reviewForMain ...];
export const worker = setupWorker(...handlers);
api/review.api.ts
앞서 생성했던 가상 api로 요청을 보내는 함수를 정의한다.
export const fetchReviewAll = async () => {
return await requestHandler<BookReviewItem>('get', '/reviews');
}
hooks/useMain.ts
useEffect에서 리뷰들을 받아와서 reviews에 저장하고, 이것을 반환하여 다른 파일에서 리뷰들을 사용할 수 있도록 한다.
...
export const useMain = () => {
const [reviews, setReviews] = useState<BookReviewItem[]>([]);
...
useEffect(() => {
fetchReviewAll().then((reviews) => {
setReviews(reviews);
});
...
}, []);
return { reviews ... };
}
pages/Home.tsx
훅에서 리뷰들을 가져와서 화면에 렌더링 한다.
function Home() {
const { reviews ... } = useMain();
return (
<HomeStyle>
...
<section className="section">
<Title size="large">리뷰</Title>
<MainReview reviews={reviews} />
</section>
</HomeStyle>
);
}
components/main/MainReivew.tsx
리뷰를 슬라이드 형태로 나타내기 위해 먼저 slick 라이브러리를 설치한다.
npm i react-slick --save
npm i slick-carousel --save
npm i --save-dev @types/react-slick
slick의 Slider 안에서 각 리뷰들을 화면에 표시한다.
슬라이더 옵션 중에서 slidesToShow는 한 화면에 보이게 할 개수이고, slidesToScroll은 옆으로 넘길 때 몇 개가 넘어가게 할 것인가에 대한 설정이다.
...
import Slider from "react-slick";
import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';
interface Props {
reviews: IBookReviewItem[];
}
function MainReview({ reviews }: Props) {
const sliderSettings = {
dots: true,
infinite: true,
speed: 500,
slidesToShow: 3,
slidesToScroll: 3,
gap: 16
}
return (
<MainReviewStyle>
<Slider {...sliderSettings}>
{
reviews.map((review) => (
<BookReviewItem key={review.id} review={review} />
))
}
</Slider>
</MainReviewStyle>
);
}
const MainReviewStyle = styled.div`
padding: 0 0 24px 0;
.slick-track {
padding: 12px 0;
}
.slick-slide > div {
margin: 0 12px;
}
.slick-prev:before,
.slick-next:before {
color: #000;
}
`;
...
신간 section
hooks/useMain.ts
카테고리 아이디를 undefined로 하고, 신간 여부인 news를 true로 설정하여 도서 목록을 가져온다.
...
export const useMain = () => {
...
const [newBooks, setNewBooks] = useState<Book[]>([]);
...
useEffect(() => {
...
fetchBooks({
category_id: undefined,
news: true,
current_page: 1,
limit: 4
}).then(({ books }) => {
setNewBooks(books);
});
...
}, []);
return { ..., newBooks, ... };
}
components/main/MainNewBooks.tsx
props로 전달된 신간 도서들을 렌더링 하는 컴포넌트이다.
interface Props {
books: Book[];
}
function MainNewBooks({ books }: Props) {
return (
<MainNewBooksStyle>
{
books.map((book) => (
<BookItem key={book.id} book={book} view='grid' />
))
}
</MainNewBooksStyle>
);
}
pages/Home.tsx
위 훅에서 가져온 신간들을 화면에 표시한다.
function Home() {
const { ..., newBooks, ... } = useMain();
return (
<HomeStyle>
// 배너
// 베스트셀러
<section className="section">
<Title size="large">신간 안내</Title>
<MainNewBooks books={newBooks} />
</section>
// 리뷰
</HomeStyle>
);
}
베스트 section
mock/books.ts
베스트셀러 도서 목록을 가져오기 위해 가상 api를 정의한다.
...
const bestBooksData: Book[] = Array.from({ length: 10 }).map((item, index) => ({
id: index,
title: faker.lorem.sentence(),
img: faker.helpers.rangeToNumber({ min: 100, max: 200 }),
category_id: faker.helpers.rangeToNumber({ min: 0, max: 2 }),
form: '종이책',
isbn: faker.commerce.isbn(),
summary: faker.lorem.paragraph(),
detail: faker.lorem.paragraph(),
author: faker.person.firstName(),
pages: faker.helpers.rangeToNumber({ min: 100, max: 500 }),
contents: faker.lorem.paragraph(),
price: faker.helpers.rangeToNumber({ min: 10000, max: 50000 }),
likes: faker.helpers.rangeToNumber({ min: 0, max: 100 }),
pubDate: faker.date.past().toISOString()
}))
export const bestBooks = http.get('http://localhost:3000/books/best', () => {
return HttpResponse.json(bestBooksData, {
status: 200
})
})
api/books.api.ts
위에서 만든 가상 api로 요청을 보내는 함수를 정의한다.
export const fetchBestBooks = async () => {
const response = await httpClient.get<Book[]>(`books/best`);
return response.data;
}
hooks/useMain.ts
훅으로 베스트셀러 도서들을 가져온다.
...
export const useMain = () => {
...
const [bestBooks, setBestBooks] = useState<Book[]>([]);
...
useEffect(() => {
...
fetchBestBooks().then((books) => {
setBestBooks(books);
});
...
}, []);
return { ..., bestBooks, ... };
}
pages/Home.tsx
위에서 생성한 훅으로 베스트셀러 도서 목록을 받아오고, 베스트셀러를 화면에 표시한다.
function Home() {
const { ..., bestBooks, ... } = useMain();
return (
<HomeStyle>
// 배너
<section className="section">
<Title size="large">베스트 셀러</Title>
<MainBest books={bestBooks} />
</section>
// 신간 안내
// 리뷰
</HomeStyle>
);
}
components/main/MainBest.tsx
props로 받은 도서 목록을 map으로 순회하며 각 도서를 화면에 표시한다.
interface Props {
books: Book[];
}
function MainBest({ books }: Props) {
return (
<MainBestStyle>
{
books.map((item, index) => (
<BookBestItem key={item.id} book={item} itemIndex={index} />
))
}
</MainBestStyle>
);
}
components/books/BookBestItem.tsx
도서 목록을 조회할 때 사용되는 컴포넌트 BookItem과 유사하기 때문에 BookItem의 스타일을 오버라이드한다.
interface Props {
book: Book;
itemIndex: number;
}
function BookBestItem({ book, itemIndex }: Props) {
return (
<BookBestItemStyle>
<BookItem book={book} view="grid" />
<div className="rank">{itemIndex + 1}</div>
</BookBestItemStyle>
);
}
const BookBestItemStyle = styled.div`
${BookItemStyle} {
.summary,
.price,
.likes {
display: none;
}
...
}
position: relative;
.rank {
...
}
`;
배너 section
models/banner.model.ts
배너의 모델을 정의한다.
export interface Banner {
id: number;
title: string;
description: string;
image: string;
url: string;
target: string;
}
mock/banner.ts
배너 데이터를 반환해 주는 가상 api를 만든다.
...
const bannersData: Banner[] = [
{
id: 1,
title: '배너 1 제목',
description: 'Banner 1 description',
image: 'https://picsum.photos/id/111/1200/400',
url: 'http://some.url',
target: '_blank'
},
{
id: 2,
title: '배너 2 제목',
description: 'Banner 2 description',
image: 'https://picsum.photos/id/222/1200/400',
url: 'http://some.url',
target: '_self'
},
{
id: 3,
title: '배너 3 제목',
description: 'Banner 3 description',
image: 'https://picsum.photos/id/113/1200/400',
url: 'http://some.url',
target: '_blank'
}
]
export const banners = http.get('http://localhost:3000/banners', () => {
return HttpResponse.json(bannersData, {
status: 200
})
})
api/banner.api.ts
위에서 만든 가상 api로부터 배너 정보를 받아오는 함수를 정의한다.
export const fetchBanners = async () => {
return await requestHandler<Banner[]>('get', '/banners');
}
hooks/useMain.ts
훅에서 배너 데이터를 가져오고, 다른 파일에서 배너 데이터를 사용할 수 있도록 리턴해준다.
...
export const useMain = () => {
...
const [banners, setBanners] = useState<Banner[]>([]);
useEffect(() => {
...
fetchBanners().then((banners) => {
setBanners(banners);
});
}, []);
return { ..., banners };
}
pages/Home.tsx
배너를 화면에 표시한다.
function Home() {
const { ..., banners } = useMain();
return (
<HomeStyle>
<Banner banners={banners} />
// 베스트 셀러
// 신간 안내
// 리뷰
</HomeStyle>
);
}
components/common/banner/BannerItem.tsx
화면에 배너는 하나만 보여야 하므로 flex grow와 shrink는 0, basis는 100%로 설정한다.
interface Props {
banner: IBanner;
}
function BannerItem({ banner }: Props) {
return (
<BannerItemStyle>
<div className="img">
<img src={banner.image} alt={banner.title} />
</div>
<div className="content">
<h2>{banner.title}</h2>
<p>{banner.description}</p>
</div>
</BannerItemStyle>
);
}
const BannerItemStyle = styled.div`
flex: 0 0 100%;
display: flex;
...
`;
components/common/banner/Banner.tsx
배너에 들어갈 아이템들이 차례대로 가로로 나열되어 있다고 가정하자. 그러면 오른쪽 버튼을 클릭하면 배너 1개의 크기만큼 왼쪽으로 이동해야 한다. 따라서 transformValue 값에 X축 방향으로 얼마나 이동할지에 대해 저장한다. 마이너스인 이유는 오른쪽 버튼을 클릭했을 때 배너들은 왼쪽으로 이동해야하기 때문이고, %로 나타낼 것이기 때문에 100이라고 적는다. 그리고 BannerItem에서 각 배너들의 flex grow, shrink를 0, basis는 100%로 설정하고 배너들을 감싸는 div인 BannerStyle에서 overflow를 hidden으로 설정했기 때문에 하나의 배너만 화면에 보이게 된다.
왼쪽, 오른쪽 버튼 클릭 시 배너를 무한으로 돌아갈 수 있게 하지 않게 할 것이므로 배너의 첫 인덱스와 마지막 인덱스인 경우에 대해 따로 처리를 해준다.
...
interface Props {
banners: IBanner[];
}
function Banner({ banners }: Props) {
const [currentIndex, setCurrentIndex] = useState(0);
const transformValue = useMemo(() => {
return currentIndex * -100;
}, [currentIndex])
const handlePrev = () => {
if (currentIndex === 0) return;
setCurrentIndex(currentIndex - 1);
}
const handleNext = () => {
if (currentIndex === banners.length - 1) return;
setCurrentIndex(currentIndex + 1);
}
const handleIndicatorClick = (index: number) => {
setCurrentIndex(index);
}
return (
<BannerStyle>
<BannerContainerStyle $transformValue={transformValue}>
{
banners.map((item) => (
<BannerItem banner={item} />
))
}
</BannerContainerStyle>
<BannerButtonStyle>
<button className="prev" onClick={handlePrev}><FaAngleLeft /></button>
<button className="next" onClick={handleNext}><FaAngleRight /></button>
</BannerButtonStyle>
<BannerIndicatorStyle>
{
banners.map((banner, index) => (
<span
className={index === currentIndex ? 'active' : ''}
onClick={() => handleIndicatorClick(index)}
></span>
))
}
</BannerIndicatorStyle>
</BannerStyle>
);
}
const BannerStyle = styled.div`
overflow: hidden;
position: relative;
`;
interface BannerContainerStyleProps {
$transformValue: number;
}
const BannerContainerStyle = styled.div<BannerContainerStyleProps>`
display: flex;
transform: translateX(${(props) => props.$transformValue}%);
transition: transform 0.5s ease-in-out;
`;
const BannerButtonStyle = styled.div`
...
`;
const BannerIndicatorStyle = styled.div`
...
`;
export default Banner;
배운 점
- slick 라이브러리로 슬라이드를 간단하게 구현할 수 있다.
- 동일한 컴포넌트를 스타일만 오버라이딩하여 재활용할 수 있다.
- flex 속성과 translateX를 이용하여 슬라이더를 직접 구현하는 방법을 배웠다.
'데브코스' 카테고리의 다른 글
[16주차 - DAY4] 도커 이미지 생성 (1) | 2024.06.13 |
---|---|
[16주차 - DAY4] 도서 정보 사이트 - 모바일 대응, 도커 (0) | 2024.06.12 |
[16주차 - DAY2] 도서 정보 사이트 - 다양한 UI (0) | 2024.06.11 |
[16주차 - DAY1] 도서 정보 사이트 - 리뷰 (0) | 2024.06.10 |
[15주차 - DAY5] 스니펫 (0) | 2024.06.07 |