프로젝트/BookStore 사이트

1127 비밀번호 초기화, 도서 목록 페이지 구현

thinktank911 2025. 11. 27. 15:17

비밀번호 초기화

  • 회원가입과 유사해서 SignupStyle 재활용
  • App.tsx에 라우트 등록
  • resetRequested : 리셋 요청 여부에 따라 submit 시 분기처리

ResetPassword.tsx

import { useForm } from "react-hook-form";
import styled from "styled-components";
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 { use, useState } from "react";
import { resetPassword, resetRequest, signup } from "../api/auth.api";
import { useAlert } from "../hooks/useAlert";
import { SignupStyle } from "./Signup";

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

function ResetPassword() {
    const navigate = useNavigate();
    const showAlert = useAlert();

    // 리셋 요청 여부
    const [resetRequested, setResetRequested] = useState(false);

    // react-hook-form 이용해 form 관리 
    const { register, 
            handleSubmit, 
            formState: { errors}
          } = useForm<SignupProps>();

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

  return (
    <>
        <Title size="large">비밀번호 초기화</Title>
        <SignupStyle>
            <form onSubmit={handleSubmit(onSubmit)}>
                <fieldset>
                    <InputText  placeholder="이메일"
                        // inputType="email" value={email}
                        // onChange={(e) => setEmail(e.target.value)}
                        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="large" scheme="primary">
                        {resetRequested ? "비밀번호 초기화" : "비밀번호 초기화 요청"}
                    </Button>
                </fieldset>
                <div className="info">
                    <Link to="/reset">비밀번호 초기화</Link>
                </div>
            </form>
        </SignupStyle>
    </>

  );
}

export default ResetPassword;
  • 초기화 요청과 초기화 api 메소드 작성

auth.api.ts

// 비밀번호 리셋 요청
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",   // put 메소드
        data);
    return response.data;
}

 

로그인과 전역 상태

 

  • zustand 사용

 

로그인 완료 후 조치

store 만들기 위해 zustand 사용
설치 : npm i zustand --save

  • 상태정보와 액션함수 같이 선언
  • 로컬스토리지에 토큰 관리

authStore.ts

import { create } from "zustand";

// 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");
};

// set : isloggedIn과 같은 상태 정보를 변경할 수 있게 된다.
export const useAuthStore = create<StoreState>((set) => ({
    isloggedIn: getToken() ? true : false,  // 초기값
    storeLogin: (token:string) => {
        set(() => ({ isloggedIn: true }))
        setToken(token);
    },  // 액션함수
    storeLogout: () => {
        set(() => ({ isloggedIn: false }))
        removeToken();
    }
}));

 

Login.tsx

// zustand 상태관리 모듈
const { isloggedIn, storeLogin, storeLogout } = useAuthStore();

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

 

로그아웃 처리

  • Header.tsx에 useAuthStore() 셋팅
  • isloggedIn으로 UI 분기처리
  • 로그아웃 버튼 onClick 시 storeLogout 실행

headers에 Authorization 토큰 넣기

  • http.ts에 headers에 Authorization 추가
    headers: {
        "Content-Type": "application/json",
        Authorization: getToken() ? getToken() : "",
    },

 

로그인 만료 처리

 

http.ts

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

    });

 

도서 목록 페이지

도서 목록 화면 요구사항

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

도서 목록 페이지 구조

도서아이템

이미지 관리 위해 유틸 함수 생성

 

image.ts

export const getImgSrc = (id:number) => {
    return `https://picsum.photos/id/${id}/info/600/600`;
}

 

BookItem.ts

interface Props {
    book: Book;
}

function 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>
        <div className="likes">
            {/* <FaHeart /> */}
            <span>{book.likes}</span>
        </div>
    </BookItemStyle>
  );
}

 

 

도서아이템 테스트하기

  • getByAltText 활용

BookItem.spec.tsx

import { render, screen } from "@testing-library/react";
import BookItem from "./BookItem";
import { BookStoreThemeProvider } from "../../context/ThemeContext";

const dumyBook = {
    id: 1,
    title: "어린왕자들",
    img: 7,
    category_id: 0,
    form: "종이책",
    isbn: "0",
    summary: "어리다..",
    detail: "많이 어리다..",
    author: "김어림",
    pages: 100,
    contents: "목차입니다.",
    price: 20000,
    likes: 3,
    pubDate: "2025-10-01",
};

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

        expect(getByText(dumyBook.title)).toBeInTheDocument();
        expect(getByText(dumyBook.summary)).toBeInTheDocument();
        expect(getByText(dumyBook.author)).toBeInTheDocument();
        expect(getByText("20,000원")).toBeInTheDocument();
        expect(getByText(dumyBook.likes)).toBeInTheDocument();
        expect(getByAltText(dumyBook.title)).toHaveAttribute("src", 
            `https://picsum.photos/id/${dumyBook.img}/info/600/600`
        );
    });
})

 

BooksEmpty.tsx

function BooksEmpty(props: Props) {
  return (
    <BooksEmptyStyle>
        <div className="icon">
            {/* <FaSmileWink /> */}
        </div>
        <Title size="large" color="secondary">
            검색 결과가 없습니다.
        </Title>
        <p>
            <Link to="/books">전체 검색 결과로 이동</Link>
        </p>
    </BooksEmptyStyle>
  );
}

 

 

BooksFilter

쿼리스트링 업데이트

 

 

url 쿼리 변경 감지해서 화면 렌더링

  • useCategory로 버튼 데이터 map 출력
  • useSearchParams 로 쿼리 스트링 감지
    • URLSearchParams : url에 접근하거나 수정할 수 있는 유틸리티
  • button active
    • 일일히 넣는 거 복잡
    • useCategory 훅에 active 정보 넣기
    • useLocation 활용 : location.search - 쿼리스트링

 

BooksFilter.tsx

function BooksFilter(props: Props) {
    // 상태
    // 1. 카테고리
    // 2. 신간 여부 true, false
    // 쿼리스트링 이용하기 : 상태 공유 가능, 재사용성, 검색엔진 최적화, 데이터 추적 분석에 용이

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

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

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

        setSearchParams(newSearchParams); // 실제 업데이트
    }

    // 현재 선택된 카테고리 가져오기
    // const currentCategory = searchParams.get("category_id");
    // console.log(currentCategory);

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

        if(newSearchParams.has("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.has("news") ? "primary" : "normal"}
                onClick={()=> handeleNews()}
            >
                신간
            </Button>
        </div>
    </BooksFilterStyle>
  );
}

 

useCategory.ts

export const useCategory = () =>{
    const location = useLocation();
    const [category, setCategory] = useState<Category[]>([]);

    const setActive = () => {
        const params = new URLSearchParams(location.search);
        const categoryId = params.get("category_id");
        if(categoryId){
            setCategory((prev) => {
                return prev.map((item) => {
                    return { 
                        ...item, 
                        isActive: item.category_id === Number(categoryId)
                    };
                });
            });
        } else {
            // 전체일 경우 active 항상 false
            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 };
}

 

쿼리스트링 상수화

 

querystring.ts

// 쿼리스트링 키 상수화
export const QERYSTRING = {
    CATEGORY_ID: "category_id",
    NEWS: "news"
}

 

 

페이지네이션

 

pagination.tsx

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

 

BooksViewSwitcher

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/>,
    label: "리스트",
  },
  {
    value: "grid",
    // icon: <FaTh/>,
    label: "그리드",
  },
]

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

  const handleSwitch = (value: string) => {
    const newSearchParams = new URLSearchParams(searchParams);
    newSearchParams.set(QUERYSTRING.VIEW, value);
    setSearchParams(newSearchParams);
  }

  // 첫 화면 때 디폴트 값
  useEffect(() => {
    if(!searchParams.get(QUERYSTRING.VIEW)){
      handleSwitch("grid"); // 그리드가 default
    }
  }, [])


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

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

export default BooksViewSwitcher;

 

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

function 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>
        <div className="likes">
            {/* <FaHeart /> */}
            <span>{book.likes}</span>
        </div>
    </BookItemStyle>
  );
}

const BookItemStyle = styled.div<Pick<Props, "view">>`
    display: flex;
    flex-direction: ${({view}) => (view === "grid" ? "column" : "row")};
    position: relative;
    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.color.secondary};
            margin: 0 0 4px 0;
        }
        .author {
            font-size: 0.875rem;
            color: ${({theme}) => theme.color.secondary};
            margin: 0 0 4px 0;
        }
        .price {
            font-size: 1rem;
            font-weight: 700;
            color: ${({theme}) => theme.color.secondary};
            margin: 0 0 4px 0;
        }
    }

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

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

`;

export default BookItem;