[프로그래머스] 프론트엔드 심화: 프로젝트(6)

Lina Hongbi Ko·2024년 11월 17일
0

Programmers_BootCamp

목록 보기
57/76
post-thumbnail

2024년 11월 15일

✏️ 도서 상세 페이지(1)

  • 도서 상세 화면 요구 사항

    • 각 도서 상세 정보를 노출
    • 좋아요 버튼을 클릭시 좋아요 또는 취소 기능
    • 수량을 입력하여 장바구니 담기
  • BookDetail

    • 책 상세 페이지 틀 만들기
// App.tsx

... 생략 ...

const router = createBrowserRouter([

	... 생략 ...
	
	},
	{
    path: "/books/:bookId",
    element: <Layout><BookDetail /></Layout>
  }
]);

... 생략 ...
// components / books / BookItem.tsx

... 생략 ...

const BookItem = ({ book, view }: Props) => {
  return (
    <BookItemStyle view={view}>
      <Link to={`/books/${book.id}`}>
        <div className="img">
        
        ... 생략 ...
// api / books.api.ts

... 생략 ...

export const fetchBook = async (bookId: string) => {
  const response = await httpClient.get<BookDetail>(`/books/${bookId}`);
  return response.data;
}
// pages / BookDetail.tsx

import { useParams } from 'react-router-dom';
import styled from 'styled-components'
import { useBook } from '../hooks/useBook';

const BookDetail = () => {
  const { bookId } = useParams();
  const { book } = useBook(bookId);
  
  if(!book) return null;
  
  return (
    <BookDetailStyle>{book.title}</BookDetailStyle>
  )
}

const BookDetailStyle = styled.div`
`;

export default BookDetail
// hooks / useBook.ts

import { useState, useEffect } from 'react'
import { BookDetail } from '../models/book.model';
import { fetchBook } from '../api/books.api';

export const useBook = (bookId: string | undefined) => {
  const [book, setBook] = useState<BookDetail | null>(null);

  useEffect(()=>{
    if(!bookId) return;
    
    fetchBook(bookId).then((book) => {setBook(book)})
  }, [bookId]);

  return { book };
}

✏️ 도서 상세 페이지 (2)

  • BookDetail
    • 책 정보 보여주기
      • date를 포맷하는 경량 라이브러리 사용 예정 : npm install dayjs --save
// pages / BookDetail.tsx

import { useParams } from 'react-router-dom';
import styled from 'styled-components'
import { useBook } from '../hooks/useBook';
import Title from '../components/common/Title';
import { getImgSrc } from '../utils/image';
import { BookDetail as IBookDetail} from '../models/book.model';
import {formatNumber, formatDate } from '../utils/format';
import { Link } from 'react-router-dom';

const bookInfoList = [
  {
    label: "카테고리",
    key: "category_name",
    filter: (book:IBookDetail) => {
      return <Link to={`/books?category_id=${book.category_id}`}>
      {book.category_name}</Link>
    }
  },
  {
    label: "포맷",
    key: "form"
  },
  {
    label: "페이지",
    key: "pages"
  },
  {
    label: "ISBN",
    key: "isbn"
  },
  {
    label: "출간일",
    key: "pub_date",
    filter: (book: IBookDetail) => {
      return formatDate(book.pub_date);
    }
  },
  {
    label: "가격",
    key: "price",
    filter: (book: IBookDetail) => {
      return `${formatNumber(book.price)} 원`;
    }
  }
]

const BookDetail = () => {
  const { bookId } = useParams();
  const { book } = 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 key={item.label}>
                <dt>{item.label}</dt>
                <dt>{item.filter ? item.filter(book) : 
                book[item.key as keyof IBookDetail]}</dt>
              </dl>
            ))
          }
          <p className="summary">{book.summary}</p>

          <div className="lik">라이크</div>
          <div className="add-cart">장바구니 넣기</div>
        </div>
      </header>
      <div className="content"></div>
    </BookDetailStyle>
  )
}

const BookDetailStyle = styled.div`
  .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.colors.secondary};
        }
        a {
          color: ${({theme}) => theme.colors.primary};
        }
      }
    }
  }
`;

export default BookDetail
// utils / format.ts

import dayjs from "dayjs";

export const formatNumber = (number:number):string => {
  return number.toLocaleString();
};

export const formatDate = (date: string, format?: string) => {
  return dayjs(date).format(format ? format : "YYYY년 MM월 DD일");
};

✏️ 도서 상세 페이지 (3)

  • BookDetail 상세 설명 보여주기 + 토글버튼
  • 상세 설명을 줄여주는 라인 클램프(line-clamp) 다른 곳에서도 많이 쓰일 것으로 예상 됨 → 공통 컴포넌트로 만들어서 다시 쓸 수 있게 만들기 (재사용성)
// components / common / EllipsisBox.tsx

import React, { 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;
}

const 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 {
    display: flex;
    justify-content: end;
    svg {
      transform: ${({$expanded}) => ($expanded ? 
      "rotate(180deg)" : "rotate(0)")};
    }
  }
`;

export default EllipsisBox
// pages / BookDetail.tsx

... 생략 ...

		</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>
		    </BookDetailStyle>
		  )
		}
		
		... 생략 ...

✏️ 도서 상세 페이지 (4)

  • 좋아요 버튼 만들기
// components / book / LikeButton.tsx

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;
}

const LikeButton = ({book, onClick} : Props) => {
  return (
    <LikeButtonStyle size="medium" scheme={book.liked ? 
    "like" : "normal"} onClick={onClick}>
      <FaHeart />
      {book.likes}
    </LikeButtonStyle>
  );
}

const LikeButtonStyle = styled(Button)`
  display: flex;
  gap: 6px;

  svg {
    color: inherit;
    * {
      color: inherit;
    }
  }
`;

export default LikeButton;
// components / common / Button.tsx

import { styled } from 'styled-components'
import { ButtonScheme, ButtonSize } from '../../style/theme';

interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement>{
  children: React.ReactNode
  size: ButtonSize;
  scheme: ButtonScheme;
  disabled?: boolean;
  isLoading?: boolean;
  onClick?: () => void;
  className?: string;
}

const Button = ({ children, size, scheme, disabled, isLoading, className, onClick }:Props) => {
  return (
    <ButtonStyle className={className} onClick={onClick} size={size} scheme={scheme} disabled={disabled} isLoading={isLoading}>{children}</ButtonStyle>
  )
}

... 생략 ...
  • 오류 해결 (이거 찾느라 한시간 걸림..)
    • 특정 태그로 styledcomponent 사용하려면 classname까지 넘겨줘야하는 것이었음 ㅠㅠㅠㅠ (강사님 왜 말씀을 안해주셨나요,,) 참고자료 :https://lily-im.tistory.com/100
      +이틀 후, 다른 분이 올려놓으신 QA에 근본적인 해결 방법 찾음!!!
      버튼의 공통 어트리뷰트를 inputText처럼 ...props로 넘겨줬어야 했다..
const Button = ({ children, size, scheme, 
disabled, isLoading, ...props }:Props) => {
  return (
    <ButtonStyle {...props} size={size} scheme={scheme} disabled={disabled} 
    isLoading={isLoading}>{children}</ButtonStyle>
  )
}
// style / theme.ts

... 생략 ...

export type ButtonScheme = "primary" | "normal" | "like";

... 생략 ...
	buttonScheme: {
    primary : {
      color : "white",
      backgroundColor : "midnightblue"
    },
    normal : {
      color : "black",
      backgroundColor: "lightgrey"
    },
    like: {
      color: "white",
      backgroundColor: "coral",
    }
  },
  
  ... 생략 ...
// pages / BookDetail.tsx

... 생략 ...

const BookDetail = () => {
  const { bookId } = useParams();
  const { book, likeToggle } = useBook(bookId);
  
  if(!book) return null;
  
  return (
    <BookDetailStyle>
      
      ... 생략 ...
      
          <div className="like">
            <LikeButton book={book} onClick={likeToggle} />
          </div>
          
          ... 생략 ...
// hooks / useBook.ts

import { useNavigate } from 'react-router-dom';
import { useState, useEffect } 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';

export const useBook = (bookId: string | undefined) => {
  const [book, setBook] = useState<BookDetail | null>(null);
  
  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,
        })
      })
    }
  };

  useEffect(()=>{
    if(!bookId) return;
    
    fetchBook(bookId).then((book) => {setBook(book)})
  }, [bookId]);

  return { book, likeToggle };
}
// api / books.api.ts


... 생략 ...

export const fetchBook = async (bookId: string) => {
  const response = await httpClient.get<BookDetail>(`/books/${bookId}`);
  return response.data;
}
// string으로 props를 받는 이유는 리액트 라우터를 통해 bookId를 사용하면 문자열로 변환되기 때문
// 밑에서는 직접 요청하므로 number 타입이 넘어감
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;
}

... 생략 ...

✏️ 도서 상세 페이지 (5)

  • 장바구니 담기 기능

  • 기본적으로 quantity를 업데이트 하는 UI 작성

// components / book / AddToCart.tsx

import styled from 'styled-components'
import { BookDetail } from '../../models/book.model';
import InputText from '../common/InputText';
import Button from '../common/Button';
import React, { useState } from 'react';

interface Props {
  book: BookDetail;
}

const AddToCart = ({ book } : Props) => {
  const [quantity, setQuantity] = useState<number>(1);
  
  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>
      <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">장바구니 담기</Button>
    </AddToCartStyle>
  )
}

const AddToCartStyle = styled.div`
    display: flex;
    justify-content: space-between;
    align-items: center;
`;

export default AddToCart
// pages / BookDetail.tsx

... 생략 ...

		<div className="add-cart">
            <AddToCart book={book} />
    </div>
    
    ... 생략 ...
  • 장바구니 담기 버튼 클릭하면 api와 연동 되게 만들기
// 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;
}
// components / book / AddToCart.tsx

... 생략 ...

  const showAlert = useAlert();
  const [cartAdded, setCartAdded] = useState(false);
  
  ... 생략 ...

  const addToCart = () => {
    addCart({
      book_id: book.id,
      quantity: quantity
    }).then(() => {
      //  showAlert("장바구니에 추가되었습니다.");
      setCartAdded(true);
    })
  }

  return (
    <AddToCartStyle>
      <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}>장바구니 담기</Button>
      {
        cartAdded && (
          <div className="added">
            <p>장바구니에 추가되었습니다.</p>
            <Link to="/cart">장바구니로 이동</Link>
          </div>
        )
      }
    </AddToCartStyle>
  )
}

const AddToCartStyle = styled.div`
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: relative;

    .added {
      position: absolute;
      right: 0;
      bottom: -90px;
      background: ${({theme}) => theme.colors.background};
      border-radius: ${({theme}) => theme.borderRadius.default};
      padding: 8px 12px;

      p {
        padding: 0 0 8px 0;
        margin: 0;
      }
    }
`;

export default AddToCart
  • 장바구니에 추가되었습니다 작은 창 시간 넣어서 보여준뒤, 사라지게 하기 & 장바구니에 추가하는 로직을 useBook 훅에서 관리하도록 빼주기
// components / book / AddToCart.tsx

import styled from 'styled-components'
import { BookDetail } from '../../models/book.model';
import InputText from '../common/InputText';
import Button from '../common/Button';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useBook } from '../../hooks/useBook';

interface Props {
  book: BookDetail;
}

const 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="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="/cart">장바구니로 이동</Link>
      </div>
    </AddToCartStyle>
  )
}

interface AddCartStyleProps {
  $added: boolean; 
  // 일반 html 요소의 어트리뷰트는 문자열로 제한하기 때문에 해당 내용을 $을 붙여 회피시킨다
}

const AddToCartStyle = styled.div<AddCartStyleProps>`
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: relative;

    .added {
      position: absolute;
      right: 0;
      bottom: -90px;
      background: ${({theme}) => theme.colors.background};
      border-radius: ${({theme}) => theme.borderRadius.default};
      padding: 8px 12px;
      opacity: ${({$added}) => ($added ? "1" : "0") };
      transition: all 0.5s ease;

      p {
        padding: 0 0 8px 0;
        margin: 0;
      }
    }
`;

export default AddToCart
// hooks / useBook.ts

import { useNavigate } from 'react-router-dom';
import { useState, useEffect } 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 navigate = useNavigate();
  const [cartAdded, setCartAdded] = useState(false);

  ... 생략 ...

  const addToCart = (quantity: number) => {
    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 };
}
profile
프론트엔드개발자가 되고 싶어서 열심히 땅굴 파는 자

0개의 댓글