2024년 11월 21일
// mock / review.ts
... 생략 ...
export const reviewForMain = http.get("http://127.0.0.1:9999/reviews", () => {
return HttpResponse.json(mockReviewsData, {
status: 200,
});
});
// mock / browser.ts
import { setupWorker } from "msw/browser";
import { addReview, reviewForMain, reviewsById } from './review';
const handlers = [reviewsById, addReview, reviewForMain];
export const worker = setupWorker(...handlers);
// api / review.api.ts
... 생략 ...
export const fetchReviewAll = async () => {
return await requestHandler<BookReviewItem>("get", "/reviews");
}
... 생략 ...
// hooks / useMain.ts
import { fetchReviewAll } from '@/api/review.api';
import { BookReviewItem } from '@/models/book.model';
import { useEffect, useState } from 'react'
export const useMain = () => {
const [reviews, setReviews] = useState<BookReviewItem[]>([]);
useEffect(()=>{
fetchReviewAll().then((reviews)=> {
setReviews(reviews);
})
},[]);
return { reviews };
}
// pages / Home.tsx
import MainReview from '@/components/main/HomeReview';
import { useMain } from '@/hooks/useMain';
import styled from 'styled-components';
const Home = () => {
const { reviews } = useMain();
return (
<HomeStyle>
{/* 배너 */}
{/* 베스트셀러 */}
{/* 신간 */}
{/* 리뷰 */}
<MainReview reviews={reviews} />
</HomeStyle>
);
}
const HomeStyle = styled.div`
color: ${({ theme }) => theme.colors.primary};
`;
export default Home;
// components / main / HomeReview.tsx
import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import styled from 'styled-components';
import BookReviewItem from '../book/BookReviewItem';
interface Props {
reviews: IBookReviewItem[];
}
const MainReview = ({reviews}: Props) => {
return (
<MainReviewStyle>
{
reviews.map((review)=> (
<BookReviewItem key={review.id} review={review} />
))
}
</MainReviewStyle>
);
}
const MainReviewStyle = styled.div``;
export default MainReview;
react-slick 라이브러리를 이용해서 틀 만들어주기
react-slick
슬라이드 임포트
// components / main / HomeReview.tsx
import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import styled from 'styled-components';
import BookReviewItem from '../book/BookReviewItem';
import Slider from 'react-slick';
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
interface Props {
reviews: IBookReviewItem[];
}
const 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;
}
`;
export default MainReview;
// hooks / useMain.ts
... 생략 ...
const [newBooks, setNewBooks] = useState<Book[]>([]);
... 생략 ...
fetchBooks({
category_id: undefined,
news: true,
currentPage: 1,
limit: 4,
}).then(({books})=> setNewBooks(books));
},[]);
return { reviews, newBooks };
}
// pages / Home.tsx
... 생략 ...
<section className="sec">
<Title size="large">신간 안내</Title>
<MainNewBooks books={newBooks} />
... 생략 ...
// components / main / MainNewBooks.tsx
import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookItem from '../books/BookItem';
interface Props {
books: Book[];
}
const MainNewBooks = ({books}: Props) => {
return (
<MainNewBooksStyle>
{
books.map((book)=>(<BookItem key={book.id} book={book} view="grid" />))
}
</MainNewBooksStyle>
);
}
const MainNewBooksStyle = styled.div`
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
`;
export default MainNewBooks;
// mock / books.ts
import { Book } from '@/models/book.model';
import { http, HttpResponse } from "msw";
import { fakerKO as faker } from "@faker-js/faker";
const bestBooksData: Book[] = Array.from({length: 10}).map((item, index)=>(
{
id: index,
title: faker.lorem.sentence(),
img: (faker.helpers as any).rangeToNumber({ min: 100, max: 200}),
category_id: (faker.helpers as any).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 as any).rangeToNumber({ min: 100, max: 500 }),
contents: faker.lorem.paragraph(),
price: (faker.helpers as any).rangeToNumber({ min: 10000, max: 50000 }),
likes: (faker.helpers as any).rangeToNumber({ min: 0, max: 100 }),
pub_date: faker.date.past().toISOString(),
}
))
export const bestBooks = http.get("http://127.0.0.1:9999/books/best", () => {
return HttpResponse.json(bestBooksData, {
status: 200
})
})
// mock / browser.ts
import { setupWorker } from "msw/browser";
import { addReview, reviewForMain, reviewsById } from './review';
import { bestBooks } from './books';
const handlers = [reviewsById, addReview, reviewForMain, bestBooks];
export const worker = setupWorker(...handlers);
// api / books.api.ts
... 생략 ...
export const fetchBestBooks = async () => {
const response = await httpClient.get<Book[]>(`/books/best`);
return response.data;
}
// hooks / useMain.ts
... 생략 ...
const [bestBooks, setBestBooks] = useState<Book[]>([]);
... 생략 ...
fetchBestBooks().then((books)=> setBestBooks(books));
},[]);
return { reviews, newBooks, bestBooks };
}
// pages / Home.tsx
... 생략 ...
{/* 베스트셀러 */}
<section className="sec">
<Title size="large">베스트 셀러</Title>
<MainBest books={bestBooks} />
</section>
... 생략 ...
// components / main / mainBest.tsx
import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookBestItem from '../books/BookBestItem';
interface Props {
books: Book[];
}
const MainBest = ({books}: Props) => {
return (
<MainBestStyle>
{
books.map((item, index)=> (
<BookBestItem key={item.id} book={item} itemIndex={index} />))
}
</MainBestStyle>
);
}
const MainBestStyle = styled.div`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
`;
export default MainBest;
// components / books / BookBestItem.tsx
import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookItem, { BookItemStyle } from './BookItem';
interface Props {
book: Book;
itemIndex: number;
}
const 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;
}
h2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
position: relative;
.rank {
position: absolute;
top: -10px;
left: -10px;
width: 40px;
height: 40px;
background: ${({theme}) => theme.colors.primary};
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: #fff;
font-weight: 700;
font-stye: italic;
}
`;
export default BookBestItem;
// models / bannel.model.ts
export interface Banner {
id: number;
title: string;
description: string;
image: string;
url: string;
target: string;
}
// mock / banner.ts
import { http, HttpResponse } from "msw";
import { fakerKO as faker } from "@faker-js/faker";
import { Banner } from '@/models/bannel.model';
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/33/1200/400",
url: "http://some.url",
target: "_blank",
}
]
export const banners = http.get("http://127.0.0.1:9999/banners", () => {
return HttpResponse.json(bannersData, {
status: 200,
})
})
// mock /browser.ts
import { setupWorker } from "msw/browser";
import { addReview, reviewForMain, reviewsById } from './review';
import { bestBooks } from './books';
import { banners } from './banner';
const handlers = [reviewsById, addReview, reviewForMain, bestBooks, banners];
export const worker = setupWorker(...handlers);
// api / banner.api.ts
import { Banner } from '@/models/bannel.model';
import { requestHandler } from './http';
export const fetchBanners = async () => {
return await requestHandler<Banner[]>("get", "/banners");
}
// hooks / useMain.ts
const [banners, setBanners] = useState<Banner[]>([]);
... 생략 ...
fetchBanners().then((banners)=> setBanners(banners));
},[]);
return { reviews, newBooks, bestBooks, banners };
}
// pages / Home.tsx
... 생략 ...
const Home = () => {
const { reviews, newBooks, bestBooks, banners} = useMain();
return (
<HomeStyle>
{/* 배너 */}
<Banner banners={banners} />
... 생략 ...
// components / banner / Banner.tsx
import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';
import BannerItem from './BannerItem';
interface Props {
banners: IBanner[];
}
const Banner = ({banners}: Props) => {
return (
<BannerStyle>
{
banners.map((item, index)=>(
<BannerItem key={item.id} banner={item} />
))
}
</BannerStyle>
);
}
const BannerStyle = styled.div``;
export default Banner;
// components / banner / BannerItem.tsx
import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';
interface Props {
banner: IBanner;
}
const 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`
display: flex;
align-items: center;
justify-content: center;
text-align: center;
position: relative;
.img {
width: 100%;
max-width: 100%;
}
.content {
position: absolute;
top: 0;
left: 0;
width: 40%;
height: 100%;
background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
h2 {
font-size: 2rem;
font-eight: 700;
margin-bottom: 1rem;
color: ${({theme})=> theme.colors.primary};
}
p {
font-size: 1.2rem;
color: ${({theme})=> theme.colors.text};
margin: 0;
}
}
`;
export default BannerItem;
// components / banner / Banner.tsx
import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';
import BannerItem from './BannerItem';
import { useMemo, useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
interface Props {
banners: IBanner[];
}
const 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, index)=>(
<BannerItem key={item.id} 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`
button {
border: 0;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.5);
border-radius: 100%;
font-size: 2rem;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 50%;
transform: translateY(-50%);
svg {
fill: #fff;
}
&.prev {
left: 10px;
}
&.next {
right: 10px;
}
}
`;
const BannerIndicatorStyle = styled.div`
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
span {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 100%;
background: #fff;
margin: 0 4px;
cursor: pointer;
&.active {
background: ${({theme}) => theme.colors.primary};
}
}
`;
export default Banner;
// components / banner / BannerItem.tsx
import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';
interface Props {
banner: IBanner;
}
const 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;
align-items: center;
justify-content: center;
text-align: center;
position: relative;
.img {
img {
width: 100%;
max-width: 100%;
}
}
.content {
position: absolute;
top: 0;
left: 0;
width: 40%;
height: 100%;
background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
h2 {
font-size: 2rem;
font-eight: 700;
margin-bottom: 1rem;
color: ${({theme})=> theme.colors.primary};
}
p {
font-size: 1.2rem;
color: ${({theme})=> theme.colors.text};
margin: 0;
}
}
`;
export default BannerItem;
// hooks / useMediaQuery.ts
import { getTheme } from '@/style/theme';
import { useEffect, useState } from 'react'
export const useMediaQuery = () => {
const [isMobile, setIsMobile] = useState(window.matchMedia(getTheme("light").mediaQuery.mobile).matches);
useEffect(()=>{
const isMobileQuery = window.matchMedia(getTheme("light").mediaQuery.mobile);
setIsMobile(isMobileQuery.matches);
}, []);
return { isMobile };
}
// style / theme.ts
... 생략 ...
export type MediaQuery = "mobile" | "tablet" | "desktop";
... 생략 ...
mediaQuery: {
[key in MediaQuery] : string;
}
}
... 생략 ...
mediaQuery: {
mobile: "(max-width: 768px)", // 768px 이하에서 동작
tablet: "(max-width: 1024px)", // 1024px 이하에서 동작
desktop: "(min-width: 1025px)" // 1025px 이상에서 동작
}
};
// pages / Header.tsx
... 생략 ...
const [ isMobileOpen, setIsMobileOpen ] = useState(false);
return (
<HeaderStyle $isOpen={isMobileOpen}>
... 생략 ...
<nav className="category">
<button className="menu-button" onClick={() => setIsMobileOpen(!isMobileOpen)}>
{ isMobileOpen ? <FaAngleRight /> : <FaBars />}
</button>
... 생략 ...
interface HeaderStyleProps {
$isOpen: boolean;
}
const HeaderStyle = styled.header<HeaderStyleProps>`
... 생략 ...
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
height: 52px;
.logo {
padding: 0 0 0 12px;
img {
width: 60px;
}
}
.auth {
position: absolute;
top: 12px;
right: 12px;
}
.category {
.menu-button {
display: flex;
position: absolute;
top: 14px;
right: ${({$isOpen}) => ($isOpen ? "60%" : "52px")};
background: #fff;
border: 0;
font-size: 1.5rem;
}
ul {
position: fixed;
top: 0;
right: ${({$isOpen}) => ($isOpen ? "0" : "-100%")};
width: 60%;
height: 100vh;
background: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transition: right 0.3s ease-in-out;
margin: 0;
padding: 24px;
z-index: 1000;
flex-direction: column;
gap: 16px;
li {
a {
font-size: 1.1rem;
}
}
}
}
}
`;
export default Header;
// components / common / Header.tsx
... 생략 ...
const [ isMobileOpen, setIsMobileOpen ] = useState(false);
return (
<HeaderStyle $isOpen={isMobileOpen}>
... 생략 ...
<nav className="category">
<button className="menu-button" onClick={() => setIsMobileOpen(!isMobileOpen)}>
{ isMobileOpen ? <FaAngleRight /> : <FaBars />}
</button>
... 생략 ...
interface HeaderStyleProps {
$isOpen: boolean;
}
const HeaderStyle = styled.header<HeaderStyleProps>`
.... 생략 ...
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
height: 52px;
.logo {
padding: 0 0 0 12px;
img {
width: 60px;
}
}
.auth {
position: absolute;
top: 12px;
right: 12px;
}
.category {
.menu-button {
display: flex;
position: absolute;
top: 14px;
right: ${({$isOpen}) => ($isOpen ? "60%" : "52px")};
background: #fff;
border: 0;
font-size: 1.5rem;
}
ul {
position: fixed;
top: 0;
right: ${({$isOpen}) => ($isOpen ? "0" : "-100%")};
width: 60%;
height: 100vh;
background: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transition: right 0.3s ease-in-out;
margin: 0;
padding: 24px;
z-index: 1000;
flex-direction: column;
gap: 16px;
li {
a {
font-size: 1.1rem;
}
}
}
}
}
`;
export default Header;
// components / MainBest.tsx
... 생략 ...
const MainBestStyle = styled.div`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
grid-template-columns: repeat(2, 1fr);
}
`;
export default MainBest;
// componenets / layout / Layout.tsx
... 생략 ...
const LayoutStyle = styled.main`
width: 100%;
margin: 0 auto;
max-width: ${({theme}) => theme.layout.width.large};
padding: 20px 0;
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
padding: 20px 12px;
}
`;
export default Layout
// components / main / MainNewBooks.tsx
... 생략 ...
const MainNewBooksStyle = styled.div`
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
grid-template-columns: repeat(2, 1fr);
}
`;
export default MainNewBooks;
// components / main / MainReview.tsx
... 생략 ...
import { useMediaQuery } from '@/hooks/useMediaQuery';
... 생략 ...
const MainReview = ({reviews}: Props) => {
const { isMobile } = useMediaQuery();
const sliderSettings = {
dots : true,
infinite: true,
speed: 500,
slidesToShow: isMobile ? 1 : 3,
slidesToScroll: isMobile ? 1 : 3,
gap: 16
}
... 생략 ...
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
.slick-prev {
left: 0;
}
.slick-next {
right: 0;
}
}
// components / common / footer.tsx
... 생략 ...
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
flex-direction: column;
align-items: center;
}
`;
export default Footer;
// components / banner / BannerItem.tsx
... 생략 ...
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
.content {
width: 100%;
background: linear-gradient(to top, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
h2 {
font-size: 1.5rem;
margin-bottom: 8px;
}
p {
font-size: 0.75rem;
}
}
}
`;
export default BannerItem;
// components / banner / Banner.tsx
... 생략 ...
const BannerButtonStyle = styled.div`
... 생략 ...
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
width: 28px;
height: 28px;
font-size: 1.5rem;
&.prev {
left: 0;
}
&.next {
right: 0;
}
}
}
`;
const BannerIndicatorStyle = styled.div`
... 생략 ...
@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
bottom: 0;
span {
width: 12px;
height: 12px;
&.active {
width: 24px;
}
}
}
`;
export default Banner;
// pages / Login.tsx
... 생략 ...
<InputText placeholder='이메일' inputType='email'
{...register("email", { required: true })} inputMode='email'/>
... 생략 ...
<InputText placeholder='비밀번호' inputType='password'
{...register("password", { required: true })} inputMode='text'/>