🎯 λ„μ„œ 상세 νŽ˜μ΄μ§€λ₯Ό μ œμž‘ν•©λ‹ˆλ‹€.


πŸ“— Today I Learned

λ„μ„œ 상세 νŽ˜μ΄μ§€

useBook.ts

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에 μ €μž₯ν•©λ‹ˆλ‹€.




bookDetail.tsx

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μ—λŠ” 상세섀λͺ…κ³Ό λͺ©μ°¨λ₯Ό μ„€λͺ…ν•©λ‹ˆλ‹€.




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

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)λ₯Ό λ„˜κ²¨ λ°›μ•„μ„œ μ‹€ν–‰μ‹œν‚€λŠ” μ’‹μ•„μš” λ²„νŠΌ μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€.




AddToCart.tsx

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 κ°μ†Œμ‹œν‚€λŠ” ν•¨μˆ˜μž…λ‹ˆλ‹€.




EllipsisBox.tsx

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 κΈ°μˆ λ“€μ΄ μ§€μ›λ˜μ—ˆμœΌλ©΄ μ’‹κ² λ‹€.

profile
🌱개발 기둝μž₯

0개의 λŒ“κΈ€