generated from pricelees/issue-pr-template
feat: 새로운 결제 및 나의 예약 조회 페이지 추가
This commit is contained in:
parent
4c82ad80c0
commit
817dc9f761
@ -33,10 +33,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -13,6 +13,10 @@ import AdminThemePage from './pages/admin/ThemePage';
|
||||
import AdminWaitingPage from './pages/admin/WaitingPage';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import AdminRoute from './components/AdminRoute';
|
||||
import ReservationStep1Page from './pages/v2/ReservationStep1Page';
|
||||
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
|
||||
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
|
||||
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
|
||||
|
||||
const AdminRoutes = () => (
|
||||
<AdminLayout>
|
||||
@ -43,7 +47,13 @@ function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/reservation" element={<ReservationPage />} />
|
||||
<Route path="/reservation-mine" element={<MyReservationPage />} />
|
||||
<Route path="/my-reservation" element={<MyReservationPage />} />
|
||||
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
|
||||
|
||||
{/* V2 Reservation Flow */}
|
||||
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
||||
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
||||
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
} />
|
||||
|
||||
@ -2,6 +2,10 @@ import apiClient from "@_api/apiClient";
|
||||
import type {
|
||||
AdminReservationCreateRequest,
|
||||
MyReservationRetrieveListResponse,
|
||||
ReservationPaymentRequest,
|
||||
ReservationPaymentResponse,
|
||||
ReservationCreateRequest,
|
||||
ReservationCreateResponse,
|
||||
ReservationCreateWithPaymentRequest,
|
||||
ReservationRetrieveListResponse,
|
||||
ReservationRetrieveResponse,
|
||||
@ -68,3 +72,13 @@ export const confirmWaiting = async (id: string): Promise<void> => {
|
||||
export const rejectWaiting = async (id: string): Promise<void> => {
|
||||
return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true);
|
||||
};
|
||||
|
||||
// POST /v2/reservations
|
||||
export const createPendingReservation = async (data: ReservationCreateRequest): Promise<ReservationCreateResponse> => {
|
||||
return await apiClient.post<ReservationCreateResponse>('/v2/reservations', data, true);
|
||||
};
|
||||
|
||||
// POST /v2/reservations/{id}/pay
|
||||
export const confirmReservationPayment = async (id: string, data: ReservationPaymentRequest): Promise<ReservationPaymentResponse> => {
|
||||
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
|
||||
};
|
||||
150
frontend/src/api/reservation/reservationAPIV2.ts
Normal file
150
frontend/src/api/reservation/reservationAPIV2.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import type { ReservationSummaryV2, ReservationDetailV2 } from './reservationTypes';
|
||||
|
||||
// --- API 호출 함수 ---
|
||||
|
||||
/**
|
||||
* 내 예약 목록을 가져옵니다. (V2)
|
||||
*/
|
||||
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryV2[]> => {
|
||||
// 실제 API 연동 시 아래 코드로 교체
|
||||
// const response = await apiClient.get<ReservationSummaryV2[]>('/v2/reservations');
|
||||
// return response.data;
|
||||
|
||||
// 현재는 목업 데이터를 반환합니다.
|
||||
console.log('[API] fetchMyReservationsV2 호출');
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{ id: 1, date: '2025-08-20', time: '14:00', themeName: '공포의 방', status: 'CONFIRMED' },
|
||||
{ id: 2, date: '2025-08-22', time: '19:30', themeName: '신비의 숲', status: 'CONFIRMED' },
|
||||
{ id: 3, date: '2024-12-25', time: '11:00', themeName: '미래 도시', status: 'CANCELLED' },
|
||||
]);
|
||||
}, 500)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 예약의 상세 정보를 가져옵니다. (V2)
|
||||
* @param id 예약 ID
|
||||
*/
|
||||
export const fetchReservationDetailV2 = async (id: number): Promise<ReservationDetailV2> => {
|
||||
// 실제 API 연동 시 아래 코드로 교체
|
||||
// const response = await apiClient.get<ReservationDetailV2>(`/v2/reservations/${id}`);
|
||||
// return response.data;
|
||||
|
||||
// 현재는 목업 데이터를 반환합니다.
|
||||
console.log(`[API] fetchReservationDetailV2 호출 (id: ${id})`);
|
||||
console.log(`[API] fetchReservationDetailV2 호출 (id: ${id})`);
|
||||
|
||||
const mockDetails: { [key: number]: ReservationDetailV2 } = {
|
||||
1: {
|
||||
id: 1,
|
||||
memberName: '박예약',
|
||||
memberEmail: 'reserve@example.com',
|
||||
applicationDateTime: '2025-08-20T13:50:00Z',
|
||||
payment: {
|
||||
paymentKey: 'reserve-payment-key',
|
||||
orderId: 'reserve-order-id',
|
||||
totalAmount: 50000,
|
||||
method: 'CARD',
|
||||
status: 'DONE',
|
||||
requestedAt: '2025-08-20T13:50:00Z',
|
||||
approvedAt: '2025-08-20T13:50:05Z',
|
||||
detail: {
|
||||
type: 'CARD',
|
||||
issuerCode: 'SHINHAN',
|
||||
cardType: 'CHECK',
|
||||
ownerType: 'PERSONAL',
|
||||
cardNumber: '5423-****-****-1234',
|
||||
approvalNumber: '12345678',
|
||||
installmentPlanMonths: 0,
|
||||
isInterestFree: true,
|
||||
}
|
||||
},
|
||||
cancellation: null,
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
memberName: '이간편',
|
||||
memberEmail: 'easypay@example.com',
|
||||
applicationDateTime: '2025-08-22T19:20:00Z',
|
||||
payment: {
|
||||
paymentKey: 'easypay-card-key',
|
||||
orderId: 'easypay-card-order-id',
|
||||
totalAmount: 75000,
|
||||
method: 'CARD',
|
||||
status: 'DONE',
|
||||
requestedAt: '2025-08-22T19:20:00Z',
|
||||
approvedAt: '2025-08-22T19:20:05Z',
|
||||
detail: {
|
||||
type: 'CARD',
|
||||
issuerCode: 'HYUNDAI',
|
||||
cardType: 'CREDIT',
|
||||
ownerType: 'PERSONAL',
|
||||
cardNumber: '4321-****-****-5678',
|
||||
approvalNumber: '87654321',
|
||||
installmentPlanMonths: 3,
|
||||
isInterestFree: false,
|
||||
easypayProviderCode: 'NAVERPAY',
|
||||
}
|
||||
},
|
||||
cancellation: null,
|
||||
},
|
||||
3: {
|
||||
id: 3,
|
||||
memberName: '김취소',
|
||||
memberEmail: 'cancel@example.com',
|
||||
applicationDateTime: '2024-12-25T10:50:00Z',
|
||||
payment: {
|
||||
paymentKey: 'cancel-payment-key',
|
||||
orderId: 'cancel-order-id',
|
||||
totalAmount: 52000,
|
||||
method: 'EASYPAY_PREPAID',
|
||||
status: 'CANCELED',
|
||||
requestedAt: '2024-12-25T10:50:00Z',
|
||||
approvedAt: '2024-12-25T10:50:05Z',
|
||||
detail: {
|
||||
type: 'EASYPAY_PREPAID',
|
||||
easypayProviderCode: 'TOSSPAY',
|
||||
amount: 52000,
|
||||
discountAmount: 0,
|
||||
}
|
||||
},
|
||||
cancellation: {
|
||||
cancellationRequestedAt: '2025-01-10T14:59:00Z',
|
||||
cancellationCompletedAt: '2025-01-10T15:00:00Z',
|
||||
cancelReason: '개인 사정으로 인한 취소',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
const mockDetail = mockDetails[id] || mockDetails[1]; // Fallback to 1
|
||||
resolve(mockDetail);
|
||||
}, 800)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 예약을 취소합니다. (V2)
|
||||
* @param id 예약 ID
|
||||
* @param cancelReason 취소 사유
|
||||
*/
|
||||
export const cancelReservationV2 = async (id: number, cancelReason: string): Promise<void> => {
|
||||
// 실제 API 연동 시 아래 코드로 교체
|
||||
// await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason });
|
||||
|
||||
console.log(`[API] cancelReservationV2 호출 (id: ${id}, reason: ${cancelReason})`);
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (cancelReason && cancelReason.trim().length > 0) {
|
||||
// 성공 시, 목업 데이터 업데이트 (실제로는 서버가 처리)
|
||||
console.log(`Reservation ${id} has been cancelled.`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('취소 사유를 반드시 입력해야 합니다.'));
|
||||
}
|
||||
}, 800);
|
||||
});
|
||||
};
|
||||
@ -3,15 +3,21 @@ import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
||||
|
||||
export const ReservationStatus = {
|
||||
PENDING: 'PENDING',
|
||||
CONFIRMED: 'CONFIRMED',
|
||||
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
|
||||
WAITING: 'WAITING',
|
||||
CANCELED_BY_USER: 'CANCELED_BY_USER',
|
||||
AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED'
|
||||
} as const;
|
||||
|
||||
export type ReservationStatus =
|
||||
| typeof ReservationStatus.PENDING
|
||||
| typeof ReservationStatus.CONFIRMED
|
||||
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
|
||||
| typeof ReservationStatus.WAITING;
|
||||
| typeof ReservationStatus.WAITING
|
||||
| typeof ReservationStatus.CANCELED_BY_USER
|
||||
| typeof ReservationStatus.AUTOMATICALLY_CANCELED;
|
||||
|
||||
export interface MyReservationRetrieveResponse {
|
||||
id: string;
|
||||
@ -70,3 +76,119 @@ export interface ReservationSearchQuery {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
// V2 types
|
||||
export const PaymentType = {
|
||||
NORMAL: 'NORMAL',
|
||||
BILLING: 'BILLING',
|
||||
BRANDPAY: 'BRANDPAY'
|
||||
} as const;
|
||||
|
||||
export type PaymentType =
|
||||
| typeof PaymentType.NORMAL
|
||||
| typeof PaymentType.BILLING
|
||||
| typeof PaymentType.BRANDPAY;
|
||||
|
||||
export const PaymentStatus = {
|
||||
IN_PROGRESS: '결제 진행 중',
|
||||
DONE: '결제 완료',
|
||||
CANCELED: '결제 취소',
|
||||
ABORTED: '결제 중단',
|
||||
EXPIRED: '시간 만료',
|
||||
}
|
||||
|
||||
export type PaymentStatus =
|
||||
| typeof PaymentStatus.IN_PROGRESS
|
||||
| typeof PaymentStatus.DONE
|
||||
| typeof PaymentStatus.CANCELED
|
||||
| typeof PaymentStatus.ABORTED
|
||||
| typeof PaymentStatus.EXPIRED;
|
||||
|
||||
|
||||
export interface ReservationCreateRequest {
|
||||
date: string;
|
||||
timeId: string;
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
export interface ReservationCreateResponse {
|
||||
reservationId: string;
|
||||
memberEmail: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
export interface ReservationPaymentRequest {
|
||||
paymentKey: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
paymentType: PaymentType
|
||||
}
|
||||
|
||||
export interface ReservationPaymentResponse {
|
||||
reservationId: string;
|
||||
reservationStatus: ReservationStatus;
|
||||
paymentId: string;
|
||||
paymentStatus: PaymentStatus;
|
||||
}
|
||||
|
||||
export interface ReservationSummaryV2 {
|
||||
id: number;
|
||||
themeName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
status: 'CONFIRMED' | 'CANCELLED';
|
||||
}
|
||||
|
||||
export interface ReservationDetailV2 {
|
||||
id: number;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
applicationDateTime: string; // yyyy년 MM월 dd일 HH시 mm분
|
||||
payment: PaymentV2;
|
||||
cancellation: CancellationV2 | null;
|
||||
}
|
||||
|
||||
export interface PaymentV2 {
|
||||
paymentKey: string;
|
||||
orderId: string;
|
||||
totalAmount: number;
|
||||
method: 'CARD' | 'BANK_TRANSFER' | 'EASYPAY_PREPAID';
|
||||
status: 'DONE' | 'CANCELED';
|
||||
requestedAt: string;
|
||||
approvedAt: string;
|
||||
detail: CardPaymentDetailV2 | BankTransferPaymentDetailV2 | EasyPayPrepaidPaymentDetailV2;
|
||||
}
|
||||
|
||||
export interface CardPaymentDetailV2 {
|
||||
type: 'CARD';
|
||||
issuerCode: string;
|
||||
cardType: 'CREDIT' | 'CHECK' | 'GIFT';
|
||||
ownerType: 'PERSONAL' | 'CORPORATE';
|
||||
cardNumber: string;
|
||||
approvalNumber: string;
|
||||
installmentPlanMonths: number;
|
||||
isInterestFree: boolean;
|
||||
easypayProviderCode?: string;
|
||||
easypayDiscountAmount?: number;
|
||||
}
|
||||
|
||||
export interface BankTransferPaymentDetailV2 {
|
||||
type: 'BANK_TRANSFER';
|
||||
bankCode: string;
|
||||
settlementStatus: string;
|
||||
}
|
||||
|
||||
export interface EasyPayPrepaidPaymentDetailV2 {
|
||||
type: 'EASYPAY_PREPAID';
|
||||
easypayProviderCode: string;
|
||||
amount: number;
|
||||
discountAmount: number;
|
||||
}
|
||||
|
||||
export interface CancellationV2 {
|
||||
cancellationRequestedAt: string; // ISO 8601 format
|
||||
cancellationCompletedAt: string; // ISO 8601 format
|
||||
cancelReason: string;
|
||||
}
|
||||
|
||||
137
frontend/src/components/ReservationCard.tsx
Normal file
137
frontend/src/components/ReservationCard.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import React, { useState } from 'react';
|
||||
import '../css/my-reservation-v2.css';
|
||||
import type { Reservation } from '../pages/v2/MyReservationPageV2'; // 페이지로부터 타입 import
|
||||
|
||||
interface ReservationCardProps {
|
||||
reservation: Reservation;
|
||||
}
|
||||
|
||||
// 날짜 및 시간 포맷팅 함수
|
||||
const formatDateTime = (dateStr: string, timeStr: string): string => {
|
||||
const date = new Date(`${dateStr}T${timeStr}`);
|
||||
const today = new Date();
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
|
||||
|
||||
let hour = date.getHours();
|
||||
const minute = date.getMinutes();
|
||||
|
||||
const isPastYear = year < today.getFullYear();
|
||||
|
||||
const ampm = hour < 12 ? '오전' : '오후';
|
||||
hour = hour % 12;
|
||||
if (hour === 0) hour = 12;
|
||||
|
||||
let datePart = '';
|
||||
if (isPastYear) {
|
||||
datePart = `${String(year).slice(-2)}.${month}.${day}`;
|
||||
} else {
|
||||
datePart = `${month}.${day}`;
|
||||
}
|
||||
|
||||
let timePart = `${ampm} ${hour}시`;
|
||||
if (minute > 0) {
|
||||
timePart += ` ${minute}분`;
|
||||
}
|
||||
|
||||
return `${datePart}(${dayOfWeek}) ${timePart}`;
|
||||
};
|
||||
|
||||
|
||||
const ReservationCard: React.FC<ReservationCardProps> = ({ reservation }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState('');
|
||||
|
||||
const isCanceled = reservation.status === 'CANCELED';
|
||||
|
||||
const handleToggleDetails = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
if (isExpanded) {
|
||||
setIsCancelling(false);
|
||||
setCancelReason('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCancel = () => {
|
||||
if (window.confirm('정말 예약을 취소하시겠어요?')) {
|
||||
setIsCancelling(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitCancellation = () => {
|
||||
console.log('--- 예약 취소 요청 ---');
|
||||
console.log('예약 ID:', reservation.id);
|
||||
console.log('취소 사유:', cancelReason);
|
||||
alert(`취소 요청을 접수했어요.`);
|
||||
setIsCancelling(false);
|
||||
setIsExpanded(false);
|
||||
setCancelReason('');
|
||||
// 실제 API 연동 시에는 상태를 다시 fetch 해야 함
|
||||
};
|
||||
|
||||
const cardClasses = `reservation-card ${isCanceled ? 'canceled' : ''}`;
|
||||
const headerClasses = `theme-header ${isCanceled ? 'canceled' : ''}`;
|
||||
|
||||
return (
|
||||
<div className={cardClasses}>
|
||||
{isCanceled && <div className="status-badge">취소 완료</div>}
|
||||
<div className="reservation-summary" onClick={handleToggleDetails}>
|
||||
<div className="summary-info">
|
||||
<h4 className={headerClasses}>{reservation.theme}</h4>
|
||||
<span className="date-time">{formatDateTime(reservation.date, reservation.time)}</span>
|
||||
</div>
|
||||
<button className="details-button" type="button">
|
||||
{isExpanded ? '닫기' : '상세보기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="reservation-details">
|
||||
<div className="details-item">
|
||||
<span className="label">결제수단</span>
|
||||
<span className="value">{reservation.payment.method}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="label">결제금액</span>
|
||||
<span className="value">{reservation.payment.amount}</span>
|
||||
</div>
|
||||
|
||||
<div className="cancel-section">
|
||||
{isCanceled ? (
|
||||
<button className="cancel-button disabled" type="button" disabled>
|
||||
취소 완료
|
||||
</button>
|
||||
) : !isCancelling ? (
|
||||
<button className="cancel-button" type="button" onClick={handleStartCancel}>
|
||||
취소하기
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className="cancel-reason-input"
|
||||
placeholder="취소 사유를 입력해주세요."
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="submit-cancel-button"
|
||||
type="button"
|
||||
onClick={handleSubmitCancellation}
|
||||
disabled={!cancelReason.trim()}
|
||||
>
|
||||
취소 요청 제출
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationCard;
|
||||
288
frontend/src/css/my-reservation-v2.css
Normal file
288
frontend/src/css/my-reservation-v2.css
Normal file
@ -0,0 +1,288 @@
|
||||
/* General Container */
|
||||
.my-reservation-container-v2 {
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f4f6f8;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.my-reservation-container-v2 h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333d4b;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.error-message-v2 {
|
||||
color: #d9534f;
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Reservation List */
|
||||
.reservation-list-v2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* Reservation Summary Card */
|
||||
.reservation-summary-card-v2 {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.reservation-summary-card-v2:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.summary-details-v2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summary-theme-name-v2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #333d4b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-datetime-v2 {
|
||||
font-size: 16px;
|
||||
color: #505a67;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Canceled Card Style */
|
||||
.reservation-summary-card-v2.status-cancelled {
|
||||
background-color: #f8f9fa;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.reservation-summary-card-v2.status-cancelled .summary-theme-name-v2,
|
||||
.reservation-summary-card-v2.status-cancelled .summary-datetime-v2,
|
||||
.reservation-summary-card-v2.status-cancelled .summary-details-v2 strong {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Detail Button */
|
||||
.detail-button-v2 {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.detail-button-v2:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.detail-button-v2:disabled {
|
||||
background-color: #cdd3d8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay-v2 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content-v2 {
|
||||
background: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
position: relative;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||
animation: slide-up 0.3s ease-out;
|
||||
max-height: 90vh; /* Prevent modal from being too tall */
|
||||
overflow-y: auto; /* Allow scrolling for long content */
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close-button-v2 {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #8492a6;
|
||||
}
|
||||
|
||||
.modal-content-v2 h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 25px;
|
||||
color: #333d4b;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.modal-section-v2 {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #e5e8eb;
|
||||
}
|
||||
|
||||
.modal-section-v2:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modal-section-v2 h3 {
|
||||
font-size: 18px;
|
||||
color: #333d4b;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.modal-section-v2 p {
|
||||
margin: 0 0 10px;
|
||||
color: #505a67;
|
||||
}
|
||||
|
||||
.modal-section-v2 p strong {
|
||||
color: #333d4b;
|
||||
font-weight: 600;
|
||||
min-width: 100px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cancellation-section-v2 {
|
||||
background-color: #fcf2f2;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0d1d1;
|
||||
}
|
||||
|
||||
.cancellation-section-v2 h3 {
|
||||
color: #c9302c;
|
||||
}
|
||||
|
||||
/* Modal Actions & Cancellation View */
|
||||
.modal-actions-v2 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.modal-actions-v2 button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.cancel-button-v2 {
|
||||
background-color: #e53e3e;
|
||||
color: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cancel-button-v2:hover {
|
||||
background-color: #c53030;
|
||||
}
|
||||
|
||||
.back-button-v2 {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.back-button-v2:hover {
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
.cancel-submit-button-v2 {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cancel-submit-button-v2:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.cancel-submit-button-v2:disabled,
|
||||
.back-button-v2:disabled {
|
||||
background-color: #cdd3d8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancellation-view-v2 h3 {
|
||||
font-size: 18px;
|
||||
color: #333d4b;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.cancellation-summary-v2 {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cancellation-summary-v2 p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.cancellation-summary-v2 p:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cancellation-reason-textarea-v2 {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
box-sizing: border-box; /* Ensures padding doesn't add to width */
|
||||
}
|
||||
|
||||
.cancellation-reason-textarea-v2:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
175
frontend/src/css/reservation-v2.css
Normal file
175
frontend/src/css/reservation-v2.css
Normal file
@ -0,0 +1,175 @@
|
||||
#root .flatpickr-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#root .modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1050;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#root .modal-dialog {
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
margin: 1.75rem auto;
|
||||
}
|
||||
|
||||
/* Toss-style Modal */
|
||||
#root .modal-content {
|
||||
background-color: #fff !important;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#root .modal-header {
|
||||
border-bottom: none;
|
||||
padding: 0 0 1rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#root .modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#root .btn-close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#root .modal-body {
|
||||
padding: 1rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root .modal-body p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#root .modal-footer {
|
||||
border-top: none;
|
||||
padding: 1rem 0 0 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Generic Button Styles --- */
|
||||
#root .btn-primary,
|
||||
#root .modal-footer .btn-primary,
|
||||
#root .btn-wrapper .btn-primary,
|
||||
#root .button-group .btn-primary,
|
||||
#root .success-page-actions .btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
color: #fff;
|
||||
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#root .btn-secondary,
|
||||
#root .modal-footer .btn-secondary,
|
||||
#root .success-page-actions .btn-secondary {
|
||||
background-color: #f0f2f5;
|
||||
border-color: #f0f2f5;
|
||||
color: #333;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#root .btn-primary:hover,
|
||||
#root .modal-footer .btn-primary:hover,
|
||||
#root .btn-wrapper .btn-primary:hover,
|
||||
#root .button-group .btn-primary:hover,
|
||||
#root .success-page-actions .btn-primary:hover {
|
||||
background-color: #0069d9;
|
||||
border-color: #0062cc;
|
||||
}
|
||||
|
||||
#root .btn-secondary:hover,
|
||||
#root .modal-footer .btn-secondary:hover,
|
||||
#root .success-page-actions .btn-secondary:hover {
|
||||
background-color: #e2e6ea;
|
||||
}
|
||||
|
||||
|
||||
/* --- Reservation Success Page Styles --- */
|
||||
.reservation-success-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.reservation-success-page .content-container-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.reservation-info-box {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
background-color: #fff;
|
||||
min-width: 380px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reservation-info-box h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reservation-info-box .info-item {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reservation-info-box .info-item strong {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success-page-actions {
|
||||
margin-top: 2.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
@ -3,9 +3,8 @@
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@ -53,15 +52,4 @@ button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -84,11 +84,10 @@ const ReservationPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const generateRandomString = () =>
|
||||
window.btoa(Math.random().toString()).slice(0, 20);
|
||||
const orderIdPrefix = "WTEST";
|
||||
crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
paymentWidgetRef.current.requestPayment({
|
||||
orderId: orderIdPrefix + generateRandomString(),
|
||||
orderId: generateRandomString(),
|
||||
orderName: "테스트 방탈출 예약 결제 1건",
|
||||
amount: 1000,
|
||||
}).then(function (data: any) {
|
||||
|
||||
294
frontend/src/pages/v2/MyReservationPageV2.tsx
Normal file
294
frontend/src/pages/v2/MyReservationPageV2.tsx
Normal file
@ -0,0 +1,294 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import '../../css/my-reservation-v2.css';
|
||||
import {
|
||||
fetchMyReservationsV2,
|
||||
fetchReservationDetailV2,
|
||||
cancelReservationV2
|
||||
} from '../../api/reservation/reservationAPIV2';
|
||||
import type { ReservationSummaryV2, ReservationDetailV2, PaymentV2 } from '../../api/reservation/reservationTypes';
|
||||
|
||||
const formatDisplayDateTime = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', options).format(date);
|
||||
};
|
||||
|
||||
const formatCardDateTime = (dateStr: string, timeStr: string): string => {
|
||||
const date = new Date(`${dateStr}T${timeStr}`);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const reservationYear = date.getFullYear();
|
||||
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const dayOfWeek = days[date.getDay()];
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
let hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const ampm = hours >= 12 ? '오후' : '오전';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
|
||||
let datePart = '';
|
||||
if (currentYear === reservationYear) {
|
||||
datePart = `${month}월 ${day}일(${dayOfWeek})`;
|
||||
} else {
|
||||
datePart = `${reservationYear}년 ${month}월 ${day}일(${dayOfWeek})`;
|
||||
}
|
||||
|
||||
let timePart = `${ampm} ${hours}시`;
|
||||
if (minutes !== 0) {
|
||||
timePart += ` ${minutes}분`;
|
||||
}
|
||||
|
||||
return `${datePart} ${timePart}`;
|
||||
};
|
||||
|
||||
// --- Cancellation View Component ---
|
||||
const CancellationView: React.FC<{
|
||||
reservation: ReservationDetailV2;
|
||||
onCancelSubmit: (reason: string) => void;
|
||||
onBack: () => void;
|
||||
isCancelling: boolean;
|
||||
}> = ({ reservation, onCancelSubmit, onBack, isCancelling }) => {
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!reason.trim()) {
|
||||
alert('취소 사유를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
onCancelSubmit(reason);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cancellation-view-v2">
|
||||
<h3>취소 정보 확인</h3>
|
||||
<div className="cancellation-summary-v2">
|
||||
<p><strong>테마:</strong> {reservation.memberName}</p>
|
||||
<p><strong>신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||
<p><strong>결제 금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>
|
||||
</div>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="취소 사유를 입력해주세요."
|
||||
className="cancellation-reason-textarea-v2"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="modal-actions-v2">
|
||||
<button onClick={onBack} className="back-button-v2" disabled={isCancelling}>뒤로가기</button>
|
||||
<button onClick={handleSubmit} className="cancel-submit-button-v2" disabled={isCancelling}>
|
||||
{isCancelling ? '취소 중...' : '취소 요청'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- Reservation Detail View Component ---
|
||||
const ReservationDetailView: React.FC<{
|
||||
reservation: ReservationDetailV2;
|
||||
onGoToCancel: () => void;
|
||||
}> = ({ reservation, onGoToCancel }) => {
|
||||
|
||||
const renderPaymentDetails = (payment: PaymentV2) => {
|
||||
const { detail } = payment;
|
||||
|
||||
switch (detail.type) {
|
||||
case 'CARD':
|
||||
return (
|
||||
<>
|
||||
<p><strong>결제 금액:</strong> {payment.totalAmount.toLocaleString()}원</p>
|
||||
<p><strong>결제 수단:</strong> {detail.easypayProviderCode ? `간편결제 (${detail.easypayProviderCode})` : '카드'}</p>
|
||||
<p><strong>카드 정보:</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
|
||||
<p><strong>카드 번호:</strong> {detail.cardNumber}</p>
|
||||
<p><strong>할부 정보:</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
|
||||
<p><strong>승인 번호:</strong> {detail.approvalNumber}</p>
|
||||
</>
|
||||
);
|
||||
case 'BANK_TRANSFER':
|
||||
return (
|
||||
<>
|
||||
<p><strong>결제 수단:</strong> 계좌이체</p>
|
||||
<p><strong>은행:</strong> {detail.bankCode}</p>
|
||||
</>
|
||||
);
|
||||
case 'EASYPAY_PREPAID':
|
||||
return (
|
||||
<>
|
||||
<p><strong>결제 수단:</strong> 간편결제 / {detail.easypayProviderCode}</p>
|
||||
<p><strong>총 금액 :</strong> {payment.totalAmount.toLocaleString()}원</p>
|
||||
<p><strong>결제 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
||||
{detail.discountAmount > 0 && <p><strong>포인트:</strong> {detail.discountAmount.toLocaleString()}원</p>}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <p><strong>결제 수단:</strong> {payment.method}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-section-v2">
|
||||
<h3>예약 정보</h3>
|
||||
<p><strong>예약자명:</strong> {reservation.memberName}</p>
|
||||
<p><strong>신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||
</div>
|
||||
<div className="modal-section-v2">
|
||||
<h3>결제 정보</h3>
|
||||
{/* <p><strong>결제금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p> */}
|
||||
{renderPaymentDetails(reservation.payment)}
|
||||
<p><strong>승인일시:</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
|
||||
</div>
|
||||
{reservation.cancellation && (
|
||||
<div className="modal-section-v2 cancellation-section-v2">
|
||||
<h3>취소 정보</h3>
|
||||
<p><strong>취소 요청일시:</strong> {formatDisplayDateTime(reservation.cancellation.cancellationRequestedAt)}</p>
|
||||
<p><strong>취소 승인일시:</strong> {formatDisplayDateTime(reservation.cancellation.cancellationCompletedAt)}</p>
|
||||
<p><strong>취소 사유:</strong> {reservation.cancellation.cancelReason}</p>
|
||||
</div>
|
||||
)}
|
||||
{reservation.payment.status !== 'CANCELED' && (
|
||||
<div className="modal-actions-v2">
|
||||
<button onClick={onGoToCancel} className="cancel-button-v2">예약 취소하기</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main Page Component ---
|
||||
const MyReservationPageV2: React.FC = () => {
|
||||
const [reservations, setReservations] = useState<ReservationSummaryV2[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedReservation, setSelectedReservation] = useState<ReservationDetailV2 | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail');
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const loadReservations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await fetchMyReservationsV2();
|
||||
setReservations(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('예약 목록을 불러오는 데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadReservations();
|
||||
}, []);
|
||||
|
||||
const handleShowDetail = async (id: number) => {
|
||||
try {
|
||||
setIsDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setModalView('detail');
|
||||
const detailData = await fetchReservationDetailV2(id);
|
||||
setSelectedReservation(detailData);
|
||||
setIsModalOpen(true);
|
||||
} catch (err) {
|
||||
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedReservation(null);
|
||||
};
|
||||
|
||||
const handleCancelSubmit = async (reason: string) => {
|
||||
if (!selectedReservation) return;
|
||||
|
||||
if (!window.confirm('정말 취소하시겠어요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCancelling(true);
|
||||
setDetailError(null);
|
||||
await cancelReservationV2(selectedReservation.id, reason);
|
||||
alert('예약이 성공적으로 취소되었습니다.');
|
||||
handleCloseModal();
|
||||
loadReservations(); // Refresh the list
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-reservation-container-v2">
|
||||
<h1>내 예약 V2</h1>
|
||||
|
||||
{isLoading && <p>목록 로딩 중...</p>}
|
||||
{error && <p className="error-message-v2">{error}</p>}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="reservation-list-v2">
|
||||
{reservations.map((res) => (
|
||||
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toLowerCase()}`}>
|
||||
<div className="summary-details-v2">
|
||||
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
|
||||
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.time)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleShowDetail(res.id)}
|
||||
disabled={isDetailLoading}
|
||||
className="detail-button-v2"
|
||||
>
|
||||
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && selectedReservation && (
|
||||
<div className="modal-overlay-v2" onClick={handleCloseModal}>
|
||||
<div className="modal-content-v2" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close-button-v2" onClick={handleCloseModal}>×</button>
|
||||
<h2>{modalView === 'detail' ? '예약 상세 정보' : '예약 취소'}</h2>
|
||||
{detailError && <p className="error-message-v2">{detailError}</p>}
|
||||
|
||||
{modalView === 'detail' ? (
|
||||
<ReservationDetailView
|
||||
reservation={selectedReservation}
|
||||
onGoToCancel={() => setModalView('cancel')}
|
||||
/>
|
||||
) : (
|
||||
<CancellationView
|
||||
reservation={selectedReservation}
|
||||
onCancelSubmit={handleCancelSubmit}
|
||||
onBack={() => setModalView('detail')}
|
||||
isCancelling={isCancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyReservationPageV2;
|
||||
156
frontend/src/pages/v2/ReservationStep1Page.tsx
Normal file
156
frontend/src/pages/v2/ReservationStep1Page.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Flatpickr from 'react-flatpickr';
|
||||
import 'flatpickr/dist/flatpickr.min.css';
|
||||
import '@_css/reservation-v2.css';
|
||||
import { fetchThemes } from '@_api/theme/themeAPI';
|
||||
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
|
||||
import { createPendingReservation } from '@_api/reservation/reservationAPI';
|
||||
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
|
||||
const ReservationStep1Page: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
||||
const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeRetrieveResponse | null>(null);
|
||||
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
|
||||
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
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(() => {
|
||||
fetchThemes().then(res => setThemes(res.themes)).catch(handleError);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && selectedTheme) {
|
||||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||||
fetchTimesWithAvailability(dateStr, selectedTheme.id)
|
||||
.then(res => {
|
||||
setTimes(res.times);
|
||||
setSelectedTime(null);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
}, [selectedDate, selectedTheme]);
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) {
|
||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTime.isAvailable) {
|
||||
alert('예약할 수 없는 시간입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmPayment = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) return;
|
||||
|
||||
const reservationData = {
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
themeId: selectedTheme.id,
|
||||
timeId: selectedTime.id,
|
||||
};
|
||||
|
||||
createPendingReservation(reservationData)
|
||||
.then((res) => {
|
||||
navigate('/v2/reservation/payment', { state: { reservation: res } });
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setIsModalOpen(false));
|
||||
};
|
||||
|
||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
|
||||
|
||||
return (
|
||||
<div className="content-container col-md-10 offset-md-1 p-5">
|
||||
<h2 className="content-container-title">방탈출 예약</h2>
|
||||
<div className="d-flex" id="reservation-container">
|
||||
<div className="section border rounded col-md-4 p-3" id="date-section">
|
||||
<h3 className="fs-5 text-center mb-3">날짜 선택</h3>
|
||||
<div className="d-flex justify-content-center">
|
||||
<Flatpickr
|
||||
value={selectedDate || undefined}
|
||||
onChange={([date]) => setSelectedDate(date)}
|
||||
options={{ inline: true, defaultDate: new Date(), minDate: "today" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`section border rounded col-md-4 p-3 ${!selectedDate ? 'disabled' : ''}`} id="theme-section">
|
||||
<h3 className="fs-5 text-center mb-3">테마 선택</h3>
|
||||
<div className="p-3" id="theme-slots">
|
||||
{themes.map(theme => (
|
||||
<div key={theme.id}
|
||||
className={`theme-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTheme?.id === theme.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTheme(theme)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`section border rounded col-md-4 p-3 ${!selectedTheme ? 'disabled' : ''}`} id="time-section">
|
||||
<h3 className="fs-5 text-center mb-3">시간 선택</h3>
|
||||
<div className="p-3" id="time-slots">
|
||||
{times.length > 0 ? times.map(time => (
|
||||
<div key={time.id}
|
||||
className={`time-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`}
|
||||
onClick={() => time.isAvailable && setSelectedTime(time)}>
|
||||
{time.startAt} {!time.isAvailable && '(예약불가)'}
|
||||
</div>
|
||||
)) : <div className="no-times">선택할 수 있는 시간이 없습니다.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="button-group float-end mt-3">
|
||||
<button className="btn btn-primary" disabled={isButtonDisabled} onClick={handleNextStep}>
|
||||
결제하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">예약 정보를 확인해주세요</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setIsModalOpen(false)}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p><strong>날짜:</strong> {selectedDate?.toLocaleDateString('ko-KR')}</p>
|
||||
<p><strong>테마:</strong> {selectedTheme?.name}</p>
|
||||
<p><strong>시간:</strong> {selectedTime?.startAt}</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setIsModalOpen(false)}>취소</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleConfirmPayment}>결제하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationStep1Page;
|
||||
118
frontend/src/pages/v2/ReservationStep2Page.tsx
Normal file
118
frontend/src/pages/v2/ReservationStep2Page.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
|
||||
import '@_css/reservation-v2.css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PaymentWidget: any;
|
||||
}
|
||||
}
|
||||
|
||||
const ReservationStep2Page: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const paymentWidgetRef = useRef<any>(null);
|
||||
const paymentMethodsRef = useRef<any>(null);
|
||||
|
||||
const reservation: ReservationCreateResponse | undefined = location.state?.reservation;
|
||||
|
||||
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 (!reservation) {
|
||||
alert('잘못된 접근입니다.');
|
||||
navigate('/v2/reservation');
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://js.tosspayments.com/v1/payment-widget';
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
|
||||
const paymentWidget = window.PaymentWidget(widgetClientKey, window.PaymentWidget.ANONYMOUS);
|
||||
paymentWidgetRef.current = paymentWidget;
|
||||
|
||||
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||
"#payment-method",
|
||||
{ value: 1000 }, // TODO: 테마별 가격 적용
|
||||
{ variantKey: "DEFAULT" }
|
||||
);
|
||||
paymentMethodsRef.current = paymentMethods;
|
||||
};
|
||||
}, [reservation, navigate]);
|
||||
|
||||
const handlePayment = () => {
|
||||
if (!paymentWidgetRef.current || !reservation) {
|
||||
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const generateRandomString = () =>
|
||||
crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
paymentWidgetRef.current.requestPayment({
|
||||
orderId: generateRandomString(),
|
||||
orderName: "테스트 방탈출 예약 결제 1건",
|
||||
amount: 1000,
|
||||
}).then((data: any) => {
|
||||
const paymentData: ReservationPaymentRequest = {
|
||||
paymentKey: data.paymentKey,
|
||||
orderId: data.orderId,
|
||||
amount: data.amount,
|
||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||
};
|
||||
confirmReservationPayment(reservation.reservationId, paymentData)
|
||||
.then((res) => {
|
||||
navigate('/v2/reservation/success', {
|
||||
state: {
|
||||
reservation: res,
|
||||
themeName: reservation.themeName,
|
||||
date: reservation.date,
|
||||
startAt: reservation.startAt,
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(handleError);
|
||||
}).catch((error: any) => {
|
||||
console.error("Payment request error:", error);
|
||||
alert("결제 요청 중 오류가 발생했습니다.");
|
||||
});
|
||||
};
|
||||
|
||||
if (!reservation) {
|
||||
return null; // or a loading spinner
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-container col-md-10 offset-md-1 p-5">
|
||||
<div className="wrapper w-100">
|
||||
<div className="max-w-540 w-100">
|
||||
<div id="payment-method" className="w-100"></div>
|
||||
<div id="agreement" className="w-100"></div>
|
||||
<div className="btn-wrapper w-100 mt-3">
|
||||
<button onClick={handlePayment} className="btn btn-primary w-100">
|
||||
1,000원 결제하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationStep2Page;
|
||||
44
frontend/src/pages/v2/ReservationSuccessPage.tsx
Normal file
44
frontend/src/pages/v2/ReservationSuccessPage.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import type { ReservationPaymentResponse } from '@_api/reservation/reservationTypes';
|
||||
import '@_css/reservation-v2.css';
|
||||
|
||||
const ReservationSuccessPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { reservation, themeName, date, startAt } = (location.state as {
|
||||
reservation: ReservationPaymentResponse;
|
||||
themeName: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
}) || {};
|
||||
|
||||
if (!reservation) {
|
||||
React.useEffect(() => {
|
||||
navigate('/');
|
||||
}, [navigate]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reservation-success-page">
|
||||
<h2 className="content-container-title">예약이 확정되었습니다!</h2>
|
||||
<div className="reservation-info-box">
|
||||
<h3>최종 예약 정보</h3>
|
||||
<div className="info-item"><strong>테마:</strong> <span>{themeName}</span></div>
|
||||
<div className="info-item"><strong>날짜:</strong> <span>{date}</span></div>
|
||||
<div className="info-item"><strong>시간:</strong> <span>{startAt}</span></div>
|
||||
</div>
|
||||
<div className="success-page-actions">
|
||||
<Link to="/my-reservation" className="btn btn-secondary">
|
||||
내 예약 목록
|
||||
</Link>
|
||||
<Link to="/" className="btn btn-secondary">
|
||||
메인 페이지로 이동
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationSuccessPage;
|
||||
Loading…
x
Reference in New Issue
Block a user