프로젝트 개요

  1. 프로젝트 주제 : 숙박 예약 웹서비스 구현 프로젝트
  2. 프로젝트 기간 : 11월 20일(월) ~ 12월 01일(목)
  3. 주요 목표 : UI/UX 작업, React.js 기반 컴포넌트 단위 구조 설계, API 명세서에 따른 JSON 데이터 화면 출력, 무한 스크롤

배포 링크

wiki

구현 내용

회원가입

axios API 설정

const instance = axios.create({
	baseURL: SERVER_URL,
	headers: {
		Accept: 'application/json',
	},
});

instance.interceptors.request.use(
	(config) => {
		const accessToken = getCookie('accessToken');
		if (accessToken) {
			config.headers['Authorization'] = `Bearer ${accessToken}`;
		}
		return config;
	},
	(error) => {
		return Promise.reject(error);
	},
);

회원가입 API 연결

import instance from './axios';

export const postJoin = async (
	email: string,
	password: string,
	name: string,
) => {
	const res = await instance.post('members/signup', {
		email: email,
		password: password,
		name: name,
	});
	return res.data;
};

회원가입 유효성 검사

export const validationSignUpSchema = yup.object({
	mail: yup
		.string()
		.matches(/^[^@\s]+@[^\s]+\.[^@\s]+$/, '잘못된 이메일 형식입니다.')
		.required('이메일은 필수 입니다.'),
	name: yup
		.string()
		.matches(
			/^[a-zA-Z가-힣]{2,16}$/,
			'영문자 및 한글로 2자 이상 16자 이하로 입력해주세요.',
		)
		.required('이름은 필수 입니다.'),
	pw: yup
		.string()
		.matches(
			/^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}$/,
			'최소 한 개이상의 숫자, 특수문자, 영문자 및 8자 이상 20자 이하로 입력해주세요.',
		)
		.required('비밀번호는 필수 입니다.'),
	checkPw: yup
		.string()
		.oneOf([yup.ref('pw')], '비밀번호가 일치하지 않습니다.')
		.required('비밀번호는 필수 입니다.'),
});

회원가입 HTTP 메서드 테스트

import supertest from 'supertest';
const request = supertest('https://api.hitit7mini.store');

test('POST /api/members/signup', async () => {
	const userData = {
		email: 'emailaaaaa@hanmail.net',
		password: 'password1232!',
		name: '마이콜',
	};

	const signUp = await request // 회원가입
		.post('/api/members/signup')
		.set('Content-Type', 'application/json')
		.send(userData);

	expect(signUp.status).toEqual(201); // success
	// expect(signUp.status).toEqual(400); // duplicated or error
});

회원가입 페이지

import React from 'react';
// import Header from 'components/signUp/Header';
import Inner from 'components/signUp/Inner';
import CommonHeader from 'components/common/CommonHeader';
const SignUp = () => {
	return (
		<div className="min-h-screen bg-bgGray">
			<CommonHeader name="회원가입" />
			{/* <Header /> */}
			<Inner />
		</div>
	);
};

export default SignUp;
import React, { useState, useRef, useEffect } from 'react';
import { Cancel, Visibility, VisibilityOff, Block } from '@mui/icons-material';
import { Input } from '@material-tailwind/react';
import { useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import { validationSignUpSchema } from 'utils/validateSchema';
import styles from '../../components/login/Login.module.css';
import { postJoin } from '../../apis/signUpAPI';
import { useRecoilValue } from 'recoil';
import { signUpModalState } from 'recoil/atoms/signUpModalAtom';
import SignUpModal from 'components/login/SignUpModal';
import swal from 'sweetalert';

const Inner = () => {
	const navigate = useNavigate();
	const mailRef = useRef<HTMLInputElement>(null);
	const nameRef = useRef<HTMLInputElement>(null);
	const pwRef = useRef<HTMLInputElement>(null);
	const checkPwRef = useRef<HTMLInputElement>(null);
	const [showPw, setShowPw] = useState(false);
	const [showCheckPw, setShowCheckPw] = useState(false);
	const [showName, setShowName] = useState(false);
	const [showPwVis, setShowPwVis] = useState(false);
	const [showCheckPwVis, setShowCheckPwVis] = useState(false);
	const [showMail, setShowMail] = useState(false);
	const [showModal, setShowModal] = useState(true);
	const modalCheck = useRecoilValue(signUpModalState);

	const cancelCloseMailHandler = (event: MouseEvent) => {
		if (mailRef.current && !mailRef.current.contains(event.target as Node)) {
			setShowMail(false);
		}
	};

	const cancelOpenMailHandler = (event: MouseEvent) => {
		if (mailRef.current && mailRef.current.contains(event.target as Node)) {
			setShowMail(true);
		}
	};

	const cancelCloseNameHandler = (event: MouseEvent) => {
		if (nameRef.current && !nameRef.current.contains(event.target as Node)) {
			setShowName(false);
		}
	};

	const cancelOpenNameHandler = (event: MouseEvent) => {
		if (nameRef.current && nameRef.current.contains(event.target as Node)) {
			setShowName(true);
		}
	};

	const visClosePwHandler = (event: MouseEvent) => {
		if (pwRef.current && !pwRef.current.contains(event.target as Node)) {
			setShowPwVis(false);
		}
	};

	const visOpenPwHandler = (event: MouseEvent) => {
		if (pwRef.current && pwRef.current.contains(event.target as Node)) {
			setShowPwVis(true);
		}
	};

	const visCloseCheckPwHandler = (event: MouseEvent) => {
		if (
			checkPwRef.current &&
			!checkPwRef.current.contains(event.target as Node)
		) {
			setShowCheckPwVis(false);
		}
	};

	const visOpenCheckPwHandler = (event: MouseEvent) => {
		if (
			checkPwRef.current &&
			checkPwRef.current.contains(event.target as Node)
		) {
			setShowCheckPwVis(true);
		}
	};

	useEffect(() => {
		window.addEventListener('click', cancelCloseMailHandler);
		window.addEventListener('click', cancelOpenMailHandler);

		return () => {
			window.removeEventListener('click', cancelCloseMailHandler);
			window.removeEventListener('click', cancelOpenMailHandler);
		};
	}, [showMail]);

	useEffect(() => {
		window.addEventListener('click', cancelCloseNameHandler);
		window.addEventListener('click', cancelOpenNameHandler);

		return () => {
			window.removeEventListener('click', cancelCloseNameHandler);
			window.removeEventListener('click', cancelOpenNameHandler);
		};
	}, [showName]);

	useEffect(() => {
		window.addEventListener('click', visClosePwHandler);
		window.addEventListener('click', visOpenPwHandler);
		return () => {
			window.removeEventListener('click', visClosePwHandler);
			window.removeEventListener('click', visOpenPwHandler);
		};
	}, [showPwVis]);

	useEffect(() => {
		window.addEventListener('click', visCloseCheckPwHandler);
		window.addEventListener('click', visOpenCheckPwHandler);
		return () => {
			window.removeEventListener('click', visCloseCheckPwHandler);
			window.removeEventListener('click', visOpenCheckPwHandler);
		};
	}, [showCheckPwVis]);

	const formik = useFormik({
		initialValues: {
			mail: '',
			name: '',
			pw: '',
			checkPw: '',
		},
		validationSchema: validationSignUpSchema,
		onSubmit: async (values) => {
			try {
				swal({ title: '회원가입에 성공했습니다.', icon: 'success' });

				await postJoin(values.mail, values.pw, values.name);
				navigate('/login');
			} catch (e: any) {
				let errorMessage = '';
				if (e.response.data.error.message === '이미 존재하는 이메일') {
					errorMessage = '이미 가입된 이메일입니다.';
				} else {
					for (let i = 0; i < e.response.data.error.data.length; i++) {
						if (e.response.data.error.data[i].field === 'email') {
							errorMessage = '잘못된 이메일 형식입니다.';
						} else if (e.response.data.error.data[i].field === 'name') {
							errorMessage = '잘못된 이름 형식입니다.';
						} else {
							errorMessage = '잘못된 비밀번호 형식입니다.';
						}
					}
				}
				swal({ title: errorMessage, icon: 'warning' });
			}
		},
	});

	const handleOnClose = () => {
		setShowModal(false);
	};

	const { values, touched, errors, handleChange, handleBlur, handleSubmit } =
		formik;

	return (
		<>
			{modalCheck === false && (
				<SignUpModal open={showModal} onClose={handleOnClose}></SignUpModal>
			)}
			<div className="pt-20 min-h-screen m-auto bg-white max-w-[768px] mx-auto">
				<div className="pt-4.5 pl-8 pr-8">
					<form className="m-0 p-0" onSubmit={handleSubmit}>
						<div className="relative">
							<div className="mb-8">
								<div className="pt-11 flex">
									<Input
										type="text"
										variant="standard"
										label="이메일"
										name="mail"
										value={values.mail}
										onChange={handleChange}
										onBlur={handleBlur}
										error={touched.mail && Boolean(errors.mail)}
										ref={mailRef}
										crossOrigin={undefined}
									/>
									{values.mail && showMail && (
										<Cancel
											className="cursor-pointer right-0 absolute"
											onClick={() => {
												formik.setFieldValue('mail', '');
											}}
										></Cancel>
									)}
								</div>
								{values.mail.length > 0 && errors.mail && (
									<div className="text-sm flex text-red items-center">
										<Block className="pr-1" />
										{errors.mail}
									</div>
								)}
								<div className="pt-11 flex">
									<Input
										type="text"
										variant="standard"
										label="이름"
										name="name"
										value={values.name}
										onChange={handleChange}
										onBlur={handleBlur}
										error={touched.name && Boolean(errors.name)}
										ref={nameRef}
										crossOrigin={undefined}
									/>
									{values.name && showName && (
										<Cancel
											className="cursor-pointer right-0 absolute"
											onClick={() => {
												formik.setFieldValue('name', '');
											}}
										></Cancel>
									)}
								</div>
								{values.name.length > 0 && errors.name && (
									<div className="text-sm  flex text-red items-center">
										<Block className="pr-1" />
										{errors.name}
									</div>
								)}
								<div className="pt-11 flex" ref={pwRef}>
									<Input
										type={showPw ? 'text' : 'password'}
										variant="standard"
										label="비밀번호"
										name="pw"
										value={values.pw}
										onChange={handleChange}
										onBlur={handleBlur}
										error={touched.pw && Boolean(errors.pw)}
										crossOrigin={undefined}
									/>
									{values.pw && !showPw && showPwVis && (
										<Visibility
											className="cursor-pointer right-0 absolute"
											onClick={(e) => {
												e.stopPropagation();
												setShowPw(!showPw);
											}}
										></Visibility>
									)}
									{values.pw && showPw && showPwVis && (
										<VisibilityOff
											className="cursor-pointer right-0 absolute"
											onClick={(e) => {
												e.stopPropagation();
												setShowPw(!showPw);
											}}
										></VisibilityOff>
									)}
								</div>
								{values.pw.length > 0 && errors.pw && (
									<div className="text-sm text-red flex items-center">
										<Block className="pr-1" />
										{errors.pw}
									</div>
								)}
								<div className="pt-11 flex" ref={checkPwRef}>
									<Input
										type={showCheckPw ? 'text' : 'password'}
										variant="standard"
										label="비밀번호 확인"
										name="checkPw"
										value={values.checkPw}
										onChange={handleChange}
										onBlur={handleBlur}
										error={touched.checkPw && Boolean(errors.checkPw)}
										crossOrigin={undefined}
									/>

									{values.checkPw && !showCheckPw && showCheckPwVis && (
										<Visibility
											className="cursor-pointer right-0 absolute"
											onClick={(e) => {
												e.stopPropagation();
												setShowCheckPw(!showCheckPw);
											}}
										></Visibility>
									)}
									{values.checkPw && showCheckPw && showCheckPwVis && (
										<VisibilityOff
											className="cursor-pointer right-0 absolute"
											onClick={(e) => {
												e.stopPropagation();
												setShowCheckPw(!showCheckPw);
											}}
										></VisibilityOff>
									)}
								</div>
								{values.checkPw.length > 0 && errors.checkPw && (
									<div className="text-sm text-red flex items-center">
										<Block className="pr-1" />
										{errors.checkPw}
									</div>
								)}
							</div>
						</div>

						{!errors.mail &&
						!errors.pw &&
						!errors.name &&
						!errors.checkPw &&
						values.mail.length > 0 &&
						values.pw.length > 0 &&
						values.name.length > 0 &&
						values.checkPw.length > 0 ? (
							<button
								type="submit"
								style={{
									width: '100%',
									boxShadow: 'none',
									fontFamily: 'AppleSDGothicNeoL',
									color: 'white',
									fontWeight: 700,
									fontSize: '18px',
									border: '1px solid #de2e5f',
									borderRadius: '5px',
								}}
								className={styles.btnActive}
							>
								완료
							</button>
						) : (
							<div
								style={{
									width: '100%',
									boxShadow: 'none',
									fontFamily: 'AppleSDGothicNeoL',
									color: 'white',
									fontWeight: 700,
									fontSize: '18px',
									border: '1px solid #ccc',
									borderRadius: '5px',
								}}
								className="mt-4 pt-3 pb-3 flex items-center justify-center bg-gray cursor-default"
							>
								완료
							</div>
						)}
					</form>
				</div>
			</div>
		</>
	);
};

export default Inner;

로그인

로그인 API 연결

import instance from './axios';
export const postLogin = async (email: string, password: string) => {
	const res = await instance.post('members/login', {
		email: email,
		password: password,
	});
	return res.data.data;
};

로그인 유효성 검사

export const validationLoginSchema = yup.object({
	mail: yup
		.string()
		.matches(/^[^@\s]+@[^\s]+\.[^@\s]+$/, '잘못된 이메일 형식입니다.')
		.required('이메일은 필수 입니다.'),

	pw: yup
		.string()
		.matches(
			/^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}$/,
			'최소 한 개이상의 숫자, 특수문자, 영문자 및 8자 이상 20자 이하로 입력해주세요.',
		)
		.required('비밀번호는 필수 입니다.'),
});

로그인 및 마이페이지 HTTP 메서드 테스트

import supertest from 'supertest';

const request = supertest('https://api.hitit7mini.store');
test('POST /api/members/login', async () => {
	const userData = {
		email: 'emailaaaaa@hanmail.net',
		password: 'password1232!',
		name: '마이콜',
	};

	const login = await request // 로그인
		.post('/api/members/login')
		.set('Content-Type', 'application/json')
		.send(userData);
	expect(login.status).toEqual(200); // success
	// expect(response.status).toEqual(400); // duplicated or error
	// console.log(login.body);
	const accessToken = login.body.data.accessToken; // 여기서 나온 토큰으로 밑에서 Authorization으로 토큰 값 넣는 요청 처리하시면 됩니다.

	const getUserInfo = await request // 회원 정보 조회
		.get('/api/members/mypage')
		.set('Content-Type', 'application/json')
		.set('Authorization', `Bearer ${accessToken}`);
	expect(getUserInfo.status).toEqual(200); // success

	const getUserOrderInfo = await request // 회원 주문 내역 조회
		.get('/api/members/orders')
		.set('Content-Type', 'application/json')
		.set('Authorization', `Bearer ${accessToken}`);
	expect(getUserOrderInfo.status).toEqual(200); // success
});

로그인 페이지

import React from 'react';
// import Header from 'components/login/Header';
import Inner from 'components/login/Inner';
import { alreadyLogin } from 'hooks/common/isAcessToken';
import CommonHeader from 'components/common/CommonHeader';
const Login = () => {
	alreadyLogin();
	return (
		<div className="min-h-screen bg-bgGray">
			<CommonHeader name="이메일로 로그인" />
			{/* <Header /> */}
			<Inner />
		</div>
	);
};

export default Login;
import React, { useState, useRef, useEffect } from 'react';
import {
	Cancel,
	Visibility,
	VisibilityOff,
	ChevronRight,
	Block,
} from '@mui/icons-material';
import { Input } from '@material-tailwind/react';
import { useNavigate } from 'react-router-dom';
import SignUpModal from './SignUpModal';
import styles from '../../components/login/Login.module.css';
import { validationLoginSchema } from 'utils/validateSchema';
import { useFormik } from 'formik';
import { useRecoilState } from 'recoil';
import { signUpModalState } from 'recoil/atoms/signUpModalAtom';
import { postLogin } from '../../apis/loginAPI';
import swal from 'sweetalert';
import { removeCookie, setCookie } from 'utils';
import { dateState } from 'recoil/atoms/myPageAtom';

const Inner = () => {
	const navigate = useNavigate();
	const mailRef = useRef<HTMLInputElement>(null);
	const pwRef = useRef<HTMLInputElement>(null);
	const [showPwVis, setShowPwVis] = useState(false);
	const [showPw, setShowPw] = useState(false);
	const [showMail, setShowMail] = useState(false);
	const [showModal, setShowModal] = useState(false);
	const [modalCheck, setModalCheck] = useRecoilState(signUpModalState);
	const [date, setDate] = useRecoilState(dateState);
	useEffect(() => {
		setModalCheck(false);
	}, []);
	const cancelCloseHandler = (event: MouseEvent) => {
		if (mailRef.current && !mailRef.current.contains(event.target as Node)) {
			setShowMail(false);
		}
	};

	const cancelOpenHandler = (event: MouseEvent) => {
		if (mailRef.current && mailRef.current.contains(event.target as Node)) {
			setShowMail(true);
		}
	};

	const visCloseHandler = (event: MouseEvent) => {
		if (pwRef.current && !pwRef.current.contains(event.target as Node)) {
			setShowPwVis(false);
		}
	};

	const visOpenHandler = (event: MouseEvent) => {
		if (pwRef.current && pwRef.current.contains(event.target as Node)) {
			setShowPwVis(true);
		}
	};

	const formik = useFormik({
		initialValues: {
			mail: '',
			pw: '',
		},
		validationSchema: validationLoginSchema,
		onSubmit: async (values) => {
			removeCookie();
			try {
				swal({ title: '로그인에 성공했습니다.', icon: 'success' });

				const res = await postLogin(values.mail, values.pw);
				setDate('최근 3개월');
				const { accessToken } = res;
				setCookie(accessToken);
				navigate('/');
			} catch (e: any) {
				let errorMessage = '';
				if (e.message === 'Request failed with status code 401') {
					errorMessage = '이메일과 비밀번호를 확인해주세요.';
				} else {
					for (let i = 0; i < e.response.data.error.data.length; i++) {
						if (e.response.data.error.data[i].field === 'email') {
							errorMessage = '잘못된 이메일 형식입니다.';
						} else if (e.response.data.error.data[i].field === 'password') {
							errorMessage = '잘못된 비밀번호 형식입니다.';
						}
					}
				}

				swal({ title: errorMessage, icon: 'warning' });
			}
		},
	});

	const { values, touched, errors, handleChange, handleBlur, handleSubmit } =
		formik;

	useEffect(() => {
		window.addEventListener('click', cancelCloseHandler);
		window.addEventListener('click', cancelOpenHandler);

		return () => {
			window.removeEventListener('click', cancelCloseHandler);
			window.removeEventListener('click', cancelOpenHandler);
		};
	}, [showMail]);

	useEffect(() => {
		window.addEventListener('click', visCloseHandler);
		window.addEventListener('click', visOpenHandler);
		return () => {
			window.removeEventListener('click', visCloseHandler);
			window.removeEventListener('click', visOpenHandler);
		};
	}, [showPwVis]);

	const handleOnClose = () => {
		setShowModal(false);
	};

	return (
		<div className="pt-20 min-h-screen m-auto bg-white max-w-[768px] mx-auto">
			<div className="pt-4.5 pl-8 pr-8">
				<form className="m-0 p-0" onSubmit={handleSubmit}>
					<div className="relative">
						<div className="mb-8">
							<div className="pt-11 flex">
								<Input
									type="text"
									variant="standard"
									label="이메일"
									name="mail"
									value={values.mail}
									onChange={handleChange}
									onBlur={handleBlur}
									error={touched.mail && Boolean(errors.mail)}
									ref={mailRef}
									crossOrigin={undefined}
								/>
								{values.mail && showMail && (
									<Cancel
										className="cursor-pointer right-0 absolute"
										onClick={() => {
											formik.setFieldValue('mail', '');
										}}
									></Cancel>
								)}
							</div>

							<div className="pt-11 flex" ref={pwRef}>
								<Input
									type={showPw ? 'text' : 'password'}
									variant="standard"
									label="비밀번호"
									name="pw"
									value={values.pw}
									onChange={handleChange}
									onBlur={handleBlur}
									error={touched.pw && Boolean(errors.pw)}
									crossOrigin={undefined}
								/>
								{values.pw && !showPw && showPwVis && (
									<Visibility
										className="cursor-pointer right-0 absolute"
										onClick={(e) => {
											e.stopPropagation();
											setShowPw(!showPw);
										}}
									></Visibility>
								)}
								{values.pw && showPw && showPwVis && (
									<VisibilityOff
										className="cursor-pointer right-0 absolute"
										onClick={(e) => {
											e.stopPropagation();
											setShowPw(!showPw);
										}}
									></VisibilityOff>
								)}
							</div>
						</div>
					</div>
					{!errors.mail &&
					!errors.pw &&
					values.mail.length > 0 &&
					values.pw.length > 0 ? (
						<button
							type="submit"
							style={{
								width: '100%',
								boxShadow: 'none',
								fontFamily: 'AppleSDGothicNeoL',
								color: 'white',
								fontWeight: 700,
								fontSize: '18px',
								border: '1px solid #de2e5f',
								borderRadius: '5px',
							}}
							className={styles.btnActive}
						>
							로그인
						</button>
					) : (
						<div
							style={{
								width: '100%',
								boxShadow: 'none',
								fontFamily: 'AppleSDGothicNeoL',
								color: 'white',
								fontWeight: 700,
								fontSize: '18px',
								border: '1px solid #ccc',
								borderRadius: '5px',
							}}
							className="mt-4 pt-3 pb-3 flex items-center justify-center bg-gray cursor-default"
						>
							로그인
						</div>
					)}
				</form>
				<div
					className="flex justify-center items-center "
					onClick={() => {
						setShowMail(!showModal);
					}}
				>
					<span
						className="text-content cursor-pointer flex justify-center items-center mt-6 w-auto hover:bg-bgGray"
						onClick={() => setShowModal(!showModal)}
					>
						이메일로 회원가입
						<ChevronRight className="flex cursor-pointer m-2 justify-center items-center ml-1"></ChevronRight>
					</span>
				</div>

				{modalCheck === false && (
					<SignUpModal open={showModal} onClose={handleOnClose}></SignUpModal>
				)}
			</div>
		</div>
	);
};

export default Inner;

마이페이지

마이페이지 API 연결

import instance from './axios';
import { myOrder } from 'types/myPage.type';

export const getUserInfo = async () => {
	const res = await instance.get('members/mypage');
	return res.data.data;
};

export const getUserOrderInfo = async () => {
	const res = await instance.get<myOrder>('members/orders');
	return res.data;
};

마이페이지 React query

import { getUserOrderInfo } from '../../apis/myPageAPI';
import { useQuery } from 'react-query';

const getMyPageData = async (i: number) => {
	try {
		if (i == 1) {
			i = 12;
		} else if (i == 2) {
			i = 24;
		}
		const currentDate = new Date();
		currentDate.setMonth(currentDate.getMonth() - i);
		const { data } = await getUserOrderInfo();
		const filteredData = data?.filter(
			(order: { orderItems: any[]; orderDate: string | number | Date }) => {
				return order.orderItems?.some(() => {
					const criteria = new Date(order.orderDate);
					return criteria >= currentDate;
				});
			},
		);
		return filteredData;
	} catch (error) {
		console.log(error);
	}
};

const useQueryMyPage = (i: number) => {
	const { data, isLoading } = useQuery(
		[`myPage/${i}`],
		() => getMyPageData(i),
		{
			staleTime: 60000,
		},
	);
	return { data, isLoading };
};

export default useQueryMyPage;

마이페이지 페이지

import React from 'react';
// import Header from 'components/myPage/Header';
import Inner from 'components/myPage/Inner';
import { requireLogin } from 'hooks/common/isAcessToken';
import CommonHeader from 'components/common/CommonHeader';
const MyPage = () => {
	requireLogin();
	return (
		<div className="min-h-screen bg-bgGray">
			<CommonHeader name="My 야놀자" isCartIcon />
			{/* <Header /> */}
			<Inner />
		</div>
	);
};

export default MyPage;
import React, { useEffect, useState } from 'react';
import bannerRed from '../../assets/images/bannerRed.png';
import { ArrowDropDown } from '@mui/icons-material';
import DateModal from './DateModal';
import { useRecoilState, useRecoilValue } from 'recoil';
import { dateState } from 'recoil/atoms/myPageAtom';
import ReservationCard from './ReservationCard';
import ReservationCardHeader from './ReservationCardHeader';
import { logout } from 'utils';
import { useNavigate } from 'react-router-dom';
import { getUserInfo } from '../../apis/myPageAPI';

import TopBtn from 'components/common/TopBtn';
import useScrollToShow from 'hooks/common/handleScroll';
import useQueryMyPage from 'hooks/myPage/useQueryMyPage';
import MyPageSkeleton from './skeleton/MyPageSkeleton';

const Inner = () => {
	const [now, setNow] = useState(3);
	const navigate = useNavigate();
	const [showDateModal, setShowDateModal] = useState(false);
	const [name, setName] = useState('');
	const [email, setEmail] = useState('');
	const nowDate = useRecoilValue(dateState);
	const [date, setDate] = useRecoilState(dateState);
	const show = useScrollToShow(false, 200);

	useEffect(() => {
		setNow(date !== null ? parseInt(date.replace(/\D/g, ''), 10) : 0);
	}, [date, setDate]);

	const handleDateModalClose = () => {
		setShowDateModal(false);
	};

	useEffect(() => {
		const fetchData = async () => {
			try {
				const res = await getUserInfo();
				const { email, name } = res;
				setName(name);
				setEmail(email);
			} catch (e) {
				console.log(e);
			}
		};
		fetchData();
	}, []);

	const { data, isLoading } = useQueryMyPage(now);
	return (
		<div className="pt-20 min-h-screen m-auto bg-white max-w-[768px] mx-auto">
			<div className="lg:pt-4.5 lg:pl-6 lg:pr-6 pb-7">
				<div className="flex justify-between items-center">
					<div className="text-content font-bold text-black cursor-default">
						{name}
					</div>

					<div
						className="text-xxsm text-textGray cursor-pointer"
						onClick={() => {
							logout();
							setDate('최근 3개월');
							navigate('/');
						}}
					>
						로그아웃
					</div>
				</div>
				<div className="text-md text-textGray pt-3 cursor-default">{email}</div>
				<div className="mt-[48px]">
					<img
						src={bannerRed}
						alt="bannerRed"
						className="w-full cursor-pointer"
						style={{ borderRadius: 10 }}
						onClick={() => {
							window.location.href =
								'https://www.yanolja.com/promotion/nol-promotion?eventcode=NOYLN';
						}}
					/>
					<div className="flex items-center justify-between">
						<div className="font-bold text-title pt-10 cursor-default">
							예약 내역
						</div>

						<div className="pt-10 ">
							<div
								className="flex items-center w-full h-9 border border-gray rounded-md cursor-pointer justify-between hover:bg-lightGray pl-4"
								onClick={() => setShowDateModal(true)}
							>
								{nowDate}
								<ArrowDropDown />
							</div>
						</div>
					</div>
				</div>
			</div>

			<DateModal
				open={showDateModal}
				onClose={handleDateModalClose}
			></DateModal>
			{show && <TopBtn show={show} />}
			{isLoading && <MyPageSkeleton />}
			{data && data?.length > 0 && (
				<div className="pr-6 pl-6">
					{data?.map(
						(
							order: { orderDate: string; orderItems: any[] },
							index: React.Key | null | undefined,
						) => (
							<div
								key={index}
								className="border border-gray rounded-lg mb-8 shadow-md overflow-hidden"
							>
								<ReservationCardHeader orderDate={order.orderDate} />
								{order.orderItems?.map((item, itemIndex) => (
									<React.Fragment key={itemIndex}>
										<ReservationCard
											code={item.code}
											accomodationName={item.accommodation.name}
											accomodationImage={item.accommodation.image}
											checkinDate={item.checkinDate}
											checkoutDate={item.checkoutDate}
											isUsed={item.isUsed}
											accomodationId={item.accommodation.id}
											capacity={item.roomType.capacity}
											roomName={item.roomType.name}
										/>
									</React.Fragment>
								))}
							</div>
						),
					)}
				</div>
			)}
			{data?.length === 0 && (
				<>
					<div
						style={{
							display: 'flex',
							flexDirection: 'column',
							alignItems: 'center',
							justifyContent: 'center',
							height: '100vh',
						}}
					>
						<div className="m-0 auto text-title pb-1">
							조회조건에 맞는 내역이 없습니다.
						</div>
						<div className="text-content text-textGray pb-2">
							상품을 예약해보세요
						</div>
						<div
							className="p-3 border border-blue rounded-md cursor-pointer text-blue text-content pl-10 pr-10"
							onClick={() => {
								navigate('/');
							}}
						>
							홈으로 가기
						</div>
					</div>
				</>
			)}
		</div>
	);
};

export default Inner;

트러블 슈팅

오류

로그인 및 회원 가입 중복 요청 버그

해결

로그인 및 회원 가입 버튼을 여러 번 누르면 클릭 수 만큼 요청이 되는 것을 방지하기 위해 클릭 후 swal창으로 막으려고 하였습니다.
하지만 swal이 팝업되기 전까지의 시간이 있어 여러 번 요청되는 것을 식별하였습니다.
그래서 요청을 하기 전에 swal창을 팝업하도록 수정한 결과 버튼을 누르자마자 swal창이 떠서 중복 요청을 방지할 수 있었습니다.

프로젝트 보완

로그인, 회원가입 에러 핸들링 로직 수정

let errorMessage = '';
				if (e.message === 'Request failed with status code 401') {
					errorMessage = '이메일과 비밀번호를 확인해주세요.';
				} else {
					for (let i = 0; i < e.response.data.error.data.length; i++) {
						if (e.response.data.error.data[i].field === 'email') {
							errorMessage = '잘못된 이메일 형식입니다.';
						} else if (e.response.data.error.data[i].field === 'password') {
							errorMessage = '잘못된 비밀번호 형식입니다.';
						}
					}
				}
let errorMessage = '';
				if (e.response.data.error.message === '이미 존재하는 이메일') {
					errorMessage = '이미 가입된 이메일입니다.';
				} else {
					for (let i = 0; i < e.response.data.error.data.length; i++) {
						if (e.response.data.error.data[i].field === 'email') {
							errorMessage = '잘못된 이메일 형식입니다.';
						} else if (e.response.data.error.data[i].field === 'name') {
							errorMessage = '잘못된 이름 형식입니다.';
						} else {
							errorMessage = '잘못된 비밀번호 형식입니다.';
						}
					}
				}

axios 인스턴스 분리

import instance from './axios';
export const postLogin = async (email: string, password: string) => {
	const res = await instance.post('members/login', {
		email: email,
		password: password,
	});
	return res.data.data;
};
import instance from './axios';

export const postJoin = async (
	email: string,
	password: string,
	name: string,
) => {
	const res = await instance.post('members/signup', {
		email: email,
		password: password,
		name: name,
	});
	return res.data;
};
import instance from './axios';
import { myOrder } from 'types/myPage.type';

export const getUserInfo = async () => {
	const res = await instance.get('members/mypage');
	return res.data.data;
};

export const getUserOrderInfo = async () => {
	const res = await instance.get<myOrder>('members/orders');
	return res.data;
};

마이페이지 인터페이스 분리

export interface reservationCardProps {
	code: string;
	accomodationName: string;
	accomodationImage: string;
	checkinDate: string;
	checkoutDate: string;
	isUsed: boolean;
	accomodationId: number;
	capacity: number;
	roomName: string;
}

회고

오랜만에 백엔드와 협업을 하게 되어 재밌고 유익하였습니다.
저희 팀 모두가 능력이 있으신 분들 같아 다양한 기능들을 기한보다 빠르게 마무리 할 수 있었던 것 같습니다.
프로젝트 매니지먼트부터 유닛 테스팅 까지 알차게 프로젝트를 진행한 것 같아 뿌듯했습니다.
오랜만에 Jest를 사용해서 버벅였는데 다음 프로젝트에서는 개발 중간 중간에 테스팅을 진행해야 겠다고 생각하였습니다.
모두들 고생 많으셨습니다 !!!

profile
Frontend developer

0개의 댓글