day 21 예약 필수 항목 결제 처리(예약 데이터 넘기기) + 오류 해결 + lang-common.js에서 공통 텍스트 관리
달력 연도 버튼 없애기
연도로 가는 버튼 숨기고, 월 버튼 조절하기
완성!
예약 필수 항목 검증 및 결제 처리(예약 데이터 넘기기)
http-proxy-middleware 의존성 설치
yarn add http-proxy-middleware
public/index.html
<script src="https://stdpay.inicis.com/stdjs/INIStdPay.js"></script>
INIpay 결제 시스템을 사용하려면 위 스크립트를 index.html 파일의 <head> 태그에 추가해야 된다.
src/App.js
src/payment/pages/CloseUrl.js 추가
src/reservation/apis/apiApply.js
src/reservation/components/ReservationForm.js
import React, { useContext } from 'react';
import styled from 'styled-components';
import { useTranslation } from 'react-i18next';
import UserInfoContext from '../../member/modules/UserInfoContext';
import InfoInputBox from './InfoInputBox';
import {
IoIosTime,
IoMdCheckmarkCircleOutline,
IoMdNotificationsOff,
} from 'react-icons/io';
import { GoPersonFill } from 'react-icons/go';
import { FaAddressBook } from 'react-icons/fa';
import { BigButton } from '../../commons/components/Buttons';
import CalendarForm from './CalendarForm';
import InputBox from '../../commons/components/InputBox';
import MessageBox from '../../commons/components/MessageBox';
const FormBox = styled.form`
display: flex;
flex-direction: column;
`;
const TimeTableAndPerson = styled.div`
margin-left: 20px;
flex-grow: 1;
`;
const TitleWithIcon = styled.h2`
display: flex;
align-items: center;
margin-bottom: 15px; /* 아이콘+글씨 줄 아래 마진 */
margin-top: 30px; /* 아이콘+글씨 줄 위 마진 */
svg {
margin-right: 7px; /* 아이콘과 글씨 사이 간격 */
font-size: 1.1em; /* 아이콘 크기 */
}
h2 {
margin: 0; /* Remove default margin from h2 */
font-size: 0.8em; /* h2 글씨 크기(...선택해 주세요) */
}
`;
const Subtitle = styled.h3`
margin: 5px 0 15px 5px;
font-size: 0.9em;
color: #666;
`;
const Checktitle = styled.h3`
margin: 10px 0 20px 7px;
font-size: 1.2em;
`;
const LastCheckTitle = styled(Checktitle)`
margin: 40px 0 30px 7px;
`;
const TimeButton = styled.button`
background: ${({ isSelected }) => (isSelected ? '#ff3d00' : '#ffffff')};
color: ${({ isSelected }) => (isSelected ? '#ffffff' : '#ff3d00')};
border: 1px solid #ff3d00;
border-radius: 5px;
width: 130px;
padding: 10px 35px; /* 시간 버튼 가로, 세로 크기 */
margin: 5px 5px 20px 20px; //상/우/하/좌
font-size: 1.2em; // 시간 버튼 글자 크기
cursor: pointer;
transition: background 0.3s, color 0.3s;
&:hover {
background: #ff3d00;
color: #ffffff;
}
`;
const PersonButton = styled.button`
background: ${({ isSelected }) => (isSelected ? '#ff3d00' : '#ffffff')};
color: ${({ isSelected }) => (isSelected ? '#ffffff' : '#ff3d00')};
border: 1px solid #ff3d00;
border-radius: 50%;
width: 57px; // 인원 버튼 가로 크기
height: 57px; // 인원 버튼 세로 크기
display: flex;
align-items: center;
justify-content: center;
margin: 5px;
font-size: 1.2em;
cursor: pointer;
transition: background 0.3s, color 0.3s;
margin-bottom: 30px;
&:hover {
background: #ff3d00;
color: #ffffff;
}
`;
const PersonButtonsContainer = styled.div`
display: flex;
flex-wrap: wrap; /* Allows wrapping if needed */
gap: 10px; /* Space between buttons */
`;
const ReservationInfoBox = styled.dt`
font-size: 1.2em;
`;
const ReservationForm = ({
data,
form,
times,
onCalendarClick,
onTimeClick,
onChange,
onSubmit,
errors,
}) => {
const { availableDates } = data;
const startDate = availableDates[0];
const endDate = availableDates[availableDates.length - 1];
const {
states: { userInfo },
} = useContext(UserInfoContext);
const { t } = useTranslation();
const personOptions = [...new Array(10).keys()].map((i) => i + 1);
return (
<FormBox onSubmit={onSubmit} autoComplete="off">
<CalendarForm
startDate={startDate}
endDate={endDate}
availableDates={availableDates}
onCalendarClick={onCalendarClick}
/>
<TimeTableAndPerson>
{times?.length > 0 && (
<>
<TitleWithIcon>
<IoIosTime />
<h2>{t('시간선택')}</h2>
</TitleWithIcon>
<div className="time-buttons">
{times.map((time) => (
<TimeButton
key={time}
isSelected={form.rTime === time}
onClick={() => onTimeClick(time)}
>
{time}
</TimeButton>
))}
</div>
{errors?.rTime && (
<MessageBox color="danger" messages={errors.rTime} />
)}
<dl className="persons">
<TitleWithIcon>
<GoPersonFill />
<h2>{t('인원선택')}</h2>
</TitleWithIcon>
<Subtitle>{t('최대최소인원명수')}</Subtitle>
<PersonButtonsContainer>
{personOptions.map((person) => (
<PersonButton
key={person}
isSelected={form.persons === person}
onClick={() =>
onChange({ target: { name: 'persons', value: person } })
}
>
{person}명
</PersonButton>
))}
</PersonButtonsContainer>
{errors?.persons && (
<MessageBox color="danger" messages={errors.persons} />
)}
</dl>
<div>
<TitleWithIcon>
<FaAddressBook />
<h2>{t('예약자_정보')}</h2>
</TitleWithIcon>
<ReservationInfoBox>
<dl>
<dt>{t('예약자')}</dt>
<dd>
<InfoInputBox
type="text"
name="name"
value={form?.name}
onChange={onChange}
/>
{errors?.name && (
<MessageBox color="danger" messages={errors.name} />
)}
</dd>
</dl>
<dl>
<dt>{t('이메일')}</dt>
<dd>
<InfoInputBox
type="text"
name="email"
value={form?.email}
onChange={onChange}
/>
{errors?.email && (
<MessageBox color="danger" messages={errors.email} />
)}
</dd>
</dl>
<dl>
<dt>{t('휴대전화번호')}</dt>
<dd>
<InfoInputBox
type="text"
name="mobile"
value={form?.mobile}
onChange={onChange}
/>
{errors?.mobile && (
<MessageBox color="danger" messages={errors.mobile} />
)}
</dd>
</dl>
</ReservationInfoBox>
<TitleWithIcon>
<IoMdCheckmarkCircleOutline />
<h2>{t('예약확인문구')}</h2>
</TitleWithIcon>
{[
'* 노쇼 방지를 위해 예약금과 함께 예약 신청을 받고 있습니다.',
'* 예약금은 식사 금액에서 차감합니다.',
'* 예약시간 15분 이상 늦을 시 자동 취소됩니다.(예약금 환불 X)',
'* 1인 1메뉴 주문 부탁드립니다.',
'* 외부 음식, 음료 반입 및 취식이 불가합니다.',
'* 인원 변경 시 방문 3시간 전까지 예약 수정 가능합니다.',
].map((item, index) => (
<Checktitle key={index}>{t(item)}</Checktitle>
))}
<LastCheckTitle>{t('예약자당부문구')}</LastCheckTitle>
</div>
<BigButton type="submit" color="jmt">
{t('예약하기')}
</BigButton>
</>
)}
</TimeTableAndPerson>
</FormBox>
);
};
export default React.memo(ReservationForm);
시간, 명, 예약자 정보를 선택하거나 작성하지 않았을 때 빨간색으로 경고 문구가 뜨게 설정하기
검증하는 부분, 입력하는 부분 만들었다.
date-fns 의존성 설치
yarn add date-fns
src/reservation/containers/ReservationContainer.js
import React, { useEffect, useState, useCallback, useContext } from 'react';
import { useParams } from 'react-router-dom';
import ReservationForm from '../components/ReservationForm';
import { apiGet } from '../../restaurant/apis/apiInfo';
import Loading from '../../commons/components/Loading';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import apiApply from '../apis/apiApply';
import UserInfoContext from '../../member/modules/UserInfoContext';
import ReservationPayContainer from './ReservationPayContainer';
const ReservationContainer = ({ setPageTitle }) => {
const {
states: { userInfo },
} = useContext(UserInfoContext);
const { rstrId } = useParams();
const [data, setData] = useState(null);
const [form, setForm] = useState({
rstrId,
name: userInfo?.userName,
email: userInfo?.email,
mobile: userInfo?.mobile,
persons: 1,
});
const [times, setTimes] = useState([]);
const [errors, setErrors] = useState({});
const [payConfig, setPayConfig] = useState(null);
const { t } = useTranslation();
useEffect(() => {
(async () => {
try {
const res = await apiGet(rstrId);
res.availableDates = res.availableDates.map((d) => new Date(d));
setData(res);
setPageTitle(`${res.rstrNm} ${t('예약하기')}`);
} catch (err) {
console.error(err);
}
})();
}, [rstrId, setPageTitle, t]);
const onCalendarClick = useCallback(
(selected) => {
const yoil = selected.getDay(); // 0(일) ~ 6(토)
const { availableTimes } = data;
for (const [k, times] of Object.entries(availableTimes)) {
if (
k === '매일' ||
(k === '평일' && yoil > 0 && yoil < 6) ||
(k === '토요일' && yoil === 6) ||
(k === '일요일' && yoil === 7) ||
(k === '주말' && (yoil === 6 || yoil === 0))
) {
const dateStr = format(selected, 'yyyy-MM-dd');
setForm((form) => ({ ...form, rDate: dateStr }));
setTimes(times);
break;
}
}
},
[data],
);
const onTimeClick = useCallback((rTime) => {
setForm((form) => ({ ...form, rTime }));
}, []);
const onChange = useCallback((e) => {
setForm((form) => ({ ...form, [e.target.name]: e.target.value }));
}, []);
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const _errors = {};
let hasErrors = false;
// 필수 항목 검증 S
const requiredFields = {
rDate: t('예약날짜를_선택하세요.'),
rTime: t('예약시간을_선택하세요.'),
name: t('예약자명을_입력하세요.'),
email: t('이메일을_입력하세요.'),
mobile: t('휴대전화번호를_입력하세요.'),
};
for (const [field, message] of Object.entries(requiredFields)) {
if (!form[field]?.trim()) {
_errors[field] = _errors[field] ?? [];
_errors[field].push(message);
hasErrors = true;
}
}
// 필수 항목 검증 E
setErrors(_errors);
if (hasErrors) {
return;
}
(async () => {
try {
const res = await apiApply(form);
setPayConfig(res);
console.log(res);
} catch (err) {
console.error(err);
setErrors(err.message);
}
})();
},
[t, form],
);
if (!data) {
return <Loading />;
}
if (payConfig) {
// 결제 설정이 있는 경우 결제 진행
return (
<ReservationPayContainer
payConfig={payConfig}
form={form}
data={data}
setPageTitle={setPageTitle}
/>
);
}
return (
<ReservationForm
data={data}
form={form}
times={times}
errors={errors}
onCalendarClick={onCalendarClick}
onTimeClick={onTimeClick}
onChange={onChange}
onSubmit={onSubmit}
/>
);
};
export default React.memo(ReservationContainer);
예약자 정보는 로그인 정보와 연동시켜 놓았다.
ReservationPayContainer.js는 얘가 접수하고 나서 값을 가져오는 payConfig가 있다.
payConfig는 얘가 submit 했을 때 접수하게 되면 그때 요청을 보내고 반환값으로 받는 게 바로 payConfig(setPayConfig(res);) 즉, 결제 설정에 관련된 내용이고 이 설정을 바탕으로 이니시스 쪽에 결제 요청을 보내게 된다. 이 부분들을 구현했다.
payConfig 값이 있으면 결제를 진행하는 거니까 <ReservationPayContainer> 로 지정함 여기에 이니시스 결제 스크립트도 이쪽으로 구현되어 있다. 여기서 결제가 진행되고 결제가 완료되면 결제 완료 페이지(예약 완료 페이지)로 넘어간다.
얘는 양식이다!
날짜 형식 관련된 부분인데 date-fns 의존성 추가함!
서버에 전달할 때 양식에 맞춰서 전달해야 하기 때문에 이 부분 수정했다.
src/reservation/containers/ReservationPayContainer.js
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MidButton } from '../../commons/components/Buttons';
const returnUrl = `${window.location.origin}/payment/process`;
const closeUrl = `${window.location.origin}/payment/close`;
const ReservationPayContainer = ({ payConfig, form, data, setPageTitle }) => {
const { t } = useTranslation();
useEffect(() => {
setPageTitle(data.rstrNm + ' ' + t('예약결제하기'));
}, [t, data, setPageTitle]);
const onPayProcess = useCallback(() => {
window.INIStdPay.pay('inicisForm');
}, []);
return (
<>
<MidButton type="button" onClick={onPayProcess}>
{t('결제하기')}
</MidButton>
<form id="inicisForm" method="POST">
<input type="hidden" name="version" value="1.0" />
<input type="hidden" name="gopaymethod" />
<input type="hidden" name="mid" value={payConfig.mid} />
<input type="hidden" name="oid" value={payConfig.oid} />
<input type="hidden" name="price" value={payConfig.price} />
<input type="hidden" name="timestamp" value={payConfig.timestamp} />
<input type="hidden" name="use_chkfake" value="Y" />
<input type="hidden" name="signature" value={payConfig.signature} />
<input
type="hidden"
name="verification"
value={payConfig.verification}
/>
<input type="hidden" name="mKey" value={payConfig.mkey} />
<input type="hidden" name="currency" value="WON" />
<input
type="hidden"
name="goodname"
value={`${data.rstrNm}/${form.person}인 예약`}
/>
<input type="hidden" name="buyername" value={form.name} />
<input type="hidden" name="buyertel" value={form.mobile} />
<input type="hidden" name="buyeremail" value={form.email} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<input type="hidden" name="closeUrl" value={closeUrl} />
<input type="hidden" name="acceptmethod" value="below1000" />
</form>
</>
);
};
export default React.memo(ReservationPayContainer);
결제 정보를 처리하고, INIpay를 사용하여 결제를 진행
결제 폼 생성
‘form’ 요소에 여러 ‘input’ 필드를 숨겨진 값(’type=”hidden”)으로 포함하여 INIpay가 결제를 처리하는 데 필요한 데이터를 제공한다.
결제 프로세스를 처리하기 위해 폼의 ‘id’를 ‘inicisForm’으로 설정하고, 이를 ‘onPayProcess’ 함수에서 참조한다.
src/routes/Payment.js
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import loadable from '@loadable/component';
const CloseUrl = loadable(() => import('../payment/pages/CloseUrl'));
const Payment = () => {
return (
<Routes>
<Route path="/payment">
<Route path="close" element={<CloseUrl />} />
</Route>
</Routes>
);
};
export default React.memo(Payment);
App.js에 결제 페이지 연결해야하기 때문에 routes에 넣어주기!
src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function (app) {
app.use(
'/payment/process',
createProxyMiddleware({
target: 'http://localhost:3001/payment/process',
changeOrigin: true,
}),
);
};
http-proxy-middleware를 사용해 프록시 서버를 설정한다. 주로 개발 환경에서 API 요청을 프록시하는 데 사용된다. 이 설정을 통해 /payment/process 경로로 들어오는 요청이 프록시를 통해 다른 서버로 전달된다.
const { createProxyMiddleware } = require('http-proxy-middleware');
-> createProxyMiddleware 가져오기
http-proxy-middleware 라이브러리에서 createProxyMiddleware 함수를 가져온다. 이 함수는 프록시 미들웨어를 생성하는 데 사용된다.
module.exports = function (app) {
-> 모듈 내보내기
이 코드는 Express.js 또는 비슷한 서버 애플리케이션에서 사용될 때, 프록시 설정을 애플리케이션에 추가하기 위해 module.exports로 내보내고 있다. app 객체는 Express 앱 인스턴스를 나타낸다.
app.use(
'/payment/process',
createProxyMiddleware({
target: 'http://localhost:3001/payment/process',
changeOrigin: true,
}),
);
-> 프록시 미들웨어 설정
app.use를 사용해 특정 경로에 대해 미들웨어를 추가한다. 이 경우 /payment/process 경로로 들어오는 모든 요청에 대해 프록시를 적용한다.
createProxyMiddleware 함수는 프록시 미들웨어를 생성하며, 이 프록시 미들웨어는 지정된 대상 서버로 요청을 전달한다.
target : 프록시할 대상 서버의 URL을 지정한다. 여기서는 http://localhost:3001/payment/process로 설정되어 있어, /payment/process로 들어오는 요청이 http://localhost:3001/payment/process로 전달된다.
changeOrigin : 요청의 출처(origin)를 프록시 서버의 출처로 변경할지 여부를 결정한다. true로 설정되면, 원본 서버는 요청이 프록시 서버로부터 온 것으로 인식한다.
=> setupProxy.js 요약
이 설정은 개발 환경에서 클라이언트의 /payment/process 경로로의 요청을 http:/localhost:3001/payment/process로 프록시한다. 이를 통해 클라이언트와 서버 간의 CORS 문제를 해결하거나, API 서버와의 네트워크 요청을 프록시 서버를 통해 간단하게 관리할 수 있다.
예약 시간을 선택하지 않았더니 예약시간을 선택하라는 경고 메세지가 나온다!
→ 검증 처리 & 예약하기 버튼을 눌렀을 때 결제 데이터가 잘 넘어온다
inicisForm 양식 쪽에 자동적으로 잘 만들어지게 되고 다만, 지금 처리된 부분은 같은 서버 내에서만 가능하게 되어있어서 returnUrl을 지금 프록시 형태로 설정을 하고 있다. 실제 처리는 api 서버 쪽에서 할 수 있게 앞으로 구현해야 한다.
문제점(오류)
1. 인원 버튼을 누르지 않아도 처음부터 1명이 바로 선택되어 있다.
2. 시간을 선택하자마자 예약하기 버튼을 누르지 않아도 결제 페이지로 넘어간다.
3. "상점명/undefined인 예약"으로 나오는 오류(undefined 오류)
2번 오류 해결
TimeButton 타입에 button이라고 넣어주기
PersonButton에도 type="button" 추가하기
💡
Q. button에 type="button" 을 지정해 주는 이유?
A. 그게 디폴트가 아니다!
button의 type에는 3가지 값을 지정해 줄 수 있는데 각각 submit, reset, button이다.
만약 아무런 값도 지정하지 않았다면 기본 값은 submit이다.
그러니까 <button></button> === <button type="submit"></button>인 셈이다.
따라서 form 태그 내에서 button을 사용할 때 타입 명시가 없다면 기본적으로 'submit' 처리가 일어나게 된다. 그렇기 때문에 버튼을 누르면 바로 결제 페이지로 넘어가게 된다. 따라서 시간 버튼과 인원 버튼의 타입은 버튼이라고 지정해 주어야 한다!!!
1번 오류 해결
기본값을 1로 설정해 두었었다! 이쪽을 주석 처리하기 → 명수 버튼 기본 설정이 1로 되어있던 것이 풀림!
인원을 설정하지 않고 예약하기를 누르는 상황이 이제 발생하기 때문에 필수 항목 검증을 통해 인원을 설정하지 않았을 때 오류 문구(경고 문구)가 뜨게 설정하기!
1번 오류 해결 및 필수 항목 검증 & 에러 문구 추가
common.js에서 공통 텍스트 관리
결제 페이지 결제하기 버튼 디자인 수정
MidButton을 BigButton으로 바꿔주고 color를 jmt로 설정해 주기!