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

Lina Hongbi Ko·2024년 11월 20일
0

Programmers_BootCamp

목록 보기
60/76
post-thumbnail

2024년 11월 20일

✏️ 모킹 서버 작성 (1)

  • 자바스크립트 MSW 라이브러리 사용할 것임(Mocking response)

  • Mocking: 실제로 존재 하지 않는 것을 가상으로 만들어서 응답을 받음

  • 마치 node 서버를 가상으로 띄워놓은 것처럼(실제로 백엔드에 존재하지 않는 node 서버를 띄운 것처럼) 실제로 api 응답을 mocking함

  • MSW

  • 리뷰 추가

    • 도서 상세 페이지에 리뷰를 넣을 예정 with mock server

    • 테스트 환경 설정

      1. 각 리뷰에 대한 모델 작성

        // models / book.modelts
        
        ... 생략 ...
        
        export interface BookReviewItem {
          id: number;
          userName: string;
          content: string;
          createdAt: string;
          score: number;
        }
      2. Review api 작성

        // api / review.api
        
        import { BookReviewItem } from '@/models/book.model';
        import { requestHandler } from './http';
        
        export const fetchBookReview = async (bookId: string) => {
          return await requestHandler<BookReviewItem>("get", `/reviews/${bookId}`);
        }
      3. 훅에서 api 사용해서 데이터 요청 & 훅을 이용해 컴포넌트에 넣기

        // hooks / useBooks.ts
        
        ... 생략 ...
        
        export const useBook = (bookId: string | undefined) => {
          const [book, setBook] = useState<BookDetail | null>(null);
          const [cartAdded, setCartAdded] = useState(false);
          const [reviews, setReviews] = useState<BookReviewItem[]>([]);
        
          ... 생략 ...
        
          useEffect(()=>{
            if(!bookId) return;
        
            fetchBook(bookId).then((book) => { setBook(book) });
        
            fetchBookReview(bookId).then((reviews)=> { setReviews(reviews) });
        
          }, [bookId]);
        
          return { book, likeToggle, addToCart, cartAdded, reviews };
        }
        // pages / BookDetail.tsx
        
        ... 생략 ...
        
        const BookDetail = () => {
          const { bookId } = useParams();
          const { book, likeToggle, reviews } = useBook(bookId);
        
          ... 생략 ...
        
      • BookDetails 페이지 들어가면 다음과 같은 오류 발생
  • MSW 도입

    • 설치
      • npm i msw —save-dev
    • 사용
      • npx msw init public/ —save
      • public를 지정해주는 이유 : public 폴더에는 실제로 SPA에서 서빙하는 파일들이 위치해 있음 → 따라서 msw mock service worker 역시 서빙하는 쪽에 위치를 시키고 브라우저에서 직접 서빙 하겠다는 의미임(브라우저에서 직접 서비스된 내용을 받아와서 쓰겠다는 내용)
      • 실행되면 mockServiceWorker.js 파일 생성됨
  • index.tsx에 해당 내용 적용 → ServiceWorker 실행 (mock 서버 사용 준비)

// index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { state, ThemeContext } from './context/themeContext';

if(process.env.NODE_ENV === "development") {
  const { worker } = require("./mock/browser");
  worker.start();
}

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
  • mock browser 서버 만들기 (백엔드쪽)
// mock / browser.ts

import { setupWorker } from "msw/browser";
import { reviewsById } from './review';

const handlers = [reviewsById];

export const worker = setupWorker(...handlers);
// mock / review.ts

import { BookReviewItem } from '@/models/book.model';
import { http, HttpResponse } from "msw";

export const reviewsById = http.get("http://127.0.0.1:9999/reviews/:bookId", 
() => {
  const data:BookReviewItem[] = [];
  return HttpResponse.json(data, {
    status: 200,
  });
});

✏️ 모킹 서버 작성 (2)

  • 서버 타이밍 이슈 → 위처럼 index.tsx 파일을 작성하면 rivews http 요청이 먼저 가면 mocking enabled 라고 응답함
    • mocking이 실행되는 시점이 요청보다 뒤이기 때문임
    • 프로트엔드에서는 비동기처리 하면서 타이밍 이슈를 많이 만남
    • asyn await로 동기처리 시켜줌
// index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

async function mountApp () {

  if(process.env.NODE_ENV === "development") {
    const { worker } = require("./mock/browser");
    await worker.start(); // msw 시작
  }

  const root = ReactDOM.createRoot(
    document.getElementById('root') as HTMLElement
  );
  root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
}
mountApp();
  • 리뷰에 실제로 데이터 넣어보기 → faker.js라이브러리를 통해 dummy data를 동적으로 생성
    • 설치 : npm install @faker-js/faker —save-dev
// mock / review.ts

import { BookReviewItem } from '@/models/book.model';
import { http, HttpResponse } from "msw";
import { fakerKO as faker } from "@faker-js/faker";

const mockReviewsData: BookReviewItem[] = Array.from({length: 8}).map((_, index) => 
({
  id: index,
  userName: `${faker.person.lastName()}${faker.person.firstName()}`,
  content: faker.lorem.paragraph(),
  createdAt: faker.date.past().toISOString(),
  score: (faker.helpers as any).rangeToNumber({min: 1, max: 5}),
}));

export const reviewsById = http.get("http://127.0.0.1:9999/reviews/:bookId", 
() => {
  return HttpResponse.json(mockReviewsData, {
    status: 200,
  });
});

✏️ 리뷰 목록

  • 리뷰 목록 보이게 하기 with mocking data
// pages / BookDetail.tsx

... 생략 ...

const BookDetail = () => {
  const { bookId } = useParams();
  const { book, likeToggle, reviews } = useBook(bookId);
  
  ... 생략 ...
  
			  <Title size="medium">리뷰</Title>
        <BookReview reviews={reviews}/>
        
        ... 생략 ...
// components / book / BookReview.tsx

import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import styled from 'styled-components';
import BookReviewItem from './BookReviewItem';

interface Props {
  reviews: IBookReviewItem[];
}

const BookReview = ({ reviews }: Props) => {
  return (
    <BookReviewStyle>
      {
        reviews.map((review)=>(<BookReviewItem review={review }/>))
      }
    </BookReviewStyle>
  );
}

const BookReviewStyle = styled.div`
  display: flex;
  flex-direction: column;
  gap: 16px;
`;

export default BookReview;
// components / book / BookReviewItem.tsx

import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import { formatDate } from '@/utils/format';
import styled from 'styled-components';

interface Props {
  review: IBookReviewItem;
}

const BookReviewItem = ({review} : Props) => {
  return (
    <BookReviewItemStyle>
      <header className="header">
        <div>
          <span>{review.userName}</span>
          <span>{review.score}</span>
        </div>
        <div>{formatDate(review.createdAt)}</div>
      </header>
      <div className="content">
        <p>
          {review.content}
        </p>
      </div>
    </BookReviewItemStyle>
  );
}

const BookReviewItemStyle = styled.div`
  display: flex;
  flex-direction: column;
  gap: 12px;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
  padding: 12px;
  border-radius: ${({theme}) => theme.borderRadius.default};

  .header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-size: 0.875rem;
    color:  ${({theme}) => theme.colors.secondary};
    padding: 0;
  }

  .content {
    p {
      font-size: 1rem;
      line-height: 1.5;
      margin: 0;
    }
  }
`;

export default BookReviewItem;

  • 별점 넣기
// components / book / BookReviewItem.tsx

import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import { formatDate } from '@/utils/format';
import styled from 'styled-components';
import { FaStar } from 'react-icons/fa';

interface Props {
  review: IBookReviewItem;
}

const Star = (props: Pick<IBookReviewItem, "score">) => {
  return(
    <span className="star">
      {
        Array.from({ length: props.score }, (_, index) => (
          <FaStar />
        ))
      }
    </span>
  )
}

const BookReviewItem = ({review} : Props) => {
  return (
    <BookReviewItemStyle>
      <header className="header">
        <div>
          <span>{review.userName}</span>
          <Star score={review.score} />
        </div>
        <div>{formatDate(review.createdAt)}</div>
      </header>
      <div className="content">
        <p>
          {review.content}
        </p>
      </div>
    </BookReviewItemStyle>
  );
}

const BookReviewItemStyle = styled.div`
  display: flex;
  flex-direction: column;
  gap: 12px;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
  padding: 12px;
  border-radius: ${({theme}) => theme.borderRadius.default};

  .header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-size: 0.875rem;
    color:  ${({theme}) => theme.colors.secondary};
    padding: 0;

    .star {
      padding: 0 0 0 8px;
      
      svg {
        fill: ${({theme}) => theme.colors.primary};
    padding: 0;
      }
    }
  }

  .content {
    p {
      font-size: 1rem;
      line-height: 1.5;
      margin: 0;
    }
  }
`;

export default BookReviewItem;

✏️ 리뷰 작성

// mock / review.ts

... 생략 ...

export const addReview = http.post("http://127.0.0.1:9999/reviews/:bookId", () => {
  return HttpResponse.json({
    message: "리뷰가 등록되었습니다",
  },
  {
    status: 200,
  }
  )
});
// mock / browser.ts

import { setupWorker } from "msw/browser";
import { addReview, reviewsById } from './review';

const handlers = [reviewsById, addReview];

export const worker = setupWorker(...handlers);
// models / book.model.ts

... 생략 ...

export interface BookReviewItem {
  id: number;
  userName: string;
  createdAt: string;
  content: string;
  score: number;
}

export type BookReviewItemWrite = Pick<BookReviewItem, "content" | "score">;
// api / review.api.ts

... 생략 ...


interface AddBookReviewResponse {
  message: string;
}

export const addBookReview = async (bookId: string, data: BookReviewItemWrite) => {
  return await requestHandler<AddBookReviewResponse>("post", `/reviews/${bookId}`);
}
// hooks / useBook.ts

... 생략 ...

	const addReview = (data:BookReviewItemWrite) => {
    if(!book) return;
    addBookReview(book.id.toString(), data).then((res) => {
      // fetchBookReview(book.id.toString()).then((reviews)=> 
      // { setReviews(reviews) });
      showAlert(res?.message);
    })
  }

  return { book, likeToggle, addToCart, cartAdded, reviews, addReview };
}
// pages / BookDetail.tsx

... 생략 ...

		<div className="content">
        <Title size="medium">상세 설명</Title>
        <EllipsisBox linelimit={2}>{book.detail}</EllipsisBox>

        <Title size="medium">목차</Title>
        <p className="index">{book.contents}</p>

        <Title size="medium">리뷰</Title>
        <BookReview reviews={reviews} onAdd={addReview}/>
      </div>
    </BookDetailStyle>
  )
}

... 생략 ...
// components / book / BookReview.tsx

... 생략 ...

interface Props {
  reviews: IBookReviewItem[];
  onAdd: (data: BookReviewItemWrite) => void;
}

const BookReview = ({ reviews, onAdd }: Props) => {
  return (
    <BookReviewStyle>
      <BookReviewAdd onAdd={onAdd} />
      {
        reviews.map((review)=>(<BookReviewItem key={review.id} review={review }/>))
      }
    </BookReviewStyle>
  );
}

... 생략 ...
// components / book / BookReviewAdd.tsx

import { BookReviewItemWrite } from '@/models/book.model';
import { useForm } from 'react-hook-form';
import styled from 'styled-components';
import Button from '../common/Button';

interface Props {
  onAdd: (data: BookReviewItemWrite) => void;
}

const BookReviewAdd = ({ onAdd } : Props) => {

  const { register, handleSubmit, formState: {errors} } = useForm<BookReviewItemWrite>();

  return (
    <BookReviewAddStyle>
      <form onSubmit={handleSubmit(onAdd)}>
        <fieldset>
          <textarea {...register("content", { required: true })}></textarea>
          {errors.content && <p className='error-text'>리뷰 내용을 입력해 주세요.</p>}
        </fieldset>
        <div className="submit">
        <fieldset>
          <select {...register("score", { required: true, valueAsNumber: true })}>
            <option value="1">1점</option>
            <option value="2">2점</option>
            <option value="3">3점</option>
            <option value="4">4점</option>
            <option value="5">5점</option>
          </select>
          
        </fieldset>
        <Button size="medium" scheme="primary">작성하기</Button>
        </div>
      </form>
    </BookReviewAddStyle>
  );
}

const BookReviewAddStyle = styled.div`
  form {
    display: flex;
    flex-direction: column;
    gap: 6px;

    fieldset {
      border: 0;
      padding: 0;
      margin: 0;
      display: flex;
      flex-direction: column;
      gap: 12px;
      justify-content: end;


      .error-text {
        color: red;
        padding: 0;
        margin: 0;
      }
    }
    
    textarea {
      width: 100%;
      height: 100px;
      border-radius: ${({theme}) => theme.borderRadius.default};
      border: 1px solid ${({theme}) => theme.colors.border};
      padding: 12px;
    }
  }

  .submit {
    display: flex;
    justify-content: end;
  }
  
`;

export default BookReviewAdd;

✏️ 다양한 UI 경험 - 드롭다운

// components / common / Dropdown.tsx

import { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';

interface Props {
  children: React.ReactNode;
  toggleButton: React.ReactNode;
  isOpen?: boolean;
}

const Dropdown = ({ children, toggleButton, isOpen = false }: Props) => {
  const [open, setOpen] = useState(isOpen);
  const dropdownRef = useRef<HTMLDivElement>(null);

  useEffect(()=>{
    function handleOutsideClick(event: MouseEvent) {
      if(dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
       // ref가 존재하고, ref 안에 클릭한 부분이 없을 때만 동작한다. -> 외부 클릭
        setOpen(false);
      }
    }

    document.addEventListener("mousedown", handleOutsideClick);

    return () => {
      document.removeEventListener("mousedown", handleOutsideClick);
    }
  }, [dropdownRef])
  
  return (
    <DropdownStyle $open={open} ref={dropdownRef}>
      <button className="toggle" onClick={()=> setOpen(!open)}>{toggleButton}</button>
      {
        open && <div className="panel">{children}</div>
      }
    </DropdownStyle>
  );
}

interface DropdownStyleProps {
  $open: boolean;
}

const DropdownStyle = styled.div<DropdownStyleProps>`
  position: relative;

  button {
    background: none;
    border: none;
    cursor: pointer;
    outline: none;

    svg {
      width: 30px;
      height: 30px;
      fill: ${({theme, $open}) => $open ? theme.colors.primary : theme.colors.text};
    }
  }

  .panel {
    position: absolute;
    top: 40px;
    right: 0;
    padding: 16px;
    background: #fff;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    border-radius: ${({theme}) => theme.borderRadius.default};
    z-index: 10;
  }
`;

export default Dropdown;
// components / common / Header.tsx

... 생략 ...
				<nav className="auth">
		        <Dropdown toggleButton={<FaUserCircle />}>
		          <>
		          {
		          isloggedIn && (
		            <ul>
		              <li><Link to="/carts">장바구니</Link></li>
		              <li><Link to="/orderlist">주문 내역</Link></li>
		              <li><button onClick={storeLogout}>로그아웃</button></li>
		            </ul>
		          )
		          }
		          {
		            !isloggedIn && (
		              <ul>
		                <li>
		                  <a href="/login"><FaSignInAlt />로그인</a>
		                </li>
		                <li>
		                  <a href="/signup"><FaRegUser />회원가입</a>
		                </li>
		              </ul>
		            )
		          }
		          <ThemeSwitcher />
		          </>
		        </Dropdown>
	      </nav>
	      
	      ... 생략 ...

✏️ 다양한 UI 경험 - 탭

// pages / BookDetail.tsx

... 생략 ...

					<div className="content">
		        <Tabs>
		          <Tab title="상세설명">
		            <Title size="medium">상세 설명</Title>
		            <EllipsisBox linelimit={2}>{book.detail}</EllipsisBox>
		          </Tab>
		          <Tab title="목차">
		            <Title size="medium">목차</Title>
		            <p className="index">{book.contents}</p>
		          </Tab>
		          <Tab title="리뷰">
		            <Title size="medium">리뷰</Title>
		            <BookReview reviews={reviews} onAdd={addReview}/>
		          </Tab>
		        </Tabs>
		      </div>
		      
		      ... 생략 ...
// components / common / Tabs.tsx

import React, { useState } from 'react';
import styled from 'styled-components';

interface TabProps {
  title: string;
  children: React.ReactNode;
}

const Tab = ({ children }: TabProps) => {
  return(
    <>{children}</>
  )
}

interface TabsProps {
  children: React.ReactNode;
}

const Tabs = ({ children }: TabsProps) => {

  const [activeIndex, setActiveIndex] = useState(0);
  const tabs = React.Children.toArray(children) as React.ReactElement<TabProps>[];
  // 직접 access 할 수 있는 배열로 바꿈
  return (
    <TabsStyle>
      <div className="tab-header">
        {
          tabs.map((tab, index)=>(
            <button key={tab.props.title} className={activeIndex === index ? 
            "active" : ""} onClick={()=>{setActiveIndex(index)}}>
            {tab.props.title}</button>
          ))
        }
      </div>
      <div className="tab-content">
        {tabs[activeIndex]}
      </div>
    </TabsStyle>
  );
}

const TabsStyle = styled.div`
  .tab-header {
    display: flex;
    gap: 2px;
    border-bottom: 1px solid #ddd;

    button {
      border: none;
      background: #ddd;
      cursor: pointer;
      font-size: 1.25rem;
      font-weight: bold;
      color: ${({theme}) => theme.colors.text};
      border-radius: ${({theme}) => theme.borderRadius.default} ${({theme}) => theme.borderRadius.default} 0 0;
      padding: 12px 24px;

      &.active {
        color: #fff;
        background: ${({theme}) => theme.colors.primary};
      }
    }
  }

  .tab-content {
    padding: 24px 0;
  }
`;

export { Tab, Tabs };

✏️ 다양한 UI 경험 - 토스트 (1)

// store / toastStore.ts

import { create } from 'zustand';

export type ToastType = 'info' | 'error';

export interface ToastItem {
  id: number;
  message: string;
  type: ToastType;
}

interface ToastStoreState {
  toasts: ToastItem[];
  addToast: (message: string, type?: ToastType) => void;
  removeToast: (id: number) => void;
}

const useToastStore = create<ToastStoreState>((set) => ({
  toasts: [],
  addToast: (message, type = 'info') => {
    set((state) =>({
      toasts: [...state.toasts, {message, type, id: Date.now()}]
    }));
  },
  removeToast: (id) => {
    set((state)=>({
      toasts: state.toasts.filter((toast) => toast.id !== id)
    }))
  } 
}));

export default useToastStore;
// hooks / useToast.ts

import useToastStore from '@/store/toastStore'

export const useToast = () => {
  const showToast = useToastStore((state) => state.addToast);

  return { showToast };
};
// components / common / toast / Toast.tsx

import { ToastItem } from '@/store/toastStore';
import styled from 'styled-components';
import { FaPlus, FaBan, FaInfoCircle } from "react-icons/fa";

const Toast = ({ id, message, type } : ToastItem) => {
  const handleRemoveToast = () => {

  }

  return (
    <ToastStyle>
      <p>
        { type === 'info' && <FaInfoCircle /> }
        { type === 'error' && <FaBan /> } 
        { message }
      </p>
      <button onClick={handleRemoveToast}>
        <FaPlus />
      </button>
    </ToastStyle>
  );
}

const ToastStyle = styled.div`
  background-color: ${({theme}) => theme.colors.background};
  padding: 12px;
  border-radius: ${({theme}) => theme.borderRadius.default};

  display: flex;
  justify-content: space-between;
  align-items: start;
  gap: 24px;

  p {
    color: ${({theme}) => theme.colors.text};
    line-height: 1;
    margin: 0;
    flex: 1;
    
    display: flex;
    align-items: end;
    gap: 4px;
  }
  
  button {
    background-color: transparent;
    border: none;
    cursor: pointer;
    padding: 0;
    margin: 0;

    svg {
      transform: rotate(45deg);
    }
  }
`;

export default Toast;
// hooks / useBook.ts

... 생략 ...

const { showToast } = useToast();

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,
        })
        showToast("좋아요가 취소되었습니다")
      })
    } else {
      //언라이크 상태 -> 라이크 실행
      likeBook(book.id).then(()=>{
        // 성공 처리
        setBook({
          ...book,
          liked: true,
          likes: book.likes + 1,
        })
        showToast("좋아요가 성공했습니다");
      })
    }
  };
  
  ... 생략 ...
// components / common / toast / ToastContainer.tsx

import useToastStore from '@/store/toastStore';
import styled from 'styled-components';
import Toast from './Toast';

const ToastContainer = () => {
  const toasts = useToastStore((state) => state.toasts);

  return (
    <ToastContainerStyle>
      {
        toasts.map((toast) => (
          <Toast key={toast.id} id={toast.id} message={toast.message} 
          type={toast.type}/>
        ))
      }
    </ToastContainerStyle>
  );
}

const ToastContainerStyle = styled.div`
  position: fixed;
  top: 32px;
  right: 24px;
  z-index: 1000;

  display: flex;
  flex-direction: column;
  gap: 12px;
`;

export default ToastContainer;
// App.tsx

... 생략 ...

function App() {
  
  return(
    <QueryClientProvider client={queryClient}>
      <BookStoreThemeProvider>
        <RouterProvider router={router} />
        <ToastContainer />
    </BookStoreThemeProvider>
		</QueryClientProvider>
	);
}

export default App;

✏️ 다양한 UI 경험 - 토스트 (2)

  • 토스트 사라지게 하기 & x 버튼 누르면 없어지게 하기
// components / common / toast

import useToastStore, { ToastItem } from '@/store/toastStore';
import styled from 'styled-components';
import { FaPlus, FaBan, FaInfoCircle } from "react-icons/fa";
import { useEffect, useState } from 'react';

export const TOAST_REMOVE_DELAY = 3000;

const Toast = ({ id, message, type } : ToastItem) => {
  const removeToast = useToastStore((state) => state.removeToast);
  const [isFadingOut, setIsFadingOut] = useState(false);

  const handleRemoveToast = () => {
    setIsFadingOut(true);
  }

  useEffect(()=>{
    const timer = setTimeout(() => {
      // 삭제
      // removeToast(id);
      handleRemoveToast();
    }, TOAST_REMOVE_DELAY);

    return () => clearTimeout(timer);
  }, []);

  const handleAnimationEnd = () => {
    if(isFadingOut) {
      removeToast(id);
    }
  }

  return (
    <ToastStyle className={isFadingOut ? "fade-out" : "fade-in"} onAnimationEnd={handleAnimationEnd}>
      <p>
        { type === 'info' && <FaInfoCircle /> }
        { type === 'error' && <FaBan /> } 
        { message }
      </p>
      <button onClick={handleRemoveToast}>
        <FaPlus />
      </button>
    </ToastStyle>
  );
}

const ToastStyle = styled.div`
  @keyframes fade-in {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
  @keyframes fade-out {
    from {
      opacity: 1;
    }
    to {
      opacity: 0;
    }
  }

  &.fade-in {
    animation: fade-in 0.3s ease-in-out forwards;
  }
  &.fade-out {
    animation: fade-out 0.3s ease-in-out forwards;
  }


  background-color: ${({theme}) => theme.colors.background};
  padding: 12px;
  border-radius: ${({theme}) => theme.borderRadius.default};

  display: flex;
  justify-content: space-between;
  align-items: start;
  gap: 24px;
  opacity: 0;
  transition: all 0.3s ease-in-out;

  p {
    color: ${({theme}) => theme.colors.text};
    line-height: 1;
    margin: 0;
    flex: 1;
    
    display: flex;
    align-items: end;
    gap: 4px;
  }
  
  button {
    background-color: transparent;
    border: none;
    cursor: pointer;
    padding: 0;
    margin: 0;

    svg {
      transform: rotate(45deg);
    }
  }
`;

export default Toast;
  • 훅으로 분리
// hooks / useTimeout.ts

import { useEffect } from 'react'

export const useTimeout = (callback: () => void, delay: number) => {
  useEffect(() => {
    const timer = setTimeout(callback, delay);

    return () => clearTimeout(timer);
  }, [callback, delay]);
}

export default useTimeout;
// components / common / toast

... 생략 ...

	const handleRemoveToast = () => {
    setIsFadingOut(true);
  }

  useTimeout(() => {
    handleRemoveToast();
  }, TOAST_REMOVE_DELAY);

  const handleAnimationEnd = () => {
    if(isFadingOut) {
      removeToast(id);
    }
  }

✏️ 다양한 UI 경험 - 모달

// pages / BookDetail.tsx

... 생략 ...

  const [isImgOpen, setIsImgOpen] = useState(false);
  
   if(!book) return null;

	return (
    <BookDetailStyle>
      <header className="header">
        <div className="img" onClick={()=>setIsImgOpen(true)}>
          <img src={getImgSrc(book.img)} alt={book.title} />
        </div>
        <Modal isOpen={isImgOpen} onClose={()=>setIsImgOpen(false)}>
            <img src={getImgSrc(book.img)} alt={book.title} />
        </Modal>
        
        ... 생략 ...
// components / common / Modal.tsx

import styled from 'styled-components';
import { FaPlus } from "react-icons/fa";
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from "react-dom";

interface Props {
  children: React.ReactNode;
  isOpen: boolean;
  onClose: () => void;
}

const Modal = ({children, isOpen, onClose }: Props) => {
  const [isFadingout, setIsFadingout] = useState<boolean>(false);
  const modalRef = useRef<HTMLDivElement | null>(null);

  const handleClose = () => {
    setIsFadingout(true);
    // onClose();
  }

  const handleOverlayClick = (e:React.MouseEvent) => {
    if(modalRef.current && !modalRef.current.contains(e.target as Node)) {
      handleClose();
    }
  }

  const handleKeydown = (e:KeyboardEvent) => {
    if(e.key === "Escape") {
      handleClose();
    }
  }

  const handleAnimationEnd = () => {
    if(isFadingout) {
      onClose();
      setIsFadingout(false);
    }
  }

  useEffect(()=>{
    if(isOpen) {
      window.addEventListener("keydown", handleKeydown);
    } else {
      window.removeEventListener("keydown", handleKeydown);
    }

    return () => {
      window.removeEventListener("keydown", handleKeydown);
    }
  }, [isOpen]);

  if(!isOpen) return null;

  return createPortal(
          <ModalStyle className={isFadingout ? "fade-out" : "fade-in"} onClick={handleOverlayClick} onAnimationEnd={handleAnimationEnd}>
            <div className="modal-body" ref={modalRef}>
              <div className="modal-contents">{children}</div>
              <button className="modal-close" onClick={handleClose}>
                <FaPlus />
              </button>
            </div>
          </ModalStyle>, document.body);
}

const ModalStyle = styled.div`
  @key-frames fade-in {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
  @key-frames fade-out {
    from {
      opacity: 1;
    }
    to {
      opacity: 0;
    }
  }
  

  &.fade-in {
    animation: fade-in 0.3s ease-in-out forwards;
  }

  &.fade-out {
    animation: fade-out 0.3s ease-in-out forwards;
  }

  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 1000;
  background-color: rgba(0, 0, 0, 0.6);

  .modal-body {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: 56px 32px 32px;
    border-radius: ${({theme}) => theme.borderRadius.default};
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);

    background-color: #fff;
    max-width: 80%;
  }

  .modal-close {
    border: none;
    background-color: transparent;
    cursor: pointer;

    position: absolute;
    top: 0;
    right: 0;
    padding: 12px;

    svg {
      width: 20px;
      height: 20px;
      transform: rotate(45deg);
    }
  }
`;

export default Modal;

✏️ 다양한 UI 경험 - 무한 스크롤 (1)

// hooks / useBooksInfinite.ts

import { useLocation } from 'react-router-dom'
import { fetchBooks } from '../api/books.api';
import { QUERYSTRING } from '../constants/querystring';
import { LIMIT } from '../constants/pagination';
import { useInfiniteQuery } from 'react-query';

export const useBooksInfinite = () => {
  const location = useLocation();

  const getBooks = ({ pageParam } : {pageParam: number}) => {
    const params = new URLSearchParams(location.search);
    const category_id = params.get(QUERYSTRING.CATEGORY_ID) ? Number(params.get(QUERYSTRING.CATEGORY_ID)) : undefined;
    const news = params.get(QUERYSTRING.NEWS) ? true : undefined;
    const limit = LIMIT;
    const currentPage = pageParam;

    return fetchBooks({
      category_id,
      news,
      limit,
      currentPage
    })
  }

  const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery(["books", location.search], 
    ({ pageParam = 1}) => getBooks({ pageParam }),
    {
      getNextPageParam: (lastPage) => {
        const isLastPage = Math.ceil(lastPage.pagination.totalCount / LIMIT) === lastPage.pagination.currentPage;

        return isLastPage ? null : lastPage.pagination.currentPage + 1;
      }
    }
  )
  

  const books = data ? data.pages.flatMap((page) => page.books) : [];
  const pagination = data ? data.pages[data.pages.length - 1].pagination : {};
  const isEmpty = books.length === 0;

  return {
    books,
    pagination,
    isEmpty,
    isBooksLoading : isFetching,
    fetchNextPage,
    hasNextPage
  } ;
};

  // 이전 useBooks의 data 형태
  /*
  결과 = {
    books, pagination
  }
  */
  
  // 지금의 data 형태
  /*
  결과 = {
    pages = [
      {books, pagination},
      {books, pagination},
      {books, pagination}
    ]
  }
  */
// pages / Books.tsx

... 생략 ...

 const { books, pagination, isEmpty, isBooksLoading, 
 fetchNextPage, hasNextPage } = useBooksInfinite();
 
 
		  <BooksList books={books} />
      {/* <Pagination pagination={pagination} /> */}

      <div className="more">
        <Button size="medium" scheme="normal" onClick={() => {
          fetchNextPage()
        }} disabled={!hasNextPage}>{hasNextPage ? "더보기" : "마지막 페이지"}</Button>
      </div>
    </BooksStyle>
    </>
  )
}

... 생략 ...

✏️ 다양한 UI 경험 - 무한 스크롤 (2)

// Books.tsx

... 생략 ...

const Books = () => {
  const { books, pagination, isEmpty, isBooksLoading, fetchNextPage, hasNextPage } = useBooksInfinite();
  const moreRef = useRef(null);
  
  useEffect(()=>{
    const observer = new IntersectionObserver((entries)=> {
      entries.forEach((entry)=> {
        if(entry.isIntersecting) {
          loadMore();
          observer.unobserve(entry.target);
        }
      })
    })

    if(moreRef.current) {
      observer.observe(moreRef.current);
    }

    return () => observer.disconnect();
  }, [books]);

  const loadMore = () => {
    if(!hasNextPage) return;
    fetchNextPage();
  }
  
  
  ... 생략 ...
  
			   <BooksList books={books} />
      {/* <Pagination pagination={pagination} /> */}

      <div className="more" ref={moreRef}>
        <Button size="medium" scheme="normal" onClick={() => {
          fetchNextPage()
        }} disabled={!hasNextPage}>{hasNextPage ? "더보기" : "마지막 페이지"}</Button>
      </div>
    </BooksStyle>
    </>
  )
}

... 생략 ...
import { useEffect, useRef } from 'react';

type Callback = (entries: IntersectionObserverEntry[]) => void;

interface ObserverOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
}

export const useIntersectionObserver = (callback: Callback, options?: ObserverOptions) => {
  const targetRef = useRef(null);
  
  useEffect(() => {
    const observer = new IntersectionObserver(callback, options);
    const target = targetRef.current;


    if(targetRef.current) {
      observer.observe(targetRef.current);
    }

    return () => {
      if(target) {
        observer.unobserve(target);
      }
    }

  });

  return targetRef;
};
profile
프론트엔드개발자가 되고 싶어서 열심히 땅굴 파는 자

0개의 댓글