[#41] 예약 스키마 재정의 #42

Merged
pricelees merged 41 commits from refactor/#41 into main 2025-09-09 00:43:39 +00:00
20 changed files with 497 additions and 187 deletions
Showing only changes of commit 04d1510bd1 - Show all commits

View File

@ -23,6 +23,7 @@ import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21';
import HomePageV2 from './pages/v2/HomePageV2'; import HomePageV2 from './pages/v2/HomePageV2';
import LoginPageV2 from './pages/v2/LoginPageV2'; import LoginPageV2 from './pages/v2/LoginPageV2';
import SignupPageV2 from './pages/v2/SignupPageV2'; import SignupPageV2 from './pages/v2/SignupPageV2';
import ReservationFormPage from './pages/v2/ReservationFormPage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage'; import AdminSchedulePage from './pages/admin/AdminSchedulePage';
@ -72,6 +73,7 @@ function App() {
{/* V2.1 Reservation Flow */} {/* V2.1 Reservation Flow */}
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} /> <Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
<Route path="/v2/reservation/form" element={<ReservationFormPage />} />
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} /> <Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} /> <Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
</Routes> </Routes>

View File

@ -17,3 +17,9 @@ export interface SignupResponse {
id: string; id: string;
name: string; name: string;
} }
export interface MemberSummaryRetrieveResponse {
id: string;
name: string;
email: string;
}

View File

@ -0,0 +1,73 @@
export interface PaymentConfirmRequest {
paymentKey: string;
orderId: string;
amount: number;
paymentType: PaymentType;
}
export interface PaymentCancelRequest {
reservationId: string,
cancelReason: 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 interface PaymentCreateResponseV2 {
paymentId: string;
detailId: string;
}
export interface PaymentRetrieveResponse {
orderId: string;
totalAmount: number;
method: string;
status: 'DONE' | 'CANCELED';
requestedAt: string;
approvedAt: string;
detail: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
cancellation?: CanceledPaymentDetailResponse;
}
export interface CardPaymentDetail {
type: 'CARD';
issuerCode: string;
cardType: 'CREDIT' | 'CHECK' | 'GIFT';
ownerType: 'PERSONAL' | 'CORPORATE';
cardNumber: string;
amount: number;
approvalNumber: string;
installmentPlanMonths: number;
isInterestFree: boolean;
easypayProviderName?: string;
easypayDiscountAmount?: number;
}
export interface BankTransferPaymentDetail {
type: 'BANK_TRANSFER';
bankName: string;
settlementStatus: string;
}
export interface EasyPayPrepaidPaymentDetail {
type: 'EASYPAY_PREPAID';
providerName: string;
amount: number;
discountAmount: number;
}
export interface CanceledPaymentDetailResponse {
cancellationRequestedAt: string; // ISO 8601 format
cancellationApprovedAt: string; // ISO 8601 format
cancelReason: string;
canceledBy: string;
}

View File

@ -0,0 +1,10 @@
import apiClient from "@_api/apiClient";
import type { PaymentCancelRequest, PaymentConfirmRequest, PaymentCreateResponseV2 } from "./PaymentTypes";
export const confirmPayment = async (reservationId: string, request: PaymentConfirmRequest): Promise<PaymentCreateResponseV2> => {
return await apiClient.post<PaymentCreateResponseV2>(`/payments?reservationId=${reservationId}`, request);
};
export const cancelPayment = async (request: PaymentCancelRequest): Promise<void> => {
return await apiClient.post(`/payments/cancel`, request);
};

View File

@ -85,10 +85,7 @@ export const confirmReservationPayment = async (id: string, data: ReservationPay
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true); return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
}; };
// POST /v2/reservations/{id}/cancel
export const cancelReservationV2 = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason }, true);
};
// GET /v2/reservations // GET /v2/reservations
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => { export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {

View File

@ -0,0 +1,23 @@
import apiClient from '../apiClient';
import type { PendingReservationCreateRequest, PendingReservationCreateResponse, ReservationDetailRetrieveResponse, ReservationSummaryRetrieveListResponse } from './reservationTypesV2';
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
};
export const confirmReservation = async (reservationId: string): Promise<void> => {
await apiClient.patch(`/reservations/${reservationId}/confirm`, {});
};
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/v3/reservations/${id}/cancel`, { cancelReason }, true);
};
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/v2/reservations/summary');
}
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
return await apiClient.get<ReservationDetailRetrieveResponse>(`/v2/reservations/${reservationId}/detail`);
}

View File

@ -1,4 +1,5 @@
import type { MemberRetrieveResponse } from '@_api/member/memberTypes'; import type { MemberRetrieveResponse, MemberSummaryRetrieveResponse } from '@_api/member/memberTypes';
import type { PaymentRetrieveResponse, PaymentType } from '@_api/payment/PaymentTypes';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes'; import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes'; import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
@ -77,18 +78,6 @@ export interface ReservationSearchQuery {
dateTo?: 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 = { export const PaymentStatus = {
IN_PROGRESS: '결제 진행 중', IN_PROGRESS: '결제 진행 중',
DONE: '결제 완료', DONE: '결제 완료',
@ -123,7 +112,7 @@ export interface ReservationPaymentRequest {
paymentKey: string; paymentKey: string;
orderId: string; orderId: string;
amount: number; amount: number;
paymentType: PaymentType paymentType: PaymentType;
} }
export interface ReservationPaymentResponse { export interface ReservationPaymentResponse {
@ -133,75 +122,14 @@ export interface ReservationPaymentResponse {
paymentStatus: PaymentStatus; paymentStatus: PaymentStatus;
} }
export interface ReservationSummaryV2 {
id: string;
themeName: string;
date: string;
startAt: string;
status: string; // 'CONFIRMED', 'CANCELED_BY_USER', etc.
}
export interface ReservationSummaryListV2 {
reservations: ReservationSummaryV2[];
}
export interface ReservationDetailV2 { export interface ReservationDetailV2 {
id: string; id: string;
user: UserDetailV2; user: MemberSummaryRetrieveResponse;
themeName: string; themeName: string;
date: string; date: string;
startAt: string; startAt: string;
applicationDateTime: string; applicationDateTime: string;
payment: PaymentV2; payment: PaymentRetrieveResponse;
cancellation: CancellationV2 | null;
}
export interface UserDetailV2 {
id: string;
name: string;
email: string;
}
export interface PaymentV2 {
orderId: string;
totalAmount: number;
method: string;
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;
amount: number;
approvalNumber: string;
installmentPlanMonths: number;
isInterestFree: boolean;
easypayProviderName?: string;
easypayDiscountAmount?: number;
}
export interface BankTransferPaymentDetailV2 {
type: 'BANK_TRANSFER';
bankName: string;
settlementStatus: string;
}
export interface EasyPayPrepaidPaymentDetailV2 {
type: 'EASYPAY_PREPAID';
providerName: string;
amount: number;
discountAmount: number;
}
export interface CancellationV2 {
cancellationRequestedAt: string; // ISO 8601 format
cancellationApprovedAt: string; // ISO 8601 format
cancelReason: string;
canceledBy: string;
} }

View File

@ -0,0 +1,58 @@
import type { MemberSummaryRetrieveResponse } from "@_api/member/memberTypes";
import type { PaymentRetrieveResponse } from "@_api/payment/PaymentTypes";
export const ReservationStatusV2 = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
CANCELED: 'CANCELED',
FAILED: 'FAILED',
EXPIRED: 'EXPIRED'
} as const;
export type ReservationStatusV2 =
| typeof ReservationStatusV2.PENDING
| typeof ReservationStatusV2.CONFIRMED
| typeof ReservationStatusV2.CANCELED
| typeof ReservationStatusV2.FAILED
| typeof ReservationStatusV2.EXPIRED;
export interface PendingReservationCreateRequest {
scheduleId: string,
reserverName: string,
reserverContact: string,
participantCount: number,
requirement: string
}
export interface PendingReservationCreateResponse {
id: string
}
export interface ReservationSummaryRetrieveResponse {
id: string;
themeName: string;
date: string;
startAt: string;
status: ReservationStatusV2;
}
export interface ReservationSummaryRetrieveListResponse {
reservations: ReservationSummaryRetrieveResponse[];
}
export interface ReservationDetailRetrieveResponse {
id: string;
member: MemberSummaryRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}
export interface ReservationDetail {
id: string;
themeName: string;
date: string;
startAt: string;
member: MemberSummaryRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}

View File

@ -30,3 +30,7 @@ export const updateSchedule = async (id: string, request: ScheduleUpdateRequest)
export const deleteSchedule = async (id: string): Promise<void> => { export const deleteSchedule = async (id: string): Promise<void> => {
await apiClient.del(`/schedules/${id}`); await apiClient.del(`/schedules/${id}`);
}; };
export const holdSchedule = async (id: string): Promise<void> => {
await apiClient.patch(`/schedules/${id}/hold`, {});
};

View File

@ -1,6 +1,6 @@
export enum ScheduleStatus { export enum ScheduleStatus {
AVAILABLE = 'AVAILABLE', AVAILABLE = 'AVAILABLE',
PENDING = 'PENDING', HOLD = 'HOLD',
RESERVED = 'RESERVED', RESERVED = 'RESERVED',
BLOCKED = 'BLOCKED', BLOCKED = 'BLOCKED',
} }

View File

@ -418,3 +418,80 @@
.modal-actions .confirm-button:hover { .modal-actions .confirm-button:hover {
background-color: #1B64DA; background-color: #1B64DA;
} }
/* Styles for ReservationFormPage */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: bold;
margin-bottom: 8px;
color: #333;
}
.form-group input[type="text"],
.form-group input[type="tel"],
.form-group input[type="number"],
.form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 16px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.participant-control {
display: flex;
align-items: center;
}
.participant-control input {
text-align: center;
border-left: none;
border-right: none;
width: 60px;
border-radius: 0;
}
.participant-control button {
width: 44px;
height: 44px;
border: 1px solid #ccc;
background-color: #f0f0f0;
font-size: 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.participant-control button:hover:not(:disabled) {
background-color: #e0e0e0;
}
.participant-control button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
color: #aaa;
}
.participant-control button:first-of-type {
border-radius: 8px 0 0 8px;
}
.participant-control button:last-of-type {
border-radius: 0 8px 8px 0;
}

View File

@ -11,7 +11,7 @@ const getScheduleStatusText = (status: ScheduleStatus): string => {
switch (status) { switch (status) {
case ScheduleStatus.AVAILABLE: case ScheduleStatus.AVAILABLE:
return '예약 가능'; return '예약 가능';
case ScheduleStatus.PENDING: case ScheduleStatus.HOLD:
return '예약 진행 중'; return '예약 진행 중';
case ScheduleStatus.RESERVED: case ScheduleStatus.RESERVED:
return '예약 완료'; return '예약 완료';

View File

@ -193,7 +193,7 @@ const AdminThemeEditPage: React.FC = () => {
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="price"> ()</label> <label className="form-label" htmlFor="price">1 ()</label>
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} /> <input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
<div className="form-group"> <div className="form-group">

View File

@ -37,7 +37,7 @@ const AdminThemePage: React.FC = () => {
navigate('/admin/theme/edit/new'); navigate('/admin/theme/edit/new');
}; };
const handleManageClick = (themeId: number) => { const handleManageClick = (themeId: string) => {
navigate(`/admin/theme/edit/${themeId}`); navigate(`/admin/theme/edit/${themeId}`);
}; };
@ -54,7 +54,7 @@ const AdminThemePage: React.FC = () => {
<tr> <tr>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th>1 </th>
<th></th> <th></th>
<th></th> <th></th>
</tr> </tr>

View File

@ -1,10 +1,8 @@
import { cancelPayment } from '@_api/payment/paymentAPI';
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
import { cancelReservation, fetchDetailById, fetchSummaryByMember } from '@_api/reservation/reservationAPIV2';
import type { ReservationDetail, ReservationSummaryRetrieveResponse } from '@_api/reservation/reservationTypesV2';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import {
cancelReservationV2,
fetchMyReservationsV2,
fetchReservationDetailV2
} from '../../api/reservation/reservationAPI';
import type { PaymentV2, ReservationDetailV2, ReservationSummaryV2 } from '../../api/reservation/reservationTypes';
import '../../css/my-reservation-v2.css'; import '../../css/my-reservation-v2.css';
const formatDisplayDateTime = (dateTime: any): string => { const formatDisplayDateTime = (dateTime: any): string => {
@ -78,7 +76,7 @@ const formatCardDateTime = (dateStr: string, timeStr: string): string => {
// --- Cancellation View Component --- // --- Cancellation View Component ---
const CancellationView: React.FC<{ const CancellationView: React.FC<{
reservation: ReservationDetailV2; reservation: ReservationDetail;
onCancelSubmit: (reason: string) => void; onCancelSubmit: (reason: string) => void;
onBack: () => void; onBack: () => void;
isCancelling: boolean; isCancelling: boolean;
@ -119,13 +117,12 @@ const CancellationView: React.FC<{
}; };
// --- Reservation Detail View Component ---
const ReservationDetailView: React.FC<{ const ReservationDetailView: React.FC<{
reservation: ReservationDetailV2; reservation: ReservationDetail;
onGoToCancel: () => void; onGoToCancel: () => void;
}> = ({ reservation, onGoToCancel }) => { }> = ({ reservation, onGoToCancel }) => {
const renderPaymentDetails = (payment: PaymentV2) => { const renderPaymentDetails = (payment: PaymentRetrieveResponse) => {
const { detail } = payment; const { detail } = payment;
switch (detail.type) { switch (detail.type) {
@ -178,8 +175,8 @@ const ReservationDetailView: React.FC<{
<h3> </h3> <h3> </h3>
<p><strong> :</strong> {reservation.themeName}</p> <p><strong> :</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p> <p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
<p><strong> :</strong> {reservation.user.name}</p> <p><strong> :</strong> {reservation.member.name}</p>
<p><strong> :</strong> {reservation.user.email}</p> <p><strong> :</strong> {reservation.member.email}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
</div> </div>
<div className="modal-section-v2"> <div className="modal-section-v2">
@ -188,13 +185,13 @@ const ReservationDetailView: React.FC<{
{renderPaymentDetails(reservation.payment)} {renderPaymentDetails(reservation.payment)}
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
</div> </div>
{reservation.cancellation && ( {reservation.payment.cancellation && (
<div className="modal-section-v2 cancellation-section-v2"> <div className="modal-section-v2 cancellation-section-v2">
<h3> </h3> <h3> </h3>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationRequestedAt)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationRequestedAt)}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationApprovedAt)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationApprovedAt)}</p>
<p><strong> :</strong> {reservation.cancellation.cancelReason}</p> <p><strong> :</strong> {reservation.payment.cancellation.cancelReason}</p>
<p><strong> :</strong> {reservation.cancellation.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p> <p><strong> :</strong> {reservation.payment.cancellation.canceledBy == reservation.member.id ? '회원 본인' : '관리자'}</p>
</div> </div>
)} )}
{reservation.payment.status !== 'CANCELED' && ( {reservation.payment.status !== 'CANCELED' && (
@ -208,11 +205,11 @@ const ReservationDetailView: React.FC<{
// --- Main Page Component --- // --- Main Page Component ---
const MyReservationPageV2: React.FC = () => { const MyReservationPageV2: React.FC = () => {
const [reservations, setReservations] = useState<ReservationSummaryV2[]>([]); const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedReservation, setSelectedReservation] = useState<ReservationDetailV2 | null>(null); const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailLoading, setIsDetailLoading] = useState(false); const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null); const [detailError, setDetailError] = useState<string | null>(null);
@ -223,7 +220,7 @@ const MyReservationPageV2: React.FC = () => {
const loadReservations = async () => { const loadReservations = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const data = await fetchMyReservationsV2(); const data = await fetchSummaryByMember();
setReservations(data.reservations); setReservations(data.reservations);
setError(null); setError(null);
} catch (err) { } catch (err) {
@ -237,14 +234,21 @@ const MyReservationPageV2: React.FC = () => {
loadReservations(); loadReservations();
}, []); }, []);
const handleShowDetail = async (id: string) => { const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
try { try {
setIsDetailLoading(true); setIsDetailLoading(true);
setDetailError(null); setDetailError(null);
setModalView('detail'); setModalView('detail');
const detailData = await fetchReservationDetailV2(id); const detailData = await fetchDetailById(id);
console.log('상세 정보:', detailData); setSelectedReservation({
setSelectedReservation(detailData); id: detailData.id,
themeName: themeName,
date: date,
startAt: time,
member: detailData.member,
applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment
});
setIsModalOpen(true); setIsModalOpen(true);
} catch (err) { } catch (err) {
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.'); setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
@ -268,16 +272,18 @@ const MyReservationPageV2: React.FC = () => {
try { try {
setIsCancelling(true); setIsCancelling(true);
setDetailError(null); setDetailError(null);
await cancelReservationV2(selectedReservation.id, reason); await cancelReservation(selectedReservation.id, reason);
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.'); alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal(); handleCloseModal();
loadReservations(); // Refresh the list await loadReservations(); // Refresh the list
} catch (err) { } catch (err) {
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.'); setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
} finally { } finally {
setIsCancelling(false); setIsCancelling(true);
} }
}; };
console.log("reservations=", reservations);
return ( return (
<div className="my-reservation-container-v2"> <div className="my-reservation-container-v2">
@ -289,14 +295,13 @@ const MyReservationPageV2: React.FC = () => {
{!isLoading && !error && ( {!isLoading && !error && (
<div className="reservation-list-v2"> <div className="reservation-list-v2">
{reservations.map((res) => ( {reservations.map((res) => (
console.log(res), <div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toString().toLowerCase()}`}>
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toLowerCase()}`}>
<div className="summary-details-v2"> <div className="summary-details-v2">
<h3 className="summary-theme-name-v2">{res.themeName}</h3> <h3 className="summary-theme-name-v2">{res.themeName}</h3>
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p> <p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
</div> </div>
<button <button
onClick={() => handleShowDetail(res.id)} onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
disabled={isDetailLoading} disabled={isDetailLoading}
className="detail-button-v2" className="detail-button-v2"
> >

View File

@ -0,0 +1,121 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPIV2';
import '@_css/reservation-v2-1.css';
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
const ReservationFormPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { scheduleId, theme, date, time } = location.state || {};
const [reserverName, setReserverName] = useState('');
const [reserverContact, setReserverContact] = useState('');
const [participantCount, setParticipantCount] = useState(2);
const [requirement, setRequirement] = useState('');
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);
}
};
const handleCountChange = (delta: number) => {
setParticipantCount(prev => Math.max(theme.minParticipants, Math.min(theme.maxParticipants, prev + delta)));
};
const handlePayment = () => {
if (!reserverName || !reserverContact) {
alert('예약자명과 연락처를 입력해주세요.');
return;
}
const reservationData = {
scheduleId,
reserverName,
reserverContact,
participantCount,
requirement,
};
createPendingReservation(reservationData)
.then(res => {
navigate('/v2-1/reservation/payment', {
state: {
reservationId: res.id,
themeName: theme.name,
date: date,
startAt: time,
price: theme.price * participantCount,
}
});
})
.catch(handleError);
};
if (!scheduleId || !theme) {
// Handle case where state is not passed correctly
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
<p> . .</p>
<button onClick={() => navigate('/v2-1/reservation')} className="next-step-button"> </button>
</div>
);
}
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {theme.name}</p>
<p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {formatTime(time)}</p>
</div>
<div className="step-section">
<h3> </h3>
<div className="form-group">
<label htmlFor="reserverName"></label>
<input type="text" id="reserverName" value={reserverName} onChange={e => setReserverName(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="reserverContact"></label>
<input type="tel" id="reserverContact" value={reserverContact} onChange={e => setReserverContact(e.target.value)} placeholder="'-' 없이 입력"/>
</div>
<div className="form-group">
<label></label>
<div className="participant-control">
<input
type="number"
value={participantCount}
onChange={e => setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))}
min={theme.minParticipants}
max={theme.maxParticipants}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="requirement"></label>
<textarea id="requirement" value={requirement} onChange={e => setRequirement(e.target.value)} />
</div>
</div>
<div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button">
</button>
</div>
</div>
);
};
export default ReservationFormPage;

View File

@ -1,7 +1,6 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPI'; import { holdSchedule, findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI'; import { ScheduleStatus, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import { findThemesByIds } from '@_api/theme/themeAPI'; import { findThemesByIds } from '@_api/theme/themeAPI';
import { Difficulty } from '@_api/theme/themeTypes'; import { Difficulty } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
@ -68,7 +67,9 @@ const ReservationStep1PageV21: React.FC = () => {
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
findAvailableThemesByDate(dateStr) findAvailableThemesByDate(dateStr)
.then(res => { .then(res => {
console.log('Available themes response:', res);
const themeIds: string[] = res.themeIds; const themeIds: string[] = res.themeIds;
console.log('Available theme IDs:', themeIds);
if (themeIds.length > 0) { if (themeIds.length > 0) {
return findThemesByIds({ themeIds }); return findThemesByIds({ themeIds });
} else { } else {
@ -104,23 +105,26 @@ const ReservationStep1PageV21: React.FC = () => {
alert('날짜, 테마, 시간을 모두 선택해주세요.'); alert('날짜, 테마, 시간을 모두 선택해주세요.');
return; return;
} }
if (selectedSchedule.status !== 'AVAILABLE') { if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
alert('예약할 수 없는 시간입니다.'); alert('예약할 수 없는 시간입니다.');
return; return;
} }
setIsConfirmModalOpen(true); setIsConfirmModalOpen(true);
}; };
const handleConfirmPayment = () => { const handleConfirmReservation = () => {
if (!selectedDate || !selectedTheme || !selectedSchedule) return; if (!selectedSchedule) return;
const reservationData = { holdSchedule(selectedSchedule.id)
scheduleId: selectedSchedule.id, .then(() => {
}; navigate('/v2/reservation/form', {
state: {
createPendingReservation(reservationData) scheduleId: selectedSchedule.id,
.then((res) => { theme: selectedTheme,
navigate('/v2-1/reservation/payment', { state: { reservation: res } }); date: selectedDate.toLocaleDateString('en-CA'),
time: selectedSchedule.time,
}
});
}) })
.catch(handleError) .catch(handleError)
.finally(() => setIsConfirmModalOpen(false)); .finally(() => setIsConfirmModalOpen(false));
@ -198,7 +202,18 @@ const ReservationStep1PageV21: React.FC = () => {
setIsThemeModalOpen(true); setIsThemeModalOpen(true);
}; };
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE'; const getStatusText = (status: ScheduleStatus) => {
switch (status) {
case ScheduleStatus.AVAILABLE:
return '예약가능';
case ScheduleStatus.HOLD:
return '예약 진행중';
default:
return '예약불가';
}
};
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
@ -221,7 +236,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="theme-info"> <div className="theme-info">
<h4>{theme.name}</h4> <h4>{theme.name}</h4>
<div className="theme-meta"> <div className="theme-meta">
<p><strong>:</strong> {theme.price.toLocaleString()}</p> <p><strong>1 :</strong> {theme.price.toLocaleString()}</p>
<p><strong>:</strong> {getDifficultyText(theme.difficulty)}</p> <p><strong>:</strong> {getDifficultyText(theme.difficulty)}</p>
<p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p> <p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p> <p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
@ -240,11 +255,11 @@ const ReservationStep1PageV21: React.FC = () => {
{schedules.length > 0 ? schedules.map(schedule => ( {schedules.length > 0 ? schedules.map(schedule => (
<div <div
key={schedule.id} key={schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`} className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)} onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
> >
{schedule.time} {schedule.time}
<span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span> <span className="time-availability">{getStatusText(schedule.status)}</span>
</div> </div>
)) : <div className="no-times"> .</div>} )) : <div className="no-times"> .</div>}
</div> </div>
@ -252,7 +267,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="next-step-button-container"> <div className="next-step-button-container">
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}> <button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
</button> </button>
</div> </div>
@ -267,7 +282,7 @@ const ReservationStep1PageV21: React.FC = () => {
<p><strong>:</strong> {getDifficultyText(selectedTheme.difficulty)}</p> <p><strong>:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p> <p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p> <p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p>
<p><strong>:</strong> {selectedTheme.price.toLocaleString()}</p> <p><strong>1 :</strong> {selectedTheme.price.toLocaleString()}</p>
</div> </div>
<div className="modal-section"> <div className="modal-section">
<h3></h3> <h3></h3>
@ -286,11 +301,10 @@ const ReservationStep1PageV21: React.FC = () => {
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p> <p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
<p><strong>:</strong> {selectedTheme!!.name}</p> <p><strong>:</strong> {selectedTheme!!.name}</p>
<p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p> <p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p>
<p><strong>:</strong> {selectedTheme!!.price.toLocaleString()}</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>
<button className="confirm-button" onClick={handleConfirmPayment}></button> <button className="confirm-button" onClick={handleConfirmReservation}></button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,10 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
import { type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
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 { confirmReservationPayment } from '@_api/reservation/reservationAPI';
import { isLoginRequiredError } from '@_api/apiClient'; import { PaymentType } from '@_api/payment/PaymentTypes';
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
import '@_css/reservation-v2.css'; import '@_css/reservation-v2.css';
declare global { declare global {
@ -49,7 +51,7 @@ const ReservationStep2Page: React.FC = () => {
const paymentMethods = paymentWidget.renderPaymentMethods( const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method", "#payment-method",
{ value: 1000 }, // TODO: 테마별 가격 적용 { value: 1000 }, // TODO: 테마별 요금 적용
{ variantKey: "DEFAULT" } { variantKey: "DEFAULT" }
); );
paymentMethodsRef.current = paymentMethods; paymentMethodsRef.current = paymentMethods;

View File

@ -1,7 +1,8 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import { confirmReservationPayment } from '@_api/reservation/reservationAPI'; import { confirmPayment } from '@_api/payment/paymentAPI';
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes'; import { PaymentType, type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import '@_css/reservation-v2-1.css'; // Reuse the new CSS for consistency import { confirmReservation } from '@_api/reservation/reservationAPIV2';
import '@_css/reservation-v2-1.css';
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, formatTime } from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
@ -12,16 +13,14 @@ declare global {
} }
} }
// This component is designed to work with the state passed from ReservationStep1PageV21
const ReservationStep2PageV21: React.FC = () => { const ReservationStep2PageV21: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const paymentWidgetRef = useRef<any>(null); const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null); const paymentMethodsRef = useRef<any>(null);
// The reservation object now contains the price const { reservationId, themeName, date, startAt, price } = location.state || {};
const reservation: ReservationCreateResponse & { price: number } | undefined = location.state?.reservation;
console.log(reservation)
const handleError = (err: any) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.'); alert('로그인이 필요해요.');
@ -34,7 +33,7 @@ const ReservationStep2PageV21: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
if (!reservation) { if (!reservationId) {
alert('잘못된 접근입니다.'); alert('잘못된 접근입니다.');
navigate('/v2-1/reservation'); navigate('/v2-1/reservation');
return; return;
@ -52,15 +51,15 @@ const ReservationStep2PageV21: React.FC = () => {
const paymentMethods = paymentWidget.renderPaymentMethods( const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method", "#payment-method",
{ value: reservation.price }, // Use the price from the reservation object { value: price },
{ variantKey: "DEFAULT" } { variantKey: "DEFAULT" }
); );
paymentMethodsRef.current = paymentMethods; paymentMethodsRef.current = paymentMethods;
}; };
}, [reservation, navigate]); }, [reservationId, price, navigate]);
const handlePayment = () => { const handlePayment = () => {
if (!paymentWidgetRef.current || !reservation) { if (!paymentWidgetRef.current || !reservationId) {
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.'); alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
return; return;
} }
@ -70,24 +69,27 @@ const ReservationStep2PageV21: React.FC = () => {
paymentWidgetRef.current.requestPayment({ paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(), orderId: generateRandomString(),
orderName: `${reservation.themeName} 예약 결제`, orderName: `${themeName} 예약 결제`,
amount: reservation.price, amount: price,
}).then((data: any) => { }).then((data: any) => {
const paymentData: ReservationPaymentRequest = { const paymentData: PaymentConfirmRequest = {
paymentKey: data.paymentKey, paymentKey: data.paymentKey,
orderId: data.orderId, orderId: data.orderId,
amount: data.amount, amount: price, // Use the price from component state instead of widget response
paymentType: data.paymentType || PaymentType.NORMAL, paymentType: data.paymentType || PaymentType.NORMAL,
}; };
confirmReservationPayment(reservation.reservationId, paymentData)
.then((res) => { confirmPayment(reservationId, paymentData)
// Navigate to the new success page .then(() => {
return confirmReservation(reservationId);
})
.then(() => {
alert('결제가 완료되었어요!');
navigate('/v2-1/reservation/success', { navigate('/v2-1/reservation/success', {
state: { state: {
reservation: res, themeName,
themeName: reservation.themeName, date,
date: reservation.date, startAt,
startAt: reservation.startAt,
} }
}); });
}) })
@ -98,22 +100,19 @@ const ReservationStep2PageV21: React.FC = () => {
}); });
}; };
if (!reservation) { if (!reservationId) {
return null; return null;
} }
const date = formatDate(reservation.date)
const time = formatTime(reservation.startAt);
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
<h2 className="page-title"></h2> <h2 className="page-title"></h2>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {reservation.themeName}</p> <p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {date}</p> <p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {time}</p> <p><strong>:</strong> {formatTime(startAt)}</p>
<p><strong>:</strong> {reservation.price.toLocaleString()}</p> <p><strong>:</strong> {price.toLocaleString()}</p>
</div> </div>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
@ -122,7 +121,7 @@ const ReservationStep2PageV21: React.FC = () => {
</div> </div>
<div className="next-step-button-container"> <div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button"> <button onClick={handlePayment} className="next-step-button">
{reservation.price.toLocaleString()} {price.toLocaleString()}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,25 +1,16 @@
import React from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import type { ReservationPaymentResponse } from '@_api/reservation/reservationTypes';
import '@_css/reservation-v2-1.css'; // Reuse the new CSS import '@_css/reservation-v2-1.css'; // Reuse the new CSS
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
const ReservationSuccessPageV21: React.FC = () => { const ReservationSuccessPageV21: React.FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const { themeName, date, startAt } = (location.state as {
const { reservation, themeName, date, startAt } = (location.state as {
reservation: ReservationPaymentResponse;
themeName: string; themeName: string;
date: string; date: string;
startAt: string; startAt: string;
}) || {}; }) || {};
if (!reservation) {
React.useEffect(() => {
navigate('/v2-1/reservation'); // Redirect to the new reservation page on error
}, [navigate]);
return null;
}
const formattedDate = formatDate(date) const formattedDate = formatDate(date)
const formattedTime = formatTime(startAt); const formattedTime = formatTime(startAt);