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

Lina Hongbi Ko·2024년 11월 14일
0

Programmers_BootCamp

목록 보기
56/76
post-thumbnail

2024년 11월 14일

✏️ 비밀번호 초기화

// ResetPassword.tsx

import { useForm } from 'react-hook-form';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { resetPassword, resetRequest } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';
import { SignupStyle } from './Signup';

export interface SignupProps {
  email : string;
  password : string;
}

const ResetPassword = () => {
  const [resetRequested, setResetRequested] = useState(false);
  const navigate = useNavigate();
  const showAlert = useAlert();

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

  const onSubmit = (data: SignupProps) => {
    if(resetRequested) {
      // 초기화
      resetPassword(data).then(() => {
        showAlert("비밀번호가 초기화되었습니다.");
        navigate('/login');
      });
    } else {
      // 요청
      resetRequest(data).then(() => {
        setResetRequested(true);
      });
    }
  };

  return (
    <>
      <Title size="large">비밀번호 초기화</Title>
      <SignupStyle>
        <form onSubmit={handleSubmit(onSubmit)}>
          <fieldset>
            <InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
            {errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
          </fieldset>
          {
            resetRequested && (
              <fieldset>
                <InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
              {errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
              </fieldset>
            )
          }
          <fieldset>
            <Button type="submit" size="medium" scheme="primary">
              {
                resetRequested ? "비밀번호 초기화" : "초기화 요청"
              }            </Button>
          </fieldset>
          <div className="info">
            <Link to="/reset">비밀번호 초기화</Link>
          </div>
        </form>
      </SignupStyle>
    </>
  )
}


export default ResetPassword;
// api / auth.api.ts

import { SignupProps } from '../pages/Signup';
import { httpClient  } from './http';

export const signUp = async (userData:SignupProps) => {
  const response = await httpClient.post("/users/join", userData);

  return response.data;
}

export const resetRequest = async (data:SignupProps) => {
  const response = await httpClient.post("/users/reset", data);

  return response.data;
}

export const resetPassword = async (data:SignupProps) => {
  const response = await httpClient.put("/users/reset", data);

  return response.data;
}
// App.tsx

import Layout from './components/layout/Layout';
import Home from './pages/Home';
import ThemeSwitcher from './components/header/ThemeSwitcher'; 
import { BookStoreThemeProvider } from './context/themeContext';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Error from './components/common/Error';
import Signup from './pages/Signup';
import ResetPassword from './pages/ResetPassword';

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout><Home /></Layout>,
    errorElement: <Error />
  },
  {
    path: "/books",
    element: <Layout><div>도서 목록</div></Layout>,
    errorElement: <Error />
  },
  {
    path: "/signup",
    element: <Layout><Signup /></Layout>,
  },
  {
    path: "/reset",
    element: <Layout><ResetPassword /></Layout>,
  }
]);

... 생략 ...

✏️ 로그인과 전역상태(1)

  • 로그인시키기
// pages / Login.tsx

import { useForm } from 'react-hook-form';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { login } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';
import { SignupStyle } from './Signup';

export interface SignupProps {
  email : string;
  password : string;
}

const Login = () => {
  const navigate = useNavigate();
  const showAlert = useAlert();

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

  const onSubmit = (data: SignupProps) => {
    login(data).then((res) => {
      console.log(res.token);
      showAlert("로그인 완료되었습니다.");
      navigate("/");
    })
  };

  return (
    <>
      <Title size="large">로그인</Title>
      <SignupStyle>
        <form onSubmit={handleSubmit(onSubmit)}>
          <fieldset>
            <InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
            {errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
            {errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <Button type="submit" size="medium" scheme="primary">
              로그인
            </Button>
          </fieldset>
          <div className="info">
            <Link to="/reset">비밀번호 초기화</Link>
          </div>
        </form>
      </SignupStyle>
    </>
  )
}

export default Login;
// api / auth.api.ts

... 생략 ...

interface LoginResponse {
  token: string;
}

export const login = async (data:SignupProps) => {
  const response = await httpClient.post<LoginResponse>("/users/login", data);

  return response.data;
}
// App.tsx

... 생략 ...
  {
    path: "/login",
    element: <Layout><Login /></Layout>,
  }
]);

... 생략 ...
  • 전역 상태 만들고, 로그인 시키고나서 store에 저장시키기

    • Zustand 이용 예정
      • 설치 : npm i zustand —save
  • 스토어 생성

// store / authStore.ts

import { create } from 'zustand';

interface StoreState {
	// Zustand는 상태 정보와 액션 함수를 같이 선언한다.
	isloggedIn: boolean;
    storeLogin: (token: string) => void;
    storeLogout: () => void;
}

export const useAuthStore = create<StoreState>((set) => ({
  isloggedIn: false, // 초기값

  // 액션
  storeLogin: (token: string) => {
    set({isloggedIn: true});
  },
  storeLogout: () => {
    set({isloggedIn: false});
  }
}));
  • 스토어 사용
// pages / Login.tsx

import { useForm } from 'react-hook-form';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { login } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';
import { SignupStyle } from './Signup';
import { useAuthStore } from '../store/authStore';

export interface SignupProps {
  email : string;
  password : string;
}

const Login = () => {
  const navigate = useNavigate();
  const showAlert = useAlert();

  const { isloggedIn, storeLogin, storeLogout } = useAuthStore();

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

  const onSubmit = (data: SignupProps) => {
    login(data).then((res) => {
      
      // 상태 변화
      storeLogin(res.token);

      showAlert("로그인 완료되었습니다.");
      navigate("/");
    })
  };

  console.log(isloggedIn);

  return (
    <>
      <Title size="large">로그인</Title>
      <SignupStyle>
        <form onSubmit={handleSubmit(onSubmit)}>
          <fieldset>
            <InputText placeholder='이메일' inputType='email' 
            {...register("email", { required: true })} />
            {errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <InputText placeholder='비밀번호' inputType='password' 
            {...register("password", { required: true })} />
            {errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <Button type="submit" size="medium" scheme="primary">
              로그인
            </Button>
          </fieldset>
          <div className="info">
            <Link to="/reset">비밀번호 초기화</Link>
          </div>
        </form>
      </SignupStyle>
    </>
  )
}

export default Login;
// store / authStore.ts

import { create } from 'zustand';

interface StoreState {
  isloggedIn: boolean;
  storeLogin: (token: string) => void;
  storeLogout: () => void;
}

const getToken = () => {
  const token = localStorage.getItem("token");
  return token;
}

const setToken = (token: string) => {
  localStorage.setItem("token", token);
}

const removeToken = () => {
  localStorage.removeItem("token");
}

export const useAuthStore = create<StoreState>((set) => ({
  isloggedIn: getToken() ? true : false, // 초기값

  // 액션
  storeLogin: (token: string) => {
    set({isloggedIn: true});
    setToken(token);
  },
  storeLogout: () => {
    set({isloggedIn: false});
    removeToken();
  }
}));

✏️ 로그인과 전역상태 (2)

  • isloggedIn 상태를 Header에서 사용 & 로그아웃
// components / common / Header.tsx

import { styled } from 'styled-components';
import logo from "../../assets/logo.png";
import { FaSignInAlt, FaRegUser } from "react-icons/fa";
import { Link } from 'react-router-dom';
import { useCategory } from '../../hooks/useCategory';
import { useAuthStore } from '../../store/authStore';

const Header = () => {
  const { category } = useCategory();
  const { isloggedIn, storeLogout } = useAuthStore();

  return (
    <HeaderStyle>
      <h1 className="logo">
        <Link to="/">
          <img src={logo} alt="book store"/ >
        </Link>
      </h1>
      <nav className="category">
        <ul>
          {
            category.map((item) => (
              <li key={item.category_id}>
                <Link to={item.category_id === null ? '/books' : `/books?category_id=${item.category_id}`}>
                  {item.category_name}
                </Link>
              </li>
            ))
          }
        </ul>
      </nav>
      <nav className="auth">
        {
          isloggedIn && (
            <ul>
              <li><Link to="/cart">장바구니</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>
          )
        }
      </nav>
    </HeaderStyle>
  )
}

... 생략 ...
  • http client : headers.authorization 적용 (http client 수정)
    • 로그인 후에, 인증이 필요한 부분은 headers에 authorization으로 넣을 것임
// api / http.ts

import axios, { AxiosRequestConfig } from 'axios';
import { getToken } from '../store/authStore';

const BASE_URL = "http://127.0.0.1:9999";
const DEFAULT_TIMEOUT = 30000;

export const createClient = (config?: AxiosRequestConfig) => {
  const axiosInstance = axios.create({
    baseURL : BASE_URL,
    timeout : DEFAULT_TIMEOUT,
    headers : {
      "content-type" : "application/json",
      "authorization" : getToken() ? getToken() : "",
    },
    withCredentials: true,
    ...config,
  });

  axiosInstance.interceptors.response.use(
    (response) => { return response },
    (error) => { 
	     // 로그인 만료 처리
      if(error.response.status === 401) {
        removeToken();
        window.location.href = "/login";
        return;
      }
      return Promise.reject(error)
    }
  );

  return axiosInstance;
};

export const httpClient = createClient();
  • 잘못된 이메일과 비밀벙호를 입력했을 때 -> 실패 처리
// pages / Login.tsx

... 생략 ...

  const onSubmit = (data: SignupProps) => {
    login(data).then((res) => {
      
      // 상태 변화
      storeLogin(res.token);
      console.log(res.token);

      showAlert("로그인 완료되었습니다.");
      navigate("/");
    }, (error) => {
      showAlert("로그인이 실패했습니다.");
    });
  };
  
  
  ... 생략 ...

✏️ 도서 목록 페이지 (1)

  • 도서 목록 화면 요구 사항

    • 도서의 목록을 fetch 하여 화면에 렌더
    • 페이지네이션 구현
    • 검색 결과가 없을 때, 결과 없음 하면 노출
    • 카테고리 및 신간 필터 기능을 제공
    • 목록의 view는 그리드 형태, 목록 형태로 변경 가능
  • 도서 목록 페이지 구조

  • BookList

// App.tsx

... 생략 ...

import Books from './pages/Books';

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout><Home /></Layout>,
    errorElement: <Error />
  },
  {
    path: "/books",
    element: <Layout><Books /></Layout>,
    errorElement: <Error />
  },
  
  ... 생략 ...
// pages / Books.tsx

import styled from 'styled-components'
import Title from '../components/common/Title';
import BooksFilter from '../components/books/BooksFilter';
import BooksList from '../components/books/BooksList';
import BooksEmpty from '../components/books/BooksEmpty';
import Pagination from '../components/books/Pagination';
import BooksViewSwitcher from '../components/books/BooksViewSwitcher';

const Books = () => {
  return (
    <>
    <Title size="large">도서 검색 결과</Title>
    <BooksStyle>
      <BooksFilter />
      <BooksViewSwitcher />
      <BooksList />
      <BooksEmpty />
      <Pagination />
    </BooksStyle>
    </>
  )
}

const BooksStyle = styled.div`

  `;

export default Books
// components / books / BooksList.tsx

import styled from 'styled-components'
import BookItem from './BookItem';
import { Book } from '../../models/book.model';

const dummyBook: Book = {
  id: 1,
  title: "Dummy Book",
  img: 7,
  category_id: 1,
  summary: "Dummy Summary",
  author: "Dummy Author",
  price: 10000,
  likes: 1,
  form : "paperback",
  isbn : "Dummy ISBN",
  detail : "Dummy Detail",
  pages: 100,
  contents: "Dummy Contents",
  pubDate: "2021-01-01",
}

const BooksList = () => {
  return (
    <BooksStyle>
      <BookItem book={dummyBook}/>
    </BooksStyle>
  )
}

const BooksStyle = styled.div`

`;

export default BooksList
// components / books / BookItem.tsx

import styled from 'styled-components'
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import formatNumber from '../../utils/format';
import { FaHeart } from "react-icons/fa";

interface Props {
  book: Book;
}

const BookItem = ({ book }: Props) => {
  return (
    <BookItemStyle>
      <div className="img">
        <img src={getImgSrc(book.img)} alt={book.title} />
      </div>
      <div className="content">
        <h2 className="title">{book.title}</h2>
        <p className="summary">{book.summary}</p>
        <p className="author">{book.author}</p>
        <p className="price">{formatNumber(book.price)}원</p>
        <div className="likes">
          <FaHeart />
          <span>{book.likes}</span>
        </div>
      </div>
    </BookItemStyle>
  )
}

const BookItemStyle = styled.div`

`;

export default BookItem
// utils / image.ts

export const getImgSrc = (id: number) => {
  return `https://picsum.photos/id/${id}/200/200`;
}
// utils / format.ts

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

export default formatNumber;

✏️ 도서 목록 페이지 (2)

  • BookItem 스타일링
// components / books / BookItem.tsx

import styled from 'styled-components'
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import formatNumber from '../../utils/format';
import { FaHeart } from "react-icons/fa";

interface Props {
  book: Book;
}

const BookItem = ({ book }: Props) => {
  return (
    <BookItemStyle>
      <div className="img">
        <img src={getImgSrc(book.img)} alt={book.title} />
      </div>
      <div className="content">
        <h2 className="title">{book.title}</h2>
        <p className="summary">{book.summary}</p>
        <p className="author">{book.author}</p>
        <p className="price">{formatNumber(book.price)}원</p>
        <div className="likes">
          <FaHeart />
          <span>{book.likes}</span>
        </div>
      </div>
    </BookItemStyle>
  )
}

const BookItemStyle = styled.div`
  display: flex;
  flex-direction: column;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);

  .img {
    border-radius: ${({theme}) => theme.borderRadius.default};
    overflow: hidden;
    img {
      max-width: 100%;
    }
  }

  .content {
    padding: 16px;
    position: relative;

    .title {
      font-size: 1.25rem;
      font-weight: 700;
      margin: 0 0 12px 0;
    }
    .summary {
      font-size: 0.875rem;
      color: ${({theme}) => theme.colors.secondary};
      margin: 0 0 4px 0;
    }
    .author {
      font-size: 0.875rem;
      color: ${({theme}) => theme.colors.secondary};
      margin: 0 0 4px 0;
    }
    .price {
      font-size: 1rem;
      color: ${({theme}) => theme.colors.secondary};
      margin: 0 0 4px 0;
      font-weight: 700;
    }
    .likes {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      font-size: 0.875rem;
      color: ${({theme}) => theme.colors.primary};
      margin: 0 0 4px 0;
      font-weight: 700;
      border: 1px solid ${({theme}) => theme.colors.border};
      border-radius: ${({theme}) => theme.borderRadius.default};
      padding: 4px 12px;
      position: absolute;
      bottom: 16px;
      right: 16px;

      svg {
        color: ${({theme}) => theme.colors.primary};
      }
    }
  }
`;

export default BookItem

  • BookItem 테스트
// components / books/ BookItem.spec.tsx

import { render } from "@testing-library/react";
import BookItem from './BookItem';
import { BookStoreThemeProvider } from '../../context/themeContext';
import { Book } from '../../models/book.model';

const dummyBook: Book = {
  id: 1,
  title: "Dummy Book",
  img: 7,
  category_id: 1,
  summary: "Dummy Summary",
  author: "Dummy Author",
  price: 10000,
  likes: 1,
  form : "paperback",
  isbn : "Dummy ISBN",
  detail : "Dummy Detail",
  pages: 100,
  contents: "Dummy Contents",
  pubDate: "2021-01-01",
};

describe("BookItem 테스트", () => {
  it("렌더 여부", () => {
    const { getByText, getByAltText } = render(
      <BookStoreThemeProvider>
        <BookItem book={dummyBook} />
      </BookStoreThemeProvider>
    );

    expect(getByText(dummyBook.title)).toBeInTheDocument();
    expect(getByText(dummyBook.summary)).toBeInTheDocument();
    expect(getByText(dummyBook.author)).toBeInTheDocument();
    expect(getByText(dummyBook.likes)).toBeInTheDocument();
    expect(getByAltText(dummyBook.title)).toHaveAttribute("src", `https://picsum.photos/id/${dummyBook.img}/200/200`);
  })
})

✏️ 도서 목록 페이지 (3)

  • BooksEmpty
// components / books / BooksEmpty.tsx

import styled from 'styled-components'
import { FaSmileWink } from 'react-icons/fa';
import Title from '../common/Title';
import { Link } from 'react-router-dom';
const BooksEmpty = () => {
  return (
    <BooksEmptyStyle>
      <div className="icon">
        <FaSmileWink />
      </div>
      <Title size="large" color="secondary">
        검색 결과가 없습니다.
      </Title>
      <p>
        <Link to="/books">전체 검색 결과로 이동</Link>
      </p>
    </BooksEmptyStyle>
  )
}

const BooksEmptyStyle = styled.div`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 12px;
  padding: 120px 0;

  .icon {
    svg {
      font-size: 4rem;
      fill: #ccc;
    }
  }
`;

export default BooksEmpty

  • BookFilter
    • 목록은 쿼리 스트링을 이용할 예정임
      • 상태 공유 → 쿼리 스트링을 그대로 복사해서 다른 브라우저에 붙여넣기 할 때 상태를 유지할 수 있음
      • 재사용성 보장
      • 해당 주소가 유니크한 결과값을 늘 갖고 있음 → 검색 엔진 최적화에 유리
      • 특정 마켓팅 측면 → 데이터 추적과 분석에 용이함
    • 쿼리스트링 업데이트 구조
  • 필터 틀 만들기
// components / books / BooksFilter.tsx

import styled from 'styled-components'
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';

const BooksFilter = () => {

  const { category } = useCategory();

  return (
    <BooksFilterStyle>
      <div className="category">
        {
          category.map((item) => (
            <Button size="medium" scheme="normal" key={item.category_id}>
              {item.category_name}
            </Button>
          ))
        }
      </div>
      <div className="new">
        <Button size="medium" scheme="normal">신간</Button>
      </div>
    </BooksFilterStyle>
  )
}

const BooksFilterStyle = styled.div`
  display: flex;
  gap: 24px;

  .category {
    display: flex;
    gap: 8px;
  }
`;

export default BooksFilter

✏️ 도서 목록 페이지 (4)

// components / books / BooksFilter.tsx

import styled from 'styled-components'
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';

const BooksFilter = () => {

  const { category } = useCategory();
  const [searchParams, setSearchParams] = useSearchParams();
  // 쿼리 스트링을 사용할 수 있게됨

  const handleCategory = (id: number | null) => {
    const newSearchParams = new URLSearchParams(searchParams);
		// 인스턴스를 생성해 그 인스턴스로 하여금 쿼리스트링의 내용에 access 하거나 
		// set을 할 수 있는 도구들 제공
    if(id === null) {
      newSearchParams.delete("category_id");
    } else {
      newSearchParams.set("category_id", id.toString());
    }

    setSearchParams(newSearchParams);
  }
  
  const currentCategory = searchParams.get("category_id");

  return (
    <BooksFilterStyle>
      <div className="category">
        {
          category.map((item) => (
            <Button size="medium" scheme={currentCategory === 
            item.category_id?.toString() ? 'primary' : 'normal'} 
            key={item.category_id} onClick={()=> 
            handleCategory(item.category_id)}>
              {item.category_name}
            </Button>
          ))
        }
      </div>
      <div className="new">
        <Button size="medium" scheme="normal">신간</Button>
      </div>
    </BooksFilterStyle>
  )
}

const BooksFilterStyle = styled.div`
  display: flex;
  gap: 24px;

  .category {
    display: flex;
    gap: 8px;
  }
`;

export default BooksFilter

  • 그런데 코드의 장황함 생김 → 카테고리의 searchParams를 표시할 인디케이터가 있을때마다 분기하는것이 불필요하다고 생각 됨 → 리펙토링

  • useCategory(훅)안에 active를 넣는다면, 필터와 헤더에서도 사용할 수 있을 것임

  • 훅에 active가 되는 로직 넣기

// models / category.model.ts

export interface Category {
  category_id: number | null;
  category_name: string;
  isActive?: boolean;
}
// hooks / useCategory.ts

import { useState, useEffect  } from 'react';
import { Category } from '../models/category.model';
import { fetchCategory } from '../api/category.api';
import { useLocation } from 'react-router-dom';


export const useCategory = () => {
  const location = useLocation();
  // location 안에 여러 url값 중, search라는 값을 사용할 예정임
  const [category, setCategory] = useState<Category[]>([]);

  const setActive = () => {
    const params = new URLSearchParams(location.search);
    
    if(params.get('category_id')) {
      setCategory((prev) => {
        return prev.map((item) => {
          return {...item, isActive: item.category_id === Number(params.get('category_id'))}
        })
      })
    } else {
      setCategory((prev) => {
        return prev.map((item) => {
          return {...item, isActive: false}
        })
      })
    }
  }

  useEffect(() =>{
    fetchCategory().then((category) => {

      if(!category) return;

      const categoryWithAll = [
        {
          category_id: null,
          category_name: '전체',
        },
        ...category,
      ]

      setCategory(categoryWithAll);
      setActive();
    });
  }, []);

  useEffect(() => {
    setActive();
  }, [location.search]);

  return { category };
}
// componentes / books / BooksFilter.tsx

import styled from 'styled-components'
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';

const BooksFilter = () => {

  const { category } = useCategory();
  const [searchParams, setSearchParams] = useSearchParams();
  

  const handleCategory = (id: number | null) => {
    const newSearchParams = new URLSearchParams(searchParams); 

    if(id === null) {
      newSearchParams.delete("category_id");
    } else {
      newSearchParams.set("category_id", id.toString());
    }

    setSearchParams(newSearchParams);
  }

  const handleNews = () => {
    const newSearchParams = new URLSearchParams(searchParams);

    if(newSearchParams.get('news')) {
      newSearchParams.delete("news");
    } else {
      newSearchParams.set("news", "true");
    }

    setSearchParams(newSearchParams);
  }

  return (
    <BooksFilterStyle>
      <div className="category">
        {
          category.map((item) => (
            <Button size="medium" scheme={item.isActive ? "primary" : "normal"} key={item.category_id} onClick={()=> handleCategory(item.category_id)}>
              {item.category_name}
            </Button>
          ))
        }
      </div>
      <div className="new">
        <Button size="medium" scheme={searchParams.get('news') ? 
        "primary" : "normal"} onClick={()=> handleNews()}>신간</Button>
      </div>
    </BooksFilterStyle>
  )
}

const BooksFilterStyle = styled.div`
  display: flex;
  gap: 24px;

  .category {
    display: flex;
    gap: 8px;
  }
`;

export default BooksFilter

✏️ 도서 목록 페이지 (5)

  • 쿼리스트링을 상수로 관리하도록 (따로 파일에 모아서 관리) → 나중에 변경할때 그 상수만 업데이트 하면 됨 → 확장성과 유지보수에 좋음
// constants / querystring.ts

export const QUERYSTRING = {
  CATEGORY_ID : "category_id",
  NEWS : "news",
  PAGE: "page",
}
// components / books / BooksFilter.tsx

import styled from 'styled-components'
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';

const BooksFilter = () => {

  const { category } = useCategory();
  const [searchParams, setSearchParams] = useSearchParams();

  const handleCategory = (id: number | null) => {
    const newSearchParams = new URLSearchParams(searchParams);

    if(id === null) {
      newSearchParams.delete(QUERYSTRING.CATEGORY_ID);
    } else {
      newSearchParams.set(QUERYSTRING.CATEGORY_ID, id.toString());
    }

    setSearchParams(newSearchParams);
  }

  const handleNews = () => {
    const newSearchParams = new URLSearchParams(searchParams);
    
    if(newSearchParams.get(QUERYSTRING.NEWS)) {
      newSearchParams.delete(QUERYSTRING.NEWS);
    } else {
      newSearchParams.set(QUERYSTRING.NEWS, "true");
    }
    
    setSearchParams(newSearchParams);
  }
  
  ... 생략 ...
  • 변경된 쿼리스트링을 감지해서 새롭게 내용을 fetch하고, 그럼으로써 books라는 도서 목록을 갱신해 전달하는 훅을 만들어보자
    • 도서 목록 카테고리별로 나오게 하기 (신간 아직 미적용)
// api / books.api.ts

import { Book } from '../models/book.model';
import { Pagination } from '../models/pagination.model';
import { httpClient  } from './http';

interface FetchBooksParams {
  category_id?: number;
  news?: boolean;
  currentPage?: number;
  limit: number;
} 

interface FetchBooksResponse {
  books : Book[],
  pagination: Pagination;
}

export const fetchBooks = async (params:FetchBooksParams) => {
  try {
    const response = await httpClient.get<FetchBooksResponse>("/books", {
      params: params, 
    });
  
    return response.data;

  } catch(error) {
      return {
        books: [],
        pagination: {
          totalCount: 0,
          currentPage: 1,
        },
      };
  }
}
// constants / pagination.ts

export const LIMIT = 8;
// hooks / useBooks.ts

import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom'
import { Book } from '../models/book.model';
import { Pagination } from '../models/pagination.model';
import { fetchBooks } from '../api/books.api';
import { QUERYSTRING } from '../constants/querystring';
import { LIMIT } from '../constants/pagination';

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

  const [books, setBooks] = useState<Book[]>([]);
  const [pagination, setPagination] = useState<Pagination>(
    {
      totalCount: 0,
      currentPage: 1,
    }
  );

  const [isEmpty, setIsEmpty] = useState(true);
  // books의 배열이 위에서 빈 배열이 기본값이므로 True

  useEffect(() => {
    const params = new URLSearchParams(location.search);

    fetchBooks({
      category_id: params.get(QUERYSTRING.CATEGORY_ID) ? 
      Number(params.get(QUERYSTRING.CATEGORY_ID)) : undefined,
      news: params.get(QUERYSTRING.NEWS) ? 
      true : undefined,
      currentPage: params.get(QUERYSTRING.PAGE) ? 
      Number(params.get(QUERYSTRING.PAGE)) : 1,
      limit: LIMIT,
    }).then(({books, pagination}) => {
      setBooks(books);
      setPagination(pagination);
      setIsEmpty(books.length === 0);
    })
  }, [location.search]);

  return { books, pagination, isEmpty } ;
};
// pages / Books.tsx

import styled from 'styled-components'
import Title from '../components/common/Title';
import BooksFilter from '../components/books/BooksFilter';
import BooksList from '../components/books/BooksList';
import BooksEmpty from '../components/books/BooksEmpty';
import Pagination from '../components/books/Pagination';
import BooksViewSwitcher from '../components/books/BooksViewSwitcher';
import { useBooks } from '../hooks/useBooks';

const Books = () => {
  const { books, pagination, isEmpty } = useBooks();

  return (
    <>
    <Title size="large">도서 검색 결과</Title>
    <BooksStyle>
      <div className="filter">
        <BooksFilter />
        <BooksViewSwitcher />
      </div>
      { !isEmpty && <BooksList books={books} /> }
      { isEmpty && <BooksEmpty /> }
      { !isEmpty && <Pagination /> }
    </BooksStyle>
    </>
  )
}

const BooksStyle = styled.div`
	display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: 24px;

  .filter {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px 0;
  }
  `;

export default Books
// components / books / BookList.tsx

import styled from 'styled-components'
import BookItem from './BookItem';
import { Book } from '../../models/book.model';

interface Props {
  books: Book[];
}

const BooksList = ({books}: Props) => {
  return (
    <BooksStyle>
      {
        books.map((item) => (<BookItem key={item.id} book={item} />))
      }
    </BooksStyle>
  )
}

const BooksStyle = styled.div`
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 24px;
`;

export default BooksList

*그리드 관련 글 참조 : https://studiomeal.com/archives/533

✏️ 도서 목록 페이지 (6)

  • Pagination
// pages / Books.tsx

... 생략 ...

      { !isEmpty && <BooksList books={books} /> }
      { isEmpty && <BooksEmpty /> }
      { !isEmpty && <Pagination pagination={pagination} /> }
    </BooksStyle>
    
    ... 생략 ...
// components / books / Pagination.tsx

import styled from 'styled-components'
import { Pagination as IPagination} from '../../models/pagination.model';
import { LIMIT } from '../../constants/pagination';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';

interface Props {
  pagination: IPagination;
}

const Pagination = ({pagination} : Props) => {
  const [searchParams, setSearchParams] = useSearchParams();
  const { totalCount, currentPage } = pagination;
  const pages: number = Math.ceil(totalCount / LIMIT);

  const handleClickPage = (page: number) => {
    const newSearchParams = new URLSearchParams(searchParams);

    newSearchParams.set(QUERYSTRING.PAGE, page.toString());

    setSearchParams(newSearchParams);
  }
  return (
    <PaginationStyle>
      {
        pages > 0 && (
          <ol>
            {
              Array(pages).fill(0).map((_, index) => (
                <li>
                  <Button key={index} size="small" 
                  scheme={index + 1 === currentPage ? "primary" : "normal"} 
                  onClick={() => {handleClickPage(index+1)} }>
                  {index + 1}</Button>
                </li>
              ))
            }
          </ol>
        )
      }
    </PaginationStyle>
  )
}

const PaginationStyle = styled.div`
  display: flex;
  justify-content: start;
  align-items: center;
  padding: 24px;

  ol {
    list-style: none;
    display: flex;
    gap: 8px;
    padding: 0;
    margin: 0;
  }
`;

export default Pagination

*Array.fill().map() 관련 글 : https://velog.io/@reasonz/2022.08.15-Array.fill.map-%EC%B2%B4%EC%9D%B4%EB%8B%9D-%ED%95%B4%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

✏️ 도서 목록 페이지 (7)

  • BooksViewSwitcher 작업
    • 컴포넌트를 구조화할 예정 → 옵션을 두겠다는 뜻임
// constants / querysting.ts

export const QUERYSTRING = {
  CATEGORY_ID : "category_id",
  NEWS : "news",
  PAGE: "page",
  VIEW: "view",
}
// comonents / books / BooksViewSwitcher.tsx

import styled from 'styled-components'
import Button from '../common/Button';
import { FaList, FaTh } from 'react-icons/fa';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
import { useEffect } from 'react';

const viewOptions = [
  {
    value: "list",
    icon: <FaList />
  },
  {
    value: "grid",
    icon: <FaTh />
  }
]
export type ViewMode = "grid" | "list";

const BooksViewSwitcher = () => {
  const [searchParams, setSearchParams] = useSearchParams();

  const handleSwitch = (value: ViewMode) => {
    const newSearchParams = new URLSearchParams(searchParams);

    newSearchParams.set(QUERYSTRING.VIEW, value);
    setSearchParams(newSearchParams);
  };

  useEffect(()=>{
    if(!searchParams.get(QUERYSTRING.VIEW)) {
      handleSwitch("grid");
    }
  }, []);

  return (
    <BooksViewSwitcherStyle>
      {
        viewOptions.map((option) => (
          <Button key={option.value} size="medium" 
          scheme={searchParams.get(QUERYSTRING.VIEW) === option.value ? "primary" : "normal"} 
          onClick={()=>{
            handleSwitch(option.value as ViewMode)
          }}>{option.icon}</Button>
        ))
      }
    </BooksViewSwitcherStyle>
  )
}

const BooksViewSwitcherStyle = styled.div`
  display: flex;
  gap: 8px;
  svg {
    fill: #fff;
  }  
`;

export default BooksViewSwitcher
// components / books / BooksList.tsx

import styled from 'styled-components'
import BookItem from './BookItem';
import { Book } from '../../models/book.model';
import { useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { QUERYSTRING } from '../../constants/querystring';
import { ViewMode } from './BooksViewSwitcher';

interface Props {
  books: Book[];
}

const BooksList = ({books}: Props) => {
  const location = useLocation();
  const [view, setView] = useState<ViewMode>("grid");

  useEffect(()=>{
    const params = new URLSearchParams(location.search);
    if(params.get(QUERYSTRING.VIEW)) {
      setView(params.get(QUERYSTRING.VIEW) as ViewMode);
    }
  }, [location.search]);

  return (
    <BooksListStyle view={view}>
      {
        books?.map((item) => (<BookItem key={item.id} book={item} view={view} />))
      }
    </BooksListStyle>
  )
}

interface BooksListStyleProps {
  view: ViewMode;
}

const BooksListStyle = styled.div<BooksListStyleProps>`
  display: grid;
  grid-template-columns: ${({view}) => (
    view === "grid" ? "repeat(5, 1fr);" : "repeat(1, 1fr);"
  )};
  gap: 24px;
`;

export default BooksList
// components / books / BookItem.tsx

import styled from 'styled-components'
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import formatNumber from '../../utils/format';
import { FaHeart } from "react-icons/fa";
import { ViewMode } from './BooksViewSwitcher';

interface Props {
  book: Book;
  view?: ViewMode;
}

const BookItem = ({ book, view }: Props) => {
  return (
    <BookItemStyle view={view}>
      <div className="img">
        <img src={getImgSrc(book.img)} alt={book.title} />
      </div>
      <div className="content">
        <h2 className="title">{book.title}</h2>
        <p className="summary">{book.summary}</p>
        <p className="author">{book.author}</p>
        <p className="price">{formatNumber(book.price)}원</p>
        <div className="likes">
          <FaHeart />
          <span>{book.likes}</span>
        </div>
      </div>
    </BookItemStyle>
  )
}

const BookItemStyle = styled.div<Pick<Props, 'view'>>`
  display: flex;
  flex-direction: ${({view}) => view === 'grid' ? "column" : "row"};
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);

  .img {
    border-radius: ${({theme}) => theme.borderRadius.default};
    overflow: hidden;
    width: ${({view}) => view === 'grid' ? "auto" : "160px"};
    img {
      max-width: 100%;
    }
  }

  .content {
    padding: 16px;
    position: relative;
    flex: ${({view}) => view === 'grid' ? "0" : "1"};

    .title {
      font-size: 1.25rem;
      font-weight: 700;
      margin: 0 0 12px 0;
    }
    .summary {
      font-size: 0.875rem;
      color: ${({theme}) => theme.colors.secondary};
      margin: 0 0 4px 0;
    }
    .author {
      font-size: 0.875rem;
      color: ${({theme}) => theme.colors.secondary};
      margin: 0 0 4px 0;
    }
    .price {
      font-size: 1rem;
      color: ${({theme}) => theme.colors.secondary};
      margin: 0 0 4px 0;
      font-weight: 700;
    }
    .likes {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      font-size: 0.875rem;
      color: ${({theme}) => theme.colors.primary};
      margin: 0 0 4px 0;
      font-weight: 700;
      border: 1px solid ${({theme}) => theme.colors.border};
      border-radius: ${({theme}) => theme.borderRadius.default};
      padding: 4px 12px;
      position: absolute;
      bottom: 16px;
      right: 16px;

      svg {
        color: ${({theme}) => theme.colors.primary};
      }
    }
  }
`;

export default BookItem

**궁금한 것 : 카테고리별로 버튼을 눌렀을 때, 책 목록이 잘 나오는 것 확인했음!
신간 클릭시, 쿼리 스트링에 의해 쿼리 달라져서 신간+카테고리별 도서 목록 나오는 것 확인했지만,
카테고리별 책에서 신간이 아닌 것을 토글하면 오류 발생함.. -> 이부분 쿼리 바꿔보고 몇시간 찾아봤지만 해결하지 못함 ㅠㅠㅠㅠ 멘토님께 여쭤봐야겠다...

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

0개의 댓글