generated from pricelees/issue-pr-template
[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 #57
@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
|
||||
paymentKey: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
paymentType: PaymentType;
|
||||
}
|
||||
|
||||
export interface PaymentCancelRequest {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import type { Difficulty } from '@_api/theme/themeTypes';
|
||||
|
||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||
|
||||
export const ScheduleStatus = {
|
||||
@ -40,16 +38,35 @@ export interface AdminScheduleSummaryListResponse {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id: string,
|
||||
startFrom: string,
|
||||
endAt: string,
|
||||
themeId: string,
|
||||
themeName: string,
|
||||
themeDifficulty: Difficulty,
|
||||
status: ScheduleStatus
|
||||
schedule: ScheduleResponse,
|
||||
theme: ScheduleThemeInfo
|
||||
}
|
||||
|
||||
export interface ScheduleWithThemeListResponse {
|
||||
schedules: ScheduleWithThemeResponse[];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
||||
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
|
||||
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||||
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
||||
import {getStores} from '@_api/store/storeAPI';
|
||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||
import {fetchThemeById} from '@_api/theme/themeAPI';
|
||||
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
||||
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
|
||||
import { type ReservationData } from '@_api/reservation/reservationTypes';
|
||||
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
||||
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { getStores } from '@_api/store/storeAPI';
|
||||
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
||||
import { fetchThemeById } from '@_api/theme/themeAPI';
|
||||
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {type ReservationData} from '@_api/reservation/reservationTypes';
|
||||
import {formatDate} from 'src/util/DateTimeFormatter';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||
|
||||
const ReservationStep1Page: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
|
||||
fetchSchedules(selectedStore.id, dateStr)
|
||||
.then(res => {
|
||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||
const key = schedule.themeName;
|
||||
const key = schedule.theme.name;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(schedule);
|
||||
return acc;
|
||||
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
|
||||
const handleConfirmReservation = () => {
|
||||
if (!selectedSchedule) return;
|
||||
|
||||
holdSchedule(selectedSchedule.id)
|
||||
holdSchedule(selectedSchedule.schedule.id)
|
||||
.then(() => {
|
||||
fetchThemeById(selectedSchedule.themeId).then(res => {
|
||||
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
||||
const reservationData: ReservationData = {
|
||||
scheduleId: selectedSchedule.id,
|
||||
scheduleId: selectedSchedule.schedule.id,
|
||||
store: {
|
||||
id: selectedStore!.id,
|
||||
name: selectedStore!.name,
|
||||
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
|
||||
maxParticipants: res.maxParticipants,
|
||||
},
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
startFrom: selectedSchedule.startFrom,
|
||||
endAt: selectedSchedule.endAt,
|
||||
startFrom: selectedSchedule.schedule.startFrom,
|
||||
endAt: selectedSchedule.schedule.endAt,
|
||||
};
|
||||
navigate('/reservation/form', {state: reservationData});
|
||||
}).catch(handleError);
|
||||
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
|
||||
<h3>3. 시간 선택</h3>
|
||||
<div className="schedule-list">
|
||||
{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 className="theme-header">
|
||||
<h4>{themeName}</h4>
|
||||
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
||||
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
||||
className="theme-detail-button">상세보기
|
||||
</button>
|
||||
</div>
|
||||
<div className="time-slots">
|
||||
{schedules.map(schedule => (
|
||||
{scheduleAndTheme.map(schedule => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||
key={schedule.schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||
>
|
||||
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
||||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||||
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
||||
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
|
||||
<div className="modal-section modal-info-grid">
|
||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
||||
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
||||
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
||||
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { confirmPayment } from '@_api/payment/paymentAPI';
|
||||
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes';
|
||||
import { confirm } from '@_api/order/orderAPI';
|
||||
import type { BookingErrorResponse } from '@_api/order/orderTypes';
|
||||
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
||||
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import type { AxiosError } from 'axios';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
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 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(() => {
|
||||
if (!reservationId) {
|
||||
alert('잘못된 접근입니다.');
|
||||
@ -66,7 +56,7 @@ const ReservationStep2Page: React.FC = () => {
|
||||
|
||||
const generateRandomString = () =>
|
||||
crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
|
||||
|
||||
paymentWidgetRef.current.requestPayment({
|
||||
orderId: generateRandomString(),
|
||||
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
|
||||
paymentKey: data.paymentKey,
|
||||
orderId: data.orderId,
|
||||
amount: totalPrice,
|
||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||
};
|
||||
|
||||
confirmPayment(reservationId, paymentData)
|
||||
.then(() => {
|
||||
return confirmReservation(reservationId);
|
||||
})
|
||||
confirm(reservationId, paymentData)
|
||||
.then(() => {
|
||||
alert('결제가 완료되었어요!');
|
||||
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) => {
|
||||
console.error("Payment request error:", error);
|
||||
alert("결제 요청 중 오류가 발생했습니다.");
|
||||
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user