diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355df..1afa855c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -33,10 +33,6 @@ } } -.card { - padding: 2em; -} -.read-the-docs { - color: #888; -} + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 225f6538..d31faf7f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => ( @@ -43,7 +47,13 @@ function App() { } /> } /> } /> - } /> + } /> + } /> + + {/* V2 Reservation Flow */} + } /> + } /> + } /> } /> @@ -53,4 +63,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/api/reservation/reservationAPI.ts b/frontend/src/api/reservation/reservationAPI.ts index a3f23db9..0006f0d8 100644 --- a/frontend/src/api/reservation/reservationAPI.ts +++ b/frontend/src/api/reservation/reservationAPI.ts @@ -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 => { export const rejectWaiting = async (id: string): Promise => { return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true); }; + +// POST /v2/reservations +export const createPendingReservation = async (data: ReservationCreateRequest): Promise => { + return await apiClient.post('/v2/reservations', data, true); +}; + +// POST /v2/reservations/{id}/pay +export const confirmReservationPayment = async (id: string, data: ReservationPaymentRequest): Promise => { + return await apiClient.post(`/v2/reservations/${id}/pay`, data, true); +}; \ No newline at end of file diff --git a/frontend/src/api/reservation/reservationAPIV2.ts b/frontend/src/api/reservation/reservationAPIV2.ts new file mode 100644 index 00000000..2f560898 --- /dev/null +++ b/frontend/src/api/reservation/reservationAPIV2.ts @@ -0,0 +1,150 @@ +import type { ReservationSummaryV2, ReservationDetailV2 } from './reservationTypes'; + +// --- API 호출 함수 --- + +/** + * 내 예약 목록을 가져옵니다. (V2) + */ +export const fetchMyReservationsV2 = async (): Promise => { + // 실제 API 연동 시 아래 코드로 교체 + // const response = await apiClient.get('/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 => { + // 실제 API 연동 시 아래 코드로 교체 + // const response = await apiClient.get(`/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 => { + // 실제 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); + }); +}; diff --git a/frontend/src/api/reservation/reservationTypes.ts b/frontend/src/api/reservation/reservationTypes.ts index 1ab96518..b73caf8c 100644 --- a/frontend/src/api/reservation/reservationTypes.ts +++ b/frontend/src/api/reservation/reservationTypes.ts @@ -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; +} diff --git a/frontend/src/components/ReservationCard.tsx b/frontend/src/components/ReservationCard.tsx new file mode 100644 index 00000000..cb880ceb --- /dev/null +++ b/frontend/src/components/ReservationCard.tsx @@ -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 = ({ 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 ( +
+ {isCanceled &&
취소 완료
} +
+
+

{reservation.theme}

+ {formatDateTime(reservation.date, reservation.time)} +
+ +
+ + {isExpanded && ( +
+
+ 결제수단 + {reservation.payment.method} +
+
+ 결제금액 + {reservation.payment.amount} +
+ +
+ {isCanceled ? ( + + ) : !isCancelling ? ( + + ) : ( + <> +