generated from pricelees/issue-pr-template
feat: 새로운 테마 반영 프론트엔드 페이지 추가
This commit is contained in:
parent
9f1b13a1ea
commit
87ad7e9df8
@ -17,6 +17,9 @@ import ReservationStep1Page from './pages/v2/ReservationStep1Page';
|
|||||||
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
|
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
|
||||||
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
|
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
|
||||||
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
|
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
|
||||||
|
import ReservationStep1PageV21 from './pages/v2/ReservationStep1PageV21';
|
||||||
|
import ReservationStep2PageV21 from './pages/v2/ReservationStep2PageV21';
|
||||||
|
import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21';
|
||||||
|
|
||||||
const AdminRoutes = () => (
|
const AdminRoutes = () => (
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@ -54,6 +57,11 @@ function App() {
|
|||||||
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
||||||
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
||||||
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
|
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
|
||||||
|
|
||||||
|
{/* V2.1 Reservation Flow */}
|
||||||
|
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
|
||||||
|
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
|
||||||
|
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
} />
|
} />
|
||||||
|
|||||||
349
frontend/src/css/reservation-v2-1.css
Normal file
349
frontend/src/css/reservation-v2-1.css
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
/* General Container */
|
||||||
|
.reservation-v21-container {
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 40px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
|
||||||
|
font-family: 'Toss Product Sans', sans-serif;
|
||||||
|
color: #333D4B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
color: #191F28;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step Sections */
|
||||||
|
.step-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #E5E8EB;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-section.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-section h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #191F28;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date Selector */
|
||||||
|
.date-selector {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option.active {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #3182F6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option .day-of-week {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option .day {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme List */
|
||||||
|
.theme-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #E5E8EB;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
background-color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card.active {
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-info {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-info h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-info p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6B7684;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4E5968;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-meta p {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.theme-meta strong {
|
||||||
|
color: #333D4B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-detail-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-detail-button:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time Slots */
|
||||||
|
.time-slots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot.active {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot.disabled {
|
||||||
|
background-color: #F9FAFB;
|
||||||
|
color: #B0B8C1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-availability {
|
||||||
|
font-size: 12px;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-times {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #8A94A2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Next Step Button */
|
||||||
|
.next-step-button-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-step-button {
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-step-button:disabled {
|
||||||
|
background-color: #B0B8C1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-step-button:hover:not(:disabled) {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
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 {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8A94A2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-theme-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: #191F28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #E5E8EB;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section p strong {
|
||||||
|
color: #333D4B;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .cancel-button {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
.modal-actions .cancel-button:hover {
|
||||||
|
background-color: #D1D6DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .confirm-button {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.modal-actions .confirm-button:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
307
frontend/src/pages/v2/ReservationStep1PageV21.tsx
Normal file
307
frontend/src/pages/v2/ReservationStep1PageV21.tsx
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
|
||||||
|
import '@_css/reservation-v2-1.css';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
|
// New theme type based on the provided schema
|
||||||
|
interface ThemeV21 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
difficulty: string;
|
||||||
|
description: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
expectedMinutesFrom: number;
|
||||||
|
expectedMinutesTo: number;
|
||||||
|
availableMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ReservationStep1PageV21: React.FC = () => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
|
const [themes, setThemes] = useState<ThemeV21[]>([]);
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null);
|
||||||
|
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
|
||||||
|
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null);
|
||||||
|
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||||
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = 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(() => {
|
||||||
|
const mockThemes: ThemeV21[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '우주 감옥 탈출',
|
||||||
|
difficulty: '어려움',
|
||||||
|
description: '당신은 우주에서 가장 악명 높은 감옥에 갇혔습니다. 동료들과 협력하여 감시 시스템을 뚫고 탈출하세요!',
|
||||||
|
thumbnailUrl: 'https://example.com/space-prison.jpg',
|
||||||
|
price: 28000,
|
||||||
|
minParticipants: 2,
|
||||||
|
maxParticipants: 5,
|
||||||
|
expectedMinutesFrom: 60,
|
||||||
|
expectedMinutesTo: 75,
|
||||||
|
availableMinutes: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '마법사의 서재',
|
||||||
|
difficulty: '보통',
|
||||||
|
description: '전설적인 마법사의 비밀 서재에 들어왔습니다. 숨겨진 마법 주문을 찾아 세상을 구원하세요.',
|
||||||
|
thumbnailUrl: 'https://example.com/wizard-library.jpg',
|
||||||
|
price: 25000,
|
||||||
|
minParticipants: 2,
|
||||||
|
maxParticipants: 4,
|
||||||
|
expectedMinutesFrom: 50,
|
||||||
|
expectedMinutesTo: 60,
|
||||||
|
availableMinutes: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '해적선 대탐험',
|
||||||
|
difficulty: '쉬움',
|
||||||
|
description: '전설의 해적선에 숨겨진 보물을 찾아 떠나는 모험! 가족, 친구와 함께 즐거운 시간을 보내세요.',
|
||||||
|
thumbnailUrl: 'https://example.com/pirate-ship.jpg',
|
||||||
|
price: 22000,
|
||||||
|
minParticipants: 3,
|
||||||
|
maxParticipants: 6,
|
||||||
|
expectedMinutesFrom: 45,
|
||||||
|
expectedMinutesTo: 55,
|
||||||
|
availableMinutes: 80,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMockThemes = () => {
|
||||||
|
return new Promise<{ themes: ThemeV21[] }>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({ themes: mockThemes });
|
||||||
|
}, 500); // 0.5초 딜레이
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMockThemes().then(res => setThemes(res.themes)).catch(handleError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate && selectedTheme) {
|
||||||
|
const mockTimes: TimeWithAvailabilityResponse[] = [
|
||||||
|
{ id: 't1', startAt: '10:00', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't2', startAt: '11:15', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't3', startAt: '12:30', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't4', startAt: '13:45', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't5', startAt: '15:00', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't6', startAt: '16:15', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't7', startAt: '17:30', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't8', startAt: '18:45', isAvailable: Math.random() > 0.3 },
|
||||||
|
{ id: 't9', startAt: '20:00', isAvailable: Math.random() > 0.3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMockTimes = (date: Date, themeId: string) => {
|
||||||
|
console.log(`Fetching mock times for ${date.toLocaleDateString()} and theme ${themeId}`);
|
||||||
|
return new Promise<{ times: TimeWithAvailabilityResponse[] }>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({ times: mockTimes });
|
||||||
|
}, 300); // 0.3초 딜레이
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMockTimes(selectedDate, 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;
|
||||||
|
}
|
||||||
|
setIsConfirmModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPayment = () => {
|
||||||
|
if (!selectedDate || !selectedTheme || !selectedTime) return;
|
||||||
|
|
||||||
|
const reservationData = {
|
||||||
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
|
themeId: selectedTheme.id,
|
||||||
|
timeId: selectedTime.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock createPendingReservation to include price
|
||||||
|
const mockCreatePendingReservation = (data: typeof reservationData) => {
|
||||||
|
console.log("Creating pending reservation with:", data);
|
||||||
|
return new Promise<any>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
reservationId: `res-${crypto.randomUUID()}`,
|
||||||
|
themeName: selectedTheme?.name,
|
||||||
|
date: data.date,
|
||||||
|
startAt: selectedTime?.startAt,
|
||||||
|
price: selectedTheme?.price, // Include the price in the response
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
mockCreatePendingReservation(reservationData)
|
||||||
|
.then((res) => {
|
||||||
|
navigate('/v2-1/reservation/payment', { state: { reservation: res } });
|
||||||
|
})
|
||||||
|
.catch(handleError)
|
||||||
|
.finally(() => setIsConfirmModalOpen(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDateOptions = () => {
|
||||||
|
const dates = [];
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() + i);
|
||||||
|
dates.push(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates.map(date => {
|
||||||
|
const isSelected = selectedDate.toDateString() === date.toDateString();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date.toISOString()}
|
||||||
|
className={`date-option ${isSelected ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedDate(date)}
|
||||||
|
>
|
||||||
|
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
|
||||||
|
<div className="day">{date.getDate()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openThemeModal = (theme: ThemeV21) => {
|
||||||
|
setSelectedTheme(theme);
|
||||||
|
setIsThemeModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reservation-v21-container">
|
||||||
|
<h2 className="page-title">예약하기</h2>
|
||||||
|
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>1. 날짜 선택</h3>
|
||||||
|
<div className="date-selector">{renderDateOptions()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||||||
|
<h3>2. 테마 선택</h3>
|
||||||
|
<div className="theme-list">
|
||||||
|
{themes.map(theme => (
|
||||||
|
<div
|
||||||
|
key={theme.id}
|
||||||
|
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedTheme(theme)}
|
||||||
|
>
|
||||||
|
<div className="theme-info">
|
||||||
|
<h4>{theme.name}</h4>
|
||||||
|
<div className="theme-meta">
|
||||||
|
<p><strong>난이도:</strong> {theme.difficulty}</p>
|
||||||
|
<p><strong>참여 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||||
|
<p><strong>가격:</strong> {theme.price.toLocaleString()}원</p>
|
||||||
|
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
||||||
|
<p><strong>이용 가능 시간:</strong> {theme.availableMinutes}분</p>
|
||||||
|
</div>
|
||||||
|
<button className="theme-detail-button" onClick={(e) => { e.stopPropagation(); openThemeModal(theme); }}>상세보기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
|
||||||
|
<h3>3. 시간 선택</h3>
|
||||||
|
<div className="time-slots">
|
||||||
|
{times.length > 0 ? times.map(time => (
|
||||||
|
<div
|
||||||
|
key={time.id}
|
||||||
|
className={`time-slot ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`}
|
||||||
|
onClick={() => time.isAvailable && setSelectedTime(time)}
|
||||||
|
>
|
||||||
|
{time.startAt}
|
||||||
|
<span className="time-availability">{time.isAvailable ? '예약가능' : '예약불가'}</span>
|
||||||
|
</div>
|
||||||
|
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="next-step-button-container">
|
||||||
|
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
|
||||||
|
결제하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isThemeModalOpen && selectedTheme && (
|
||||||
|
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
||||||
|
<img src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" />
|
||||||
|
<h2>{selectedTheme.name}</h2>
|
||||||
|
<div className="modal-section">
|
||||||
|
<h3>테마 정보</h3>
|
||||||
|
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||||
|
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||||
|
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||||
|
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-section">
|
||||||
|
<h3>소개</h3>
|
||||||
|
<p>{selectedTheme.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConfirmModalOpen && (
|
||||||
|
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
||||||
|
<h2>예약 정보를 확인해주세요</h2>
|
||||||
|
<div className="modal-section">
|
||||||
|
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
||||||
|
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
||||||
|
<p><strong>시간:</strong> {formatTime(selectedTime!!.startAt)}</p>
|
||||||
|
<p><strong>결제금액:</strong> {selectedTheme!!.price.toLocaleString()}원</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
<button className="confirm-button" onClick={handleConfirmPayment}>결제하기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReservationStep1PageV21;
|
||||||
132
frontend/src/pages/v2/ReservationStep2PageV21.tsx
Normal file
132
frontend/src/pages/v2/ReservationStep2PageV21.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
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-1.css'; // Reuse the new CSS for consistency
|
||||||
|
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
PaymentWidget: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This component is designed to work with the state passed from ReservationStep1PageV21
|
||||||
|
const ReservationStep2PageV21: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const paymentWidgetRef = useRef<any>(null);
|
||||||
|
const paymentMethodsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// The reservation object now contains the price
|
||||||
|
const reservation: ReservationCreateResponse & { price: number } | 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-1/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: reservation.price }, // Use the price from the reservation object
|
||||||
|
{ 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: `${reservation.themeName} 예약 결제`,
|
||||||
|
amount: reservation.price, // Use the price here as well
|
||||||
|
}).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 to the new success page
|
||||||
|
navigate('/v2-1/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = formatDate(reservation.date)
|
||||||
|
const time = formatTime(reservation.startAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reservation-v21-container">
|
||||||
|
<h2 className="page-title">결제하기</h2>
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>결제 정보 확인</h3>
|
||||||
|
<p><strong>테마:</strong> {reservation.themeName}</p>
|
||||||
|
<p><strong>날짜:</strong> {date}</p>
|
||||||
|
<p><strong>시간:</strong> {time}</p>
|
||||||
|
<p><strong>금액:</strong> {reservation.price.toLocaleString()}원</p>
|
||||||
|
</div>
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>결제 수단</h3>
|
||||||
|
<div id="payment-method" className="w-100"></div>
|
||||||
|
<div id="agreement" className="w-100"></div>
|
||||||
|
</div>
|
||||||
|
<div className="next-step-button-container">
|
||||||
|
<button onClick={handlePayment} className="next-step-button">
|
||||||
|
{reservation.price.toLocaleString()}원 결제하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReservationStep2PageV21;
|
||||||
48
frontend/src/pages/v2/ReservationSuccessPageV21.tsx
Normal file
48
frontend/src/pages/v2/ReservationSuccessPageV21.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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 { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
|
const ReservationSuccessPageV21: 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('/v2-1/reservation'); // Redirect to the new reservation page on error
|
||||||
|
}, [navigate]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const formattedDate = formatDate(date)
|
||||||
|
const formattedTime = formatTime(startAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reservation-v21-container">
|
||||||
|
<div className="success-icon">✓</div>
|
||||||
|
<h2 className="page-title">예약이 확정되었습니다!</h2>
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>최종 예약 정보</h3>
|
||||||
|
<p><strong>테마:</strong> {themeName}</p>
|
||||||
|
<p><strong>날짜:</strong> {formattedDate}</p>
|
||||||
|
<p><strong>시간:</strong> {formattedTime}</p>
|
||||||
|
</div>
|
||||||
|
<div className="success-page-actions">
|
||||||
|
<Link to="/my-reservation/v2" className="action-button">
|
||||||
|
내 예약 목록
|
||||||
|
</Link>
|
||||||
|
<Link to="/" className="action-button secondary">
|
||||||
|
메인으로 가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReservationSuccessPageV21;
|
||||||
35
frontend/src/util/DateTimeFormatter.ts
Normal file
35
frontend/src/util/DateTimeFormatter.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
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 datePart = '';
|
||||||
|
if (currentYear === reservationYear) {
|
||||||
|
datePart = `${month}월 ${day}일(${dayOfWeek})`;
|
||||||
|
} else {
|
||||||
|
datePart = `${reservationYear}년 ${month}월 ${day}일(${dayOfWeek})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return datePart;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTime = (timeStr: string) => {
|
||||||
|
const [hourStr, minuteStr] = timeStr.split(':');
|
||||||
|
let hours = parseInt(hourStr, 10);
|
||||||
|
const minutes = parseInt(minuteStr, 10);
|
||||||
|
const ampm = hours >= 12 ? '오후' : '오전';
|
||||||
|
hours = hours % 12;
|
||||||
|
hours = hours ? hours : 12;
|
||||||
|
|
||||||
|
let timePart = `${ampm} ${hours}시`;
|
||||||
|
if (minutes !== 0) {
|
||||||
|
timePart += ` ${minutes}분`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timePart;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user