비밀번호 초기화
비밀번호 까먹었을때 > 초기화 요청하는 방법
모킹 방법. 실무에서는 토큰을 전달하거나 헤더에 토큰을 심기도 하는 방법을 사용한다.
//auth.api
export const resetRequest =async (data: SignupProps) => {
const response = await httpClient.post('/users/reset', data); //데이터 바디로 보내준다
return response.data;
};
export const resetPwd =async (data: SignupProps) => {
const response = await httpClient.put('/users/reset', data); //데이터 바디로 보내준다
return response.data;
};
//ResetPwd.tsx
//...
const [resetRequested, setResetRequested] = useState(false);
const onSubmit = (data: SignupProps) => {
if (resetRequested) {
resetPwd(data).then(() => {
showAlert("비밀번호가 초기화되었습니다.");
navigate("/login");
})
} else {
//초기화 요청
resetRequest(data).then(() => {
setResetRequested(true);
});
}
};
//...
{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>
로그인 처리
로그인 성공 > 서버에서 주는 토큰 획득 > 로그인 전역 상태 GlobalState를 저장하기 위해 상태관리도구 Zustand를 사용한다. `isLoggedIn:true` + localstorage 토큰 저장 < http client, headers.Authorization
context api 컨텍스트 api로 테마 전역 상태관리를 한 바 있는데,
로그인 정보는 주스탠드 ZUSTAND 를 사용해서 관리해보겠다! (지금 다른 플젝에서는 jotai 사용중인데 비교해보기)
토큰을 쿠키에 담아서 헤더가 아닌, body 로 전송하도록 한다.
`return res.status(StatusCodes.OK).json({...results[0], token:token});`
interface LoginResponse {
token: string;
}
export const login = async (data: SignupProps) => {
const response = await httpClient.post<LoginResponse>('/users/login', data); //데이터 바디로 보내준다
return response.data;
};
//Login.tsx
const onSubmit = (data: SignupProps) => {
login(data).then((res)=>{
console.log(res);
showAlert("로그인 완료");
navigate('/');
})
};
///...
여기까지는 로그인의 처음만 동작하는거고, local 에 해당 정보를 저장하고, 전역으로 관리해야만 한다.
store 폴더 > `authStore.ts`
Zustand interface 는 상태정보 isloggedIn 상태정보와 액션함수 storeLogin 함수를 같이 저장한다.
import { create } from 'zustand';
interface StoreState {
isloggedIn: boolean;
storeLogin: (token: string) => void;
storeLogout: () => void;
}
export const useAuthStore = create<StoreState>((set) => ({
isloggedIn: getToken() ? true : false, //초기값
storeLogin: (token: string) => {
set({ isloggedIn: true });
setToken(token);
},
storeLogout: () => {
set({ isloggedIn: false });
removeToken();
},
}));
zustand 에서 토큰 설정을 해주어야 한다.
const getToken = () => {
const token = localStorage.getItem('token');
return token;
};
const setToken = (token: string) => {
localStorage.setItem('token', token);
};
const removeToken = () => {
localStorage.removeItem('token');
};
토큰을 localStorage 에 저장하고 지우는 함수.
새로고침이나 창닫침에도 로그인 유지하도록 getToken 으로 관리한다.
로그인 상태인 isloggedIn 을 헤더에서 사용하는 방법?
zustand 의 useAuthStore 을 가져와서, isloggedIn 체크를 하고, 값이 true 일경우, false 일경우 분기해서 표시한다.
const { isloggedIn, storeLogout } = useAuthStore();
//...
{isloggedIn && (
<ul>
<li>장바구니</li>
</ul>
)}
{!isloggedIn&& (
<ul>
<li>
//...
로그아웃 사용은, 로그아웃 btn 클릭 시 onClick useAuthStore 에서 작성한 함수인 storeLogout 이 호출되도록 하면 된다.
api 요청에서 headers 에 인증정보를 함께 요청보내는 방법으로, http.ts 코드의 headers 부분에 추가한다. 토큰 만료 시, 401 에러가 발생하기 때문에, localstorage 의 토큰을 지우고, login 페이지로 이동한다.(navigate를 바로 쓸 수 없다)
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;
};
로그인 틀렸을 경우, 처음에는 401 에러가 발생한다. 권한없음. then 으로 처리해주는게 좋다.
login(data).then(
(res) => {
storeLogin(res.token);
console.log(res.token);
showAlert('로그인 완료');
navigate('/');
},
(err) => {
showAlert('로그인 실패');
},
);
도서 목록 페이지
- 도서의 목록을 fetch 하여 화면에 렌더
- 페이지네이션
- 검색결과 없을때, 결과 없음 화면 놏ㄹ
- 카테고리 및 신간 필터 기능 제공
- 목록의 view 는 그리드 형태, 목록 형태로 변경 가능
Books 페이지에 하위 컴포넌트로 구성한다
const Books = () => {
return (
<>
<Title size="large">도서 검색 결과</Title>
<BookStyle>
<BooksFilter />
<BooksViewSwitcher />
<BooksList />
<BooksEmpty />
<Pagination />
</BookStyle>
</>
);
};
이미지 로드할때 매번 url 을 매칭하지 않도록 utils 에서 image.ts 로 매핑해준다. 모듈화!
//image.ts
export const getImgSrc=(id:number) =>{
return `https://picsum.photos/id/${id}/600/600`
}
//BookItem.tsx
<img src={getImgSrc(book.id)} alt={book.title} />
css : inline-flex, position: relative, absolute, static
BookItem.spec.tsx 테스트
변경이 많이 일어날 예정인 컴포넌트들은 테스트를 돌리는게 좋다.
ThemeProvider 가 적용되어있는 코드라면, 필수로 import 해주어야 css 가 잘 적용된다.
import React from 'react';
import { render } from '@testing-library/react';
import BookItem from './BookItem';
import { BookStoreThemeProvider } from '../../context/themeContext';
const dummyBook = {
id: 1,
title: 'dummy Book',
img: 5,
category_id: 1,
form: 'paperback',
isbn: 'dummy isbn',
summary: 'dummy summary',
detail: 'dummy detail',
author: 'dummy author',
pages: 100,
contents: 'dummy contents',
price: 10000,
likes: 1,
pubDate: '2024-07-31',
};
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('10,000원')).toBeInTheDocument();
expect(getByText(dummyBook.likes)).toBeInTheDocument();
expect(getByAltText(dummyBook.title)).toHaveAttribute(
'src',
`https://picsum.photos/id/${dummyBook.img}/600/600`,
);
});
});
BooksEmpty
import React from 'react';
import { FaSmileWink } from 'react-icons/fa';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import Title from '../common/Title';
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;
BooksFilter.tsx 카테고리, 신간여부 (쿼리스트링)
카테고리는 useState 가 아닌 쿼리스트링으로 제공되어 관리한다.
쿼리스트링으로 상태를 유지 및 공유할 수 있고,
재사용성, 검색엔진 최적화 유리, 마케팅의 데이터추적과 분석에 용이하다는 장점을 가지고 있다.
/books?category_id=0&news=true&view=grid
Filter 컴포넌트에서 각 필더의 변경 QS 를 요청한다 > useBooks api훅에서 QS 변경을 감지하고, fetch 를 실행시켜 화면을 갱신한다.
즉, 필터에서 QS의 상태를 변경하고, QS는 상태를 유지한 채, 훅에서 해당 상태로 갱신하는 api 를 요청한다.
유틸리티 메서드 new 를 했을때 인스턴스를 생성하고, 쿼리스트링의 access, set 을 할 수 있는 권한을 부여한다.
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;
훅에서 값이 존재하는지, isActive 매칭해준다.
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,
};
});
});
}
};
> 반복되는 상수 constent 폴더로 관리
export const QUERYSTRING = {
CATEGORY_ID: 'category_id',
NEWS: 'news',
};
useBooks.tsx 커스텀훅
변경된 쿼리스트링을 감지해서 fetch하는 기능
api는 params로 가져온다.
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,
},
};
}
};
fetchBooks로 params 값을 빼서 할당한다. 존재할 경우 set 함수로 새로할당한다.
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);
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 };
};
BookList 컴포넌트에 book 데이터 상태로 넘겨서, map.item.id 로 순회해서 표시한다.
페이지네이션 pagination
useBooks 에서 받아온 page 데이터를 pagination 값으로 전달한다. 클릭될 경우, params 에서 QS 페이지 값으로 세팅한다. 클릭 scheme 활성화 비활성화 작업을 한다.
import React from 'react';
import { Pagination as IPagination } from '../../models/pagination.model';
import { LIMIT } from 'styled-components/dist/utils/createWarnTooManyClasses';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring.';
import styled from 'styled-components';
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
size="small"
scheme={index + 1 === currentPage ? 'primary' : 'normal'}
key={index}
onClick={() => handleClickPage(index + 1)}
>
{index + 1}
</Button>
</li>
))}
</ol>
)}
</PaginationStyle>
);
};
const PaginationStyle = styled.div`
display: flex;
justify-content: start;
align-items: center;
padding: 24px 0;
ol {
list-style: none;
display: flex;
gap: 8px;
padding: 0;
margin: 0;
}
`;
export default Pagination;
Grid vs List
import React, { useEffect } from 'react';
import styled from 'styled-components';
import Button from '../common/Button';
import { FaList, FaTh } from 'react-icons/fa';
import { QUERYSTRING } from '../../constants/querystring.';
import { useSearchParams } from 'react-router-dom';
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;
Pick 타입 유틸리티
특정 타입에서 속성 몇 개를 제외한 나머지 속성으로 새로운 타입을 생성할 때 사용하는 유틸리티 타입
gird 일경우, list 일경우 상태를 저장해, 스타일링에서 분기처리한다.
import React from 'react';
import styled from 'styled-components';
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import { formatNumber } from '../../utils/foramt';
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' ? 'colunm' : '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)};
}
//...
`;
export default BookItem;
☑️ 배운 점
휘몰아치는 강의정보...
타입 유틸리티 Pick
Button 정의할때 onClick 타입 지정해두지 않으니 함수가 실행되지 않는 문제점 발생함... 타입 컴포넌트로 활용하려면 어려움이 있었다..ㅜ
QS 쿼리스트링으로 상태전달 방식 대신하는게 있었다. 그러니까... 카테고리 컴포넌트에서 클릭하면 QS 를 params에 적용하고, 컨텐츠 컴포넌트에서 URL의 QS 인 params 를 코드로 땡겨와서, 가공하는 방식! showMailer 프로젝트에서는 상태관리와 컴포넌트로 모든 카테고리와 검색기능을 구현했는데(모바일고려) 강사님 설명에서는 쿼리파람스로 관리하는게 검색 및 데이터분석, 코드가공의 문제에서 장점이 있다고 하셨다. > 리팩토링할 부분?
백엔드 포트가 자꾸 문제라서 힘들다ㅜ 역시 배포가 답(?)