[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 #57

Merged
pricelees merged 45 commits from refactor/#56 into main 2025-10-09 09:33:29 +00:00
4 changed files with 104 additions and 63 deletions
Showing only changes of commit 216bde2d25 - Show all commits

View File

@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
paymentKey: string; paymentKey: string;
orderId: string; orderId: string;
amount: number; amount: number;
paymentType: PaymentType;
} }
export interface PaymentCancelRequest { export interface PaymentCancelRequest {

View File

@ -1,5 +1,3 @@
import type { Difficulty } from '@_api/theme/themeTypes';
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED'; export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
export const ScheduleStatus = { export const ScheduleStatus = {
@ -40,14 +38,33 @@ export interface AdminScheduleSummaryListResponse {
} }
// Public // Public
export interface ScheduleResponse {
id: string;
date: string;
startFrom: string;
endAt: string;
status: ScheduleStatus;
}
export interface ScheduleThemeInfo {
id: string;
name: string;
}
export interface ScheduleStoreInfo {
id: string;
name: string;
}
export interface ScheduleWithStoreAndThemeResponse {
schedule: ScheduleResponse,
theme: ScheduleThemeInfo,
store: ScheduleStoreInfo,
}
export interface ScheduleWithThemeResponse { export interface ScheduleWithThemeResponse {
id: string, schedule: ScheduleResponse,
startFrom: string, theme: ScheduleThemeInfo
endAt: string,
themeId: string,
themeName: string,
themeDifficulty: Difficulty,
status: ScheduleStatus
} }
export interface ScheduleWithThemeListResponse { export interface ScheduleWithThemeListResponse {

View File

@ -1,17 +1,17 @@
import {isLoginRequiredError} from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI'; import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes'; import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI'; import { type ReservationData } from '@_api/reservation/reservationTypes';
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes'; import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
import {getStores} from '@_api/store/storeAPI'; import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
import {type SimpleStoreResponse} from '@_api/store/storeTypes'; import { getStores } from '@_api/store/storeAPI';
import {fetchThemeById} from '@_api/theme/themeAPI'; import { type SimpleStoreResponse } from '@_api/store/storeTypes';
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes'; import { fetchThemeById } from '@_api/theme/themeAPI';
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import {type ReservationData} from '@_api/reservation/reservationTypes'; import { formatDate } from 'src/util/DateTimeFormatter';
import {formatDate} from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => { const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
fetchSchedules(selectedStore.id, dateStr) fetchSchedules(selectedStore.id, dateStr)
.then(res => { .then(res => {
const grouped = res.schedules.reduce((acc, schedule) => { const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.themeName; const key = schedule.theme.name;
if (!acc[key]) acc[key] = []; if (!acc[key]) acc[key] = [];
acc[key].push(schedule); acc[key].push(schedule);
return acc; return acc;
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
const handleConfirmReservation = () => { const handleConfirmReservation = () => {
if (!selectedSchedule) return; if (!selectedSchedule) return;
holdSchedule(selectedSchedule.id) holdSchedule(selectedSchedule.schedule.id)
.then(() => { .then(() => {
fetchThemeById(selectedSchedule.themeId).then(res => { fetchThemeById(selectedSchedule.theme.id).then(res => {
const reservationData: ReservationData = { const reservationData: ReservationData = {
scheduleId: selectedSchedule.id, scheduleId: selectedSchedule.schedule.id,
store: { store: {
id: selectedStore!.id, id: selectedStore!.id,
name: selectedStore!.name, name: selectedStore!.name,
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
maxParticipants: res.maxParticipants, maxParticipants: res.maxParticipants,
}, },
date: selectedDate.toLocaleDateString('en-CA'), date: selectedDate.toLocaleDateString('en-CA'),
startFrom: selectedSchedule.startFrom, startFrom: selectedSchedule.schedule.startFrom,
endAt: selectedSchedule.endAt, endAt: selectedSchedule.schedule.endAt,
}; };
navigate('/reservation/form', {state: reservationData}); navigate('/reservation/form', {state: reservationData});
}).catch(handleError); }).catch(handleError);
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
<h3>3. </h3> <h3>3. </h3>
<div className="schedule-list"> <div className="schedule-list">
{Object.keys(schedulesByTheme).length > 0 ? ( {Object.keys(schedulesByTheme).length > 0 ? (
Object.entries(schedulesByTheme).map(([themeName, schedules]) => ( Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
<div key={themeName} className="theme-schedule-group"> <div key={themeName} className="theme-schedule-group">
<div className="theme-header"> <div className="theme-header">
<h4>{themeName}</h4> <h4>{themeName}</h4>
<button onClick={() => openThemeModal(schedules[0].themeId)} <button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
className="theme-detail-button"> className="theme-detail-button">
</button> </button>
</div> </div>
<div className="time-slots"> <div className="time-slots">
{schedules.map(schedule => ( {scheduleAndTheme.map(schedule => (
<div <div
key={schedule.id} key={schedule.schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`} className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)} onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
> >
{`${schedule.startFrom} ~ ${schedule.endAt}`} {`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.status)}</span> <span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
</div> </div>
))} ))}
</div> </div>
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
<div className="modal-section modal-info-grid"> <div className="modal-section modal-info-grid">
<p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p> <p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong><span>{selectedStore?.name}</span></p> <p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong><span>{selectedSchedule.themeName}</span></p> <p><strong>:</strong><span>{selectedSchedule.theme.name}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p> <p><strong>:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button> <button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button>

View File

@ -1,8 +1,9 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { confirm } from '@_api/order/orderAPI';
import { confirmPayment } from '@_api/payment/paymentAPI'; import type { BookingErrorResponse } from '@_api/order/orderTypes';
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes'; import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import { confirmReservation } from '@_api/reservation/reservationAPI'; import { confirmReservation } from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import type { AxiosError } from 'axios';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter'; import { formatDate } from 'src/util/DateTimeFormatter';
@ -21,17 +22,6 @@ const ReservationStep2Page: React.FC = () => {
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {}; const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => { useEffect(() => {
if (!reservationId) { if (!reservationId) {
alert('잘못된 접근입니다.'); alert('잘못된 접근입니다.');
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
paymentKey: data.paymentKey, paymentKey: data.paymentKey,
orderId: data.orderId, orderId: data.orderId,
amount: totalPrice, amount: totalPrice,
paymentType: data.paymentType || PaymentType.NORMAL,
}; };
confirm(reservationId, paymentData)
confirmPayment(reservationId, paymentData)
.then(() => {
return confirmReservation(reservationId);
})
.then(() => { .then(() => {
alert('결제가 완료되었어요!'); alert('결제가 완료되었어요!');
navigate('/reservation/success', { navigate('/reservation/success', {
@ -97,10 +82,50 @@ const ReservationStep2Page: React.FC = () => {
} }
}); });
}) })
.catch(handleError); .catch(err => {
const error = err as AxiosError<BookingErrorResponse>;
const errorCode = error.response?.data?.code;
const errorMessage = error.response?.data?.message;
if (errorCode === 'B000') {
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
navigate('/reservation');
return;
}
const trial = error.response?.data?.trial || 0;
if (trial < 2) {
alert(errorMessage);
return;
}
alert(errorMessage);
setTimeout(() => {
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
if (agreeToOnsitePayment) {
confirmReservation(reservationId)
.then(() => {
navigate('/reservation/success', {
state: {
storeName,
themeName,
date,
time,
participantCount,
totalPrice,
},
});
});
} else {
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
navigate('/');
}
}, 100);
});
}).catch((error: any) => { }).catch((error: any) => {
console.error("Payment request error:", error); console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했습니다."); alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
}); });
}; };