From 9f1b13a1ea1c358fcfbf8f146221e633cad29700 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 22 Aug 2025 15:46:59 +0900 Subject: [PATCH 01/13] =?UTF-8?q?refactor:=20MemberId=20MDC=20=ED=82=A4=20?= =?UTF-8?q?=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/auth/web/support/AuthInterceptor.kt | 6 ++++-- .../kotlin/roomescape/common/log/ApiLogMessageConverter.kt | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt index a4f3742b..6901c384 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt @@ -16,6 +16,8 @@ import roomescape.member.infrastructure.persistence.MemberEntity private val log: KLogger = KotlinLogging.logger {} +const val MDC_MEMBER_ID_KEY: String = "member_id" + @Component class AuthInterceptor( private val memberFinder: MemberFinder, @@ -42,7 +44,7 @@ class AuthInterceptor( throw AuthException(AuthErrorCode.ACCESS_DENIED) } - MDC.put("member_id", "${member.id}") + MDC.put(MDC_MEMBER_ID_KEY, "${member.id}") log.info { "[AuthInterceptor] 인증 완료. memberId=${member.id}, role=${member.role}" } return true } @@ -51,7 +53,7 @@ class AuthInterceptor( try { val memberId = jwtHandler.getMemberIdFromToken(accessToken) return memberFinder.findById(memberId) - .also { MDC.put("member_id", "$memberId") } + .also { MDC.put(MDC_MEMBER_ID_KEY, "$memberId") } } catch (e: Exception) { log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = $accessToken" } val errorCode = AuthErrorCode.MEMBER_NOT_FOUND diff --git a/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt index 7cf16685..ff3cec66 100644 --- a/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt +++ b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt @@ -3,6 +3,7 @@ package roomescape.common.log import com.fasterxml.jackson.databind.ObjectMapper import jakarta.servlet.http.HttpServletRequest import org.slf4j.MDC +import roomescape.auth.web.support.MDC_MEMBER_ID_KEY enum class LogType { INCOMING_HTTP_REQUEST, @@ -33,7 +34,7 @@ class ApiLogMessageConverter( controllerPayload: Map, ): String { val payload: MutableMap = commonRequestPayload(LogType.CONTROLLER_INVOKED, request) - val memberId: Long? = MDC.get("member_id")?.toLong() + val memberId: Long? = MDC.get(MDC_MEMBER_ID_KEY)?.toLong() if (memberId != null) payload["member_id"] = memberId else payload["member_id"] = "NONE" payload.putAll(controllerPayload) @@ -46,7 +47,7 @@ class ApiLogMessageConverter( payload["type"] = request.type payload["status_code"] = request.httpStatus - MDC.get("member_id")?.toLongOrNull() + MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull() ?.let { payload["member_id"] = it } ?: run { payload["member_id"] = "NONE" } -- 2.47.2 From 87ad7e9df8fe8169da77bc14d28c1ce89b622019 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 27 Aug 2025 16:08:46 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EB=B0=98=EC=98=81=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 8 + frontend/src/css/reservation-v2-1.css | 349 ++++++++++++++++++ .../src/pages/v2/ReservationStep1PageV21.tsx | 307 +++++++++++++++ .../src/pages/v2/ReservationStep2PageV21.tsx | 132 +++++++ .../pages/v2/ReservationSuccessPageV21.tsx | 48 +++ frontend/src/util/DateTimeFormatter.ts | 35 ++ 6 files changed, 879 insertions(+) create mode 100644 frontend/src/css/reservation-v2-1.css create mode 100644 frontend/src/pages/v2/ReservationStep1PageV21.tsx create mode 100644 frontend/src/pages/v2/ReservationStep2PageV21.tsx create mode 100644 frontend/src/pages/v2/ReservationSuccessPageV21.tsx create mode 100644 frontend/src/util/DateTimeFormatter.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d31faf7f..62b831d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,9 @@ import ReservationStep1Page from './pages/v2/ReservationStep1Page'; import ReservationStep2Page from './pages/v2/ReservationStep2Page'; import ReservationSuccessPage from './pages/v2/ReservationSuccessPage'; 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 = () => ( @@ -54,6 +57,11 @@ function App() { } /> } /> } /> + + {/* V2.1 Reservation Flow */} + } /> + } /> + } /> } /> diff --git a/frontend/src/css/reservation-v2-1.css b/frontend/src/css/reservation-v2-1.css new file mode 100644 index 00000000..a68804d4 --- /dev/null +++ b/frontend/src/css/reservation-v2-1.css @@ -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; +} diff --git a/frontend/src/pages/v2/ReservationStep1PageV21.tsx b/frontend/src/pages/v2/ReservationStep1PageV21.tsx new file mode 100644 index 00000000..cd4cacfa --- /dev/null +++ b/frontend/src/pages/v2/ReservationStep1PageV21.tsx @@ -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(new Date()); + const [themes, setThemes] = useState([]); + const [selectedTheme, setSelectedTheme] = useState(null); + const [times, setTimes] = useState([]); + const [selectedTime, setSelectedTime] = useState(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((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 ( +
setSelectedDate(date)} + > +
{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}
+
{date.getDate()}
+
+ ); + }); + }; + + const openThemeModal = (theme: ThemeV21) => { + setSelectedTheme(theme); + setIsThemeModalOpen(true); + }; + + const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable; + + return ( +
+

예약하기

+ +
+

1. 날짜 선택

+
{renderDateOptions()}
+
+ +
+

2. 테마 선택

+
+ {themes.map(theme => ( +
setSelectedTheme(theme)} + > +
+

{theme.name}

+
+

난이도: {theme.difficulty}

+

참여 인원: {theme.minParticipants} ~ {theme.maxParticipants}명

+

가격: {theme.price.toLocaleString()}원

+

예상 소요 시간: {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분

+

이용 가능 시간: {theme.availableMinutes}분

+
+ +
+
+ ))} +
+
+ +
+

3. 시간 선택

+
+ {times.length > 0 ? times.map(time => ( +
time.isAvailable && setSelectedTime(time)} + > + {time.startAt} + {time.isAvailable ? '예약가능' : '예약불가'} +
+ )) :
선택 가능한 시간이 없습니다.
} +
+
+ +
+ +
+ + {isThemeModalOpen && selectedTheme && ( +
setIsThemeModalOpen(false)}> +
e.stopPropagation()}> + + {selectedTheme.name} +

{selectedTheme.name}

+
+

테마 정보

+

난이도: {selectedTheme.difficulty}

+

참여 인원: {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명

+

소요 시간: {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분

+

가격: {selectedTheme.price.toLocaleString()}원

+
+
+

소개

+

{selectedTheme.description}

+
+
+
+ )} + + {isConfirmModalOpen && ( +
setIsConfirmModalOpen(false)}> +
e.stopPropagation()}> + +

예약 정보를 확인해주세요

+
+

날짜: {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}

+

테마: {selectedTheme!!.name}

+

시간: {formatTime(selectedTime!!.startAt)}

+

결제금액: {selectedTheme!!.price.toLocaleString()}원

+
+
+ + +
+
+
+ )} +
+ ); +}; + +export default ReservationStep1PageV21; diff --git a/frontend/src/pages/v2/ReservationStep2PageV21.tsx b/frontend/src/pages/v2/ReservationStep2PageV21.tsx new file mode 100644 index 00000000..bcb43428 --- /dev/null +++ b/frontend/src/pages/v2/ReservationStep2PageV21.tsx @@ -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(null); + const paymentMethodsRef = useRef(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 ( +
+

결제하기

+
+

결제 정보 확인

+

테마: {reservation.themeName}

+

날짜: {date}

+

시간: {time}

+

금액: {reservation.price.toLocaleString()}원

+
+
+

결제 수단

+
+
+
+
+ +
+
+ ); +}; + +export default ReservationStep2PageV21; diff --git a/frontend/src/pages/v2/ReservationSuccessPageV21.tsx b/frontend/src/pages/v2/ReservationSuccessPageV21.tsx new file mode 100644 index 00000000..4694ff65 --- /dev/null +++ b/frontend/src/pages/v2/ReservationSuccessPageV21.tsx @@ -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 ( +
+
+

예약이 확정되었습니다!

+
+

최종 예약 정보

+

테마: {themeName}

+

날짜: {formattedDate}

+

시간: {formattedTime}

+
+
+ + 내 예약 목록 + + + 메인으로 가기 + +
+
+ ); +}; + +export default ReservationSuccessPageV21; diff --git a/frontend/src/util/DateTimeFormatter.ts b/frontend/src/util/DateTimeFormatter.ts new file mode 100644 index 00000000..899b3983 --- /dev/null +++ b/frontend/src/util/DateTimeFormatter.ts @@ -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; +} \ No newline at end of file -- 2.47.2 From aad8d3a4ff1f1383e2ae77b9e8f1277404341f87 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 27 Aug 2025 16:15:56 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=A0=20Auditing=20+=20PK=20BaseEntity=20=EB=B0=8F?= =?UTF-8?q?=20JPA=20AuditorAwareConfig=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/common/config/JpaConfig.kt | 25 +++++++++ .../roomescape/common/entity/BaseEntityV2.kt | 55 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/main/kotlin/roomescape/common/config/JpaConfig.kt create mode 100644 src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt diff --git a/src/main/kotlin/roomescape/common/config/JpaConfig.kt b/src/main/kotlin/roomescape/common/config/JpaConfig.kt new file mode 100644 index 00000000..72f2620e --- /dev/null +++ b/src/main/kotlin/roomescape/common/config/JpaConfig.kt @@ -0,0 +1,25 @@ +package roomescape.common.config + +import org.slf4j.MDC +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.domain.AuditorAware +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import roomescape.auth.web.support.MDC_MEMBER_ID_KEY +import java.util.* + +@Configuration +@EnableJpaAuditing +class JpaConfig { + + @Bean + fun auditorAware(): AuditorAware = MdcAuditorAware() +} + +class MdcAuditorAware : AuditorAware { + override fun getCurrentAuditor(): Optional { + val memberIdStr = MDC.get(MDC_MEMBER_ID_KEY) + + return Optional.ofNullable(memberIdStr.toLongOrNull()) + } +} diff --git a/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt b/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt new file mode 100644 index 00000000..f0f94700 --- /dev/null +++ b/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt @@ -0,0 +1,55 @@ +package roomescape.common.entity + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.Id +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.PostLoad +import jakarta.persistence.PrePersist +import org.springframework.data.annotation.CreatedBy +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedBy +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.domain.Persistable +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class AuditingBaseEntity( + @Id + @Column(name = "id") + private val _id: Long, + + @Transient + private var isNewEntity: Boolean = true +) : Persistable { + @Column(updatable = false) + @CreatedDate + lateinit var createdAt: LocalDateTime + protected set + + @Column(updatable = false) + @CreatedBy + var createdBy: Long = 0L + protected set + + @Column + @LastModifiedDate + lateinit var updatedAt: LocalDateTime + protected set + + @Column + @LastModifiedBy + var updatedBy: Long = 0L + protected set + + @PostLoad + @PrePersist + fun markNotNew() { + isNewEntity = false + } + + override fun getId(): Long = _id + override fun isNew(): Boolean = isNewEntity +} -- 2.47.2 From 5ed632b1b321815b0adf5d63df081bf5977bd8bf Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 27 Aug 2025 16:16:37 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=ED=85=8C=EB=A7=88=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=9E=AC=EC=A0=95=EC=9D=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/RoomescapeApplication.kt | 2 -- src/main/resources/schema/schema-h2.sql | 21 +++++++++++++++++++ src/main/resources/schema/schema-mysql.sql | 21 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/roomescape/RoomescapeApplication.kt b/src/main/kotlin/roomescape/RoomescapeApplication.kt index be15c160..aca20d20 100644 --- a/src/main/kotlin/roomescape/RoomescapeApplication.kt +++ b/src/main/kotlin/roomescape/RoomescapeApplication.kt @@ -3,9 +3,7 @@ package roomescape import org.springframework.boot.Banner import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.data.jpa.repository.config.EnableJpaAuditing -@EnableJpaAuditing @SpringBootApplication class RoomescapeApplication diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 05b334c2..a9c09b07 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -27,6 +27,27 @@ create table if not exists themes ( last_modified_at timestamp ); +create table if not exists theme ( + id bigint primary key , + name varchar(30) not null, + difficulty varchar(20) not null, + description varchar(255) not null, + thumbnail_url varchar(255) not null, + price int not null, + min_participants smallint not null, + max_participants smallint not null, + available_minutes smallint not null, + expected_minutes_from smallint not null, + expected_minutes_to smallint not null, + created_at timestamp not null, + created_by bigint not null, + updated_at timestamp not null, + updated_by bigint not null, + + constraint fk_theme__created_by foreign key (created_by) references members (member_id), + constraint fk_theme__updated_by foreign key (updated_by) references members (member_id) +); + create table if not exists times ( time_id bigint primary key, start_at time not null, diff --git a/src/main/resources/schema/schema-mysql.sql b/src/main/resources/schema/schema-mysql.sql index 4ebb1079..d3b60713 100644 --- a/src/main/resources/schema/schema-mysql.sql +++ b/src/main/resources/schema/schema-mysql.sql @@ -29,6 +29,27 @@ create table if not exists themes last_modified_at datetime(6) null ); +create table if not exists theme ( + id bigint primary key , + name varchar(30) not null, + difficulty varchar(20) not null, + description varchar(255) not null, + thumbnail_url varchar(255) not null, + price int not null, + min_participants smallint not null, + max_participants smallint not null, + available_minutes smallint not null, + expected_minutes_from smallint not null, + expected_minutes_to smallint not null, + created_at datetime(6) not null, + created_by bigint not null, + updated_at datetime(6) not null, + updated_by bigint not null, + + constraint fk_theme__created_by foreign key (created_by) references members (member_id), + constraint fk_theme__updated_by foreign key (updated_by) references members (member_id) +); + create table if not exists times ( time_id bigint primary key, -- 2.47.2 From 6e99917a344aa1aa4ca1f3ba638e1b795eb3940e Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 3 Sep 2025 10:25:16 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=A0=84=EC=B2=B4=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=ED=85=8C=EB=A7=88=20API=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 10 + frontend/src/api/apiClient.ts | 14 +- frontend/src/api/theme/themeAPI.ts | 36 ++- frontend/src/api/theme/themeTypes.ts | 110 ++++++++ frontend/src/components/Navbar.tsx | 57 ++-- frontend/src/css/admin-page.css | 17 ++ frontend/src/css/admin-reservation-page.css | 160 +++++++++++ frontend/src/css/admin-theme-edit-page.css | 236 ++++++++++++++++ frontend/src/css/admin-theme-page.css | 121 ++++++++ frontend/src/css/admin-time-page.css | 120 ++++++++ frontend/src/css/admin-waiting-page.css | 81 ++++++ frontend/src/css/home-page-v2.css | 66 +++++ frontend/src/css/login-page-v2.css | 74 +++++ frontend/src/css/navbar.css | 117 ++++++++ frontend/src/css/signup-page-v2.css | 65 +++++ frontend/src/pages/LoginPage.tsx | 4 +- frontend/src/pages/admin/AdminNavbar.tsx | 59 ++-- frontend/src/pages/admin/AdminPage.tsx | 7 +- .../src/pages/admin/AdminThemeEditPage.tsx | 266 ++++++++++++++++++ frontend/src/pages/admin/ReservationPage.tsx | 142 +++++----- frontend/src/pages/admin/ThemePage.tsx | 123 ++++---- frontend/src/pages/admin/TimePage.tsx | 74 ++--- frontend/src/pages/admin/WaitingPage.tsx | 64 +++-- frontend/src/pages/v2/HomePageV2.tsx | 39 +++ frontend/src/pages/v2/LoginPageV2.tsx | 63 +++++ .../src/pages/v2/ReservationStep1PageV21.tsx | 126 +++------ .../src/pages/v2/ReservationStep2PageV21.tsx | 10 +- frontend/src/pages/v2/SignupPageV2.tsx | 70 +++++ src/main/resources/login.http | 55 ++++ 29 files changed, 1993 insertions(+), 393 deletions(-) create mode 100644 frontend/src/css/admin-page.css create mode 100644 frontend/src/css/admin-reservation-page.css create mode 100644 frontend/src/css/admin-theme-edit-page.css create mode 100644 frontend/src/css/admin-theme-page.css create mode 100644 frontend/src/css/admin-time-page.css create mode 100644 frontend/src/css/admin-waiting-page.css create mode 100644 frontend/src/css/home-page-v2.css create mode 100644 frontend/src/css/login-page-v2.css create mode 100644 frontend/src/css/navbar.css create mode 100644 frontend/src/css/signup-page-v2.css create mode 100644 frontend/src/pages/admin/AdminThemeEditPage.tsx create mode 100644 frontend/src/pages/v2/HomePageV2.tsx create mode 100644 frontend/src/pages/v2/LoginPageV2.tsx create mode 100644 frontend/src/pages/v2/SignupPageV2.tsx create mode 100644 src/main/resources/login.http diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62b831d0..55f77c3e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,10 @@ import MyReservationPageV2 from './pages/v2/MyReservationPageV2'; import ReservationStep1PageV21 from './pages/v2/ReservationStep1PageV21'; import ReservationStep2PageV21 from './pages/v2/ReservationStep2PageV21'; import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21'; +import HomePageV2 from './pages/v2/HomePageV2'; +import LoginPageV2 from './pages/v2/LoginPageV2'; +import SignupPageV2 from './pages/v2/SignupPageV2'; +import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; const AdminRoutes = () => ( @@ -28,6 +32,7 @@ const AdminRoutes = () => ( } /> } /> } /> + } /> } /> @@ -53,6 +58,11 @@ function App() { } /> } /> + {/* V2 Pages */} + } /> + } /> + } /> + {/* V2 Reservation Flow */} } /> } /> diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 6e488ac3..f72d764c 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -28,16 +28,16 @@ async function request( }, }; - if (isRequiredAuth) { - const accessToken = localStorage.getItem('accessToken'); - if (accessToken) { - if (!config.headers) { - config.headers = {}; - } - config.headers['Authorization'] = `Bearer ${accessToken}`; + + const accessToken = localStorage.getItem('accessToken'); + if (accessToken) { + if (!config.headers) { + config.headers = {}; } + config.headers['Authorization'] = `Bearer ${accessToken}`; } + if (method.toUpperCase() !== 'GET') { config.data = data; } diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index 6cbe5c3d..b36ca302 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -1,5 +1,13 @@ -import apiClient from "@_api/apiClient"; -import type { ThemeCreateRequest, ThemeCreateResponse, ThemeRetrieveListResponse } from "./themeTypes"; +import apiClient from '@_api/apiClient'; +import type { + AdminThemeDetailRetrieveResponse, + AdminThemeSummaryRetrieveListResponse, + ThemeCreateRequest, + ThemeCreateRequestV2, ThemeCreateResponse, + ThemeCreateResponseV2, ThemeRetrieveListResponse, + ThemeUpdateRequest, + UserThemeRetrieveListResponse +} from './themeTypes'; export const createTheme = async (data: ThemeCreateRequest): Promise => { return await apiClient.post('/themes', data, true); @@ -16,3 +24,27 @@ export const mostReservedThemes = async (count: number = 10): Promise => { return await apiClient.del(`/themes/${id}`, true); }; + +export const fetchAdminThemes = async (): Promise => { + return await apiClient.get('/admin/themes'); +}; + +export const fetchAdminThemeDetail = async (id: string): Promise => { + return await apiClient.get(`/admin/themes/${id}`); +}; + +export const createThemeV2 = async (themeData: ThemeCreateRequestV2): Promise => { + return await apiClient.post('/admin/themes', themeData); +}; + +export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise => { + await apiClient.patch(`/admin/themes/${id}`, themeData); +}; + +export const deleteTheme = async (id: string): Promise => { + await apiClient.del(`/admin/themes/${id}`); +}; + +export const fetchUserThemes = async (): Promise => { + return await apiClient.get('/v2/themes'); +}; diff --git a/frontend/src/api/theme/themeTypes.ts b/frontend/src/api/theme/themeTypes.ts index 129f1fc1..dd24ed8e 100644 --- a/frontend/src/api/theme/themeTypes.ts +++ b/frontend/src/api/theme/themeTypes.ts @@ -21,3 +21,113 @@ export interface ThemeRetrieveResponse { export interface ThemeRetrieveListResponse { themes: ThemeRetrieveResponse[]; } + + +export interface ThemeV2 { + id: string; + name: string; + description: string; + thumbnailUrl: string; + difficulty: Difficulty; + price: number; + minParticipants: number; + maxParticipants: number; + availableMinutes: number; + expectedMinutesFrom: number; + expectedMinutesTo: number; + isOpen: boolean; + createDate: string; // Assuming ISO string format + updatedDate: string; // Assuming ISO string format + createdBy: string; + updatedBy: string; +} + +export interface ThemeCreateRequestV2 { + name: string; + description: string; + thumbnailUrl: string; + difficulty: Difficulty; + price: number; + minParticipants: number; + maxParticipants: number; + availableMinutes: number; + expectedMinutesFrom: number; + expectedMinutesTo: number; + isOpen: boolean; +} + +export interface ThemeCreateResponseV2 { + id: string; +} + +export interface ThemeUpdateRequest { + name?: string; + description?: string; + thumbnailUrl?: string; + difficulty?: Difficulty; + price?: number; + minParticipants?: number; + maxParticipants?: number; + availableMinutes?: number; + expectedMinutesFrom?: number; + expectedMinutesTo?: number; + isOpen?: boolean; +} + +export interface AdminThemeSummaryRetrieveResponse { + id: string; + name: string; + difficulty: Difficulty; + price: number; + isOpen: boolean; +} + +export interface AdminThemeSummaryRetrieveListResponse { + themes: AdminThemeSummaryRetrieveResponse[]; +} + +export interface AdminThemeDetailRetrieveResponse { + id: string; + name: string; + description: string; + thumbnailUrl: string; + difficulty: Difficulty; + price: number; + minParticipants: number; + maxParticipants: number; + availableMinutes: number; + expectedMinutesFrom: number; + expectedMinutesTo: number; + isOpen: boolean; + createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format) + createdBy: string; + updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format) + updatedBy: string; +} + +export interface UserThemeRetrieveResponse { + id: string; + name: string; + thumbnailUrl: string; + description: string; + difficulty: Difficulty; + price: number; + minParticipants: number; + maxParticipants: number; + availableMinutes: number; + expectedMinutesFrom: number; + expectedMinutesTo: number; +} + +export interface UserThemeRetrieveListResponse { + themes: UserThemeRetrieveResponse[]; +} + +// @ts-ignore +export enum Difficulty { + VERY_EASY = 'VERY_EASY', + EASY = 'EASY', + NORMAL = 'NORMAL', + HARD = 'HARD', + VERY_HARD = 'VERY_HARD', +} \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 8644022f..82168dba 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from 'src/context/AuthContext'; +import 'src/css/navbar.css'; const Navbar: React.FC = () => { const { loggedIn, userName, logout } = useAuth(); @@ -14,42 +15,34 @@ const Navbar: React.FC = () => { } catch (error) { console.error('Logout failed:', error); } - } + }; return ( -