generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #34 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 회원 테이블과 관리자 테이블 분리 및 관리자 계정의 예약 기능 제거 - API 인증을 모두(Public) / 회원 전용(UserOnly) / 관리자 전용(AdminOnly) / 회원 + 관리자(Authenticated) 로 세분화해서 구분 - 관리자의 경우 API 접근 권한 세분화 등 인증 로직 개선 - 전체 리팩터링이 완료되어 레거시 코드 제거 ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> <img width="750" alt="스크린샷 2025-09-13 19.11.44.png" src="attachments/11e1a79c-9723-4843-839d-be6158d94130"> - 추가 & 변경된 모든 API에 대한 통합 테스트 진행 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> Reviewed-on: #43 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
parent
675a5b8854
commit
5658f6c31f
@ -3,7 +3,7 @@ import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import {globalIgnores} from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
|
||||
@ -1 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88"
|
||||
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
@ -1,41 +1,27 @@
|
||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import {BrowserRouter as Router, Route, Routes} from 'react-router-dom';
|
||||
import AdminRoute from './components/AdminRoute';
|
||||
import Layout from './components/Layout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SignupPage from './pages/SignupPage';
|
||||
import ReservationPage from './pages/ReservationPage';
|
||||
import MyReservationPage from './pages/MyReservationPage';
|
||||
import {AuthProvider} from './context/AuthContext';
|
||||
import AdminLayout from './pages/admin/AdminLayout';
|
||||
import AdminPage from './pages/admin/AdminPage';
|
||||
import AdminReservationPage from './pages/admin/ReservationPage';
|
||||
import AdminTimePage from './pages/admin/TimePage';
|
||||
import AdminThemePage from './pages/admin/ThemePage';
|
||||
import AdminWaitingPage from './pages/admin/WaitingPage';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import AdminRoute from './components/AdminRoute';
|
||||
import ReservationStep1Page from './pages/v2/ReservationStep1Page';
|
||||
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
|
||||
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
|
||||
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
|
||||
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 ReservationFormPage from './pages/v2/ReservationFormPage';
|
||||
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
||||
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
||||
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
||||
import AdminThemePage from './pages/admin/AdminThemePage';
|
||||
import HomePage from '@_pages/HomePage';
|
||||
import LoginPage from '@_pages/LoginPage';
|
||||
import MyReservationPage from '@_pages/MyReservationPage';
|
||||
import ReservationFormPage from '@_pages/ReservationFormPage';
|
||||
import ReservationStep1Page from '@_pages/ReservationStep1Page';
|
||||
import ReservationStep2Page from '@_pages/ReservationStep2Page';
|
||||
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
|
||||
import SignupPage from '@_pages/SignupPage';
|
||||
|
||||
const AdminRoutes = () => (
|
||||
<AdminLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<AdminPage />} />
|
||||
<Route path="/reservation" element={<AdminReservationPage />} />
|
||||
<Route path="/time" element={<AdminTimePage />} />
|
||||
<Route path="/theme" element={<AdminThemePage />} />
|
||||
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
|
||||
<Route path="/waiting" element={<AdminWaitingPage />} />
|
||||
<Route path="/schedule" element={<AdminSchedulePage />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
@ -54,28 +40,14 @@ function App() {
|
||||
<Route path="/*" element={
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/" element={<HomePage/>} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/reservation" element={<ReservationPage />} />
|
||||
<Route path="/reservation" element={<ReservationStep1Page />} />
|
||||
<Route path="/reservation/form" element={<ReservationFormPage />} />
|
||||
<Route path="/reservation/payment" element={<ReservationStep2Page />} />
|
||||
<Route path="/reservation/success" element={<ReservationSuccessPage />} />
|
||||
<Route path="/my-reservation" element={<MyReservationPage />} />
|
||||
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
|
||||
|
||||
{/* V2 Pages */}
|
||||
<Route path="/v2/home" element={<HomePageV2 />} />
|
||||
<Route path="/v2/login" element={<LoginPageV2 />} />
|
||||
<Route path="/v2/signup" element={<SignupPageV2 />} />
|
||||
|
||||
{/* V2 Reservation Flow */}
|
||||
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
||||
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
||||
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
|
||||
|
||||
{/* V2.1 Reservation Flow */}
|
||||
<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/success" element={<ReservationSuccessPageV21 />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
} />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
|
||||
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
|
||||
import JSONbig from 'json-bigint';
|
||||
|
||||
// Create a JSONbig instance that stores big integers as strings
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import apiClient from '@_api/apiClient';
|
||||
import type { LoginRequest, LoginResponse, LoginCheckResponse } from './authTypes';
|
||||
import type {CurrentUserContext, LoginRequest, LoginSuccessResponse} from './authTypes';
|
||||
|
||||
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/login', data, false);
|
||||
export const login = async (data: LoginRequest): Promise<LoginSuccessResponse> => {
|
||||
const response = await apiClient.post<LoginSuccessResponse>('/auth/login', data, false);
|
||||
localStorage.setItem('accessToken', response.accessToken);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const checkLogin = async (): Promise<LoginCheckResponse> => {
|
||||
return await apiClient.get<LoginCheckResponse>('/login/check', true);
|
||||
export const checkLogin = async (): Promise<CurrentUserContext> => {
|
||||
return await apiClient.get<CurrentUserContext>('/auth/login/check', true);
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post('/logout', {}, true);
|
||||
await apiClient.post('/auth/logout', {}, true);
|
||||
localStorage.removeItem('accessToken');
|
||||
};
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
export const PrincipalType = {
|
||||
ADMIN: 'ADMIN',
|
||||
USER: 'USER',
|
||||
} as const;
|
||||
|
||||
export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType];
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
account: string,
|
||||
password: string;
|
||||
principalType: PrincipalType;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
export interface LoginSuccessResponse {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface LoginCheckResponse {
|
||||
export interface CurrentUserContext {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'ADMIN' | 'MEMBER';
|
||||
type: PrincipalType;
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import apiClient from "@_api/apiClient";
|
||||
import type { MemberRetrieveListResponse, SignupRequest, SignupResponse } from "./memberTypes";
|
||||
|
||||
export const fetchMembers = async (): Promise<MemberRetrieveListResponse> => {
|
||||
return await apiClient.get<MemberRetrieveListResponse>('/members', true);
|
||||
};
|
||||
|
||||
export const signup = async (data: SignupRequest): Promise<SignupResponse> => {
|
||||
return await apiClient.post('/members', data, false);
|
||||
};
|
||||
@ -1,25 +0,0 @@
|
||||
export interface MemberRetrieveResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MemberRetrieveListResponse {
|
||||
members: MemberRetrieveResponse[];
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SignupResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MemberSummaryRetrieveResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
@ -34,8 +34,8 @@ export interface PaymentRetrieveResponse {
|
||||
status: 'DONE' | 'CANCELED';
|
||||
requestedAt: string;
|
||||
approvedAt: string;
|
||||
detail: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
|
||||
cancellation?: CanceledPaymentDetailResponse;
|
||||
detail?: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
|
||||
cancel?: CanceledPaymentDetailResponse;
|
||||
}
|
||||
|
||||
export interface CardPaymentDetail {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import apiClient from "@_api/apiClient";
|
||||
import type { PaymentCancelRequest, PaymentConfirmRequest, PaymentCreateResponseV2 } from "./PaymentTypes";
|
||||
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);
|
||||
|
||||
@ -1,98 +1,33 @@
|
||||
import apiClient from "@_api/apiClient";
|
||||
import apiClient from '../apiClient';
|
||||
import type {
|
||||
AdminReservationCreateRequest,
|
||||
MyReservationRetrieveListResponse,
|
||||
ReservationCreateRequest,
|
||||
ReservationCreateResponse,
|
||||
ReservationCreateWithPaymentRequest,
|
||||
ReservationDetailV2,
|
||||
ReservationPaymentRequest,
|
||||
ReservationPaymentResponse,
|
||||
ReservationRetrieveListResponse,
|
||||
ReservationRetrieveResponse,
|
||||
ReservationSearchQuery,
|
||||
ReservationSummaryListV2,
|
||||
WaitingCreateRequest
|
||||
} from "./reservationTypes";
|
||||
MostReservedThemeIdListResponse,
|
||||
PendingReservationCreateRequest,
|
||||
PendingReservationCreateResponse,
|
||||
ReservationDetailRetrieveResponse,
|
||||
ReservationSummaryRetrieveListResponse
|
||||
} from './reservationTypes';
|
||||
|
||||
// GET /reservations
|
||||
export const fetchReservations = async (): Promise<ReservationRetrieveListResponse> => {
|
||||
return await apiClient.get<ReservationRetrieveListResponse>('/reservations', true);
|
||||
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
|
||||
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
|
||||
};
|
||||
|
||||
// GET /reservations-mine
|
||||
export const fetchMyReservations = async (): Promise<MyReservationRetrieveListResponse> => {
|
||||
return await apiClient.get<MyReservationRetrieveListResponse>('/reservations-mine', true);
|
||||
};
|
||||
|
||||
// GET /reservations/search
|
||||
export const searchReservations = async (params: ReservationSearchQuery): Promise<ReservationRetrieveListResponse> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.themeId) query.append('themeId', params.themeId.toString());
|
||||
if (params.memberId) query.append('memberId', params.memberId.toString());
|
||||
if (params.dateFrom) query.append('dateFrom', params.dateFrom);
|
||||
if (params.dateTo) query.append('dateTo', params.dateTo);
|
||||
return await apiClient.get<ReservationRetrieveListResponse>(`/reservations/search?${query.toString()}`, true);
|
||||
};
|
||||
|
||||
// DELETE /reservations/{id}
|
||||
export const cancelReservationByAdmin = async (id: string): Promise<void> => {
|
||||
return await apiClient.del(`/reservations/${id}`, true);
|
||||
};
|
||||
|
||||
// POST /reservations
|
||||
export const createReservationWithPayment = async (data: ReservationCreateWithPaymentRequest): Promise<ReservationRetrieveResponse> => {
|
||||
return await apiClient.post<ReservationRetrieveResponse>('/reservations', data, true);
|
||||
};
|
||||
|
||||
// POST /reservations/admin
|
||||
export const createReservationByAdmin = async (data: AdminReservationCreateRequest): Promise<ReservationRetrieveResponse> => {
|
||||
return await apiClient.post<ReservationRetrieveResponse>('/reservations/admin', data, true);
|
||||
};
|
||||
|
||||
// GET /reservations/waiting
|
||||
export const fetchWaitingReservations = async (): Promise<ReservationRetrieveListResponse> => {
|
||||
return await apiClient.get<ReservationRetrieveListResponse>('/reservations/waiting', true);
|
||||
};
|
||||
|
||||
// POST /reservations/waiting
|
||||
export const createWaiting = async (data: WaitingCreateRequest): Promise<ReservationRetrieveResponse> => {
|
||||
return await apiClient.post<ReservationRetrieveResponse>('/reservations/waiting', data, true);
|
||||
};
|
||||
|
||||
// DELETE /reservations/waiting/{id}
|
||||
export const cancelWaiting = async (id: string): Promise<void> => {
|
||||
return await apiClient.del(`/reservations/waiting/${id}`, true);
|
||||
};
|
||||
|
||||
// POST /reservations/waiting/{id}/confirm
|
||||
export const confirmWaiting = async (id: string): Promise<void> => {
|
||||
return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true);
|
||||
};
|
||||
|
||||
// POST /reservations/waiting/{id}/reject
|
||||
export const rejectWaiting = async (id: string): Promise<void> => {
|
||||
return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true);
|
||||
};
|
||||
|
||||
// POST /v2/reservations
|
||||
export const createPendingReservation = async (data: ReservationCreateRequest): Promise<ReservationCreateResponse> => {
|
||||
return await apiClient.post<ReservationCreateResponse>('/v2/reservations', data, true);
|
||||
};
|
||||
|
||||
// POST /v2/reservations/{id}/pay
|
||||
export const confirmReservationPayment = async (id: string, data: ReservationPaymentRequest): Promise<ReservationPaymentResponse> => {
|
||||
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
|
||||
export const confirmReservation = async (reservationId: string): Promise<void> => {
|
||||
await apiClient.post(`/reservations/${reservationId}/confirm`, {});
|
||||
};
|
||||
|
||||
|
||||
|
||||
// GET /v2/reservations
|
||||
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {
|
||||
return await apiClient.get<ReservationSummaryListV2>('/v2/reservations', true);
|
||||
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
|
||||
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
|
||||
};
|
||||
|
||||
// GET /v2/reservations/{id}/details
|
||||
export const fetchReservationDetailV2 = async (id: string): Promise<ReservationDetailV2> => {
|
||||
return await apiClient.get<ReservationDetailV2>(`/v2/reservations/${id}/details`, true);
|
||||
};
|
||||
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
|
||||
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
|
||||
}
|
||||
|
||||
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
|
||||
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`);
|
||||
}
|
||||
|
||||
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => {
|
||||
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`, false);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
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.post(`/reservations/${reservationId}/confirm`, {});
|
||||
};
|
||||
|
||||
|
||||
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
|
||||
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
|
||||
};
|
||||
|
||||
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
|
||||
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
|
||||
}
|
||||
|
||||
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
|
||||
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`);
|
||||
}
|
||||
@ -1,135 +1,62 @@
|
||||
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 { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
||||
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
|
||||
import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
|
||||
|
||||
export const ReservationStatus = {
|
||||
PENDING: 'PENDING',
|
||||
CONFIRMED: 'CONFIRMED',
|
||||
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
|
||||
WAITING: 'WAITING',
|
||||
CANCELED_BY_USER: 'CANCELED_BY_USER',
|
||||
AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED'
|
||||
CANCELED: 'CANCELED',
|
||||
FAILED: 'FAILED',
|
||||
EXPIRED: 'EXPIRED'
|
||||
} as const;
|
||||
|
||||
export type ReservationStatus =
|
||||
| typeof ReservationStatus.PENDING
|
||||
| typeof ReservationStatus.CONFIRMED
|
||||
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
|
||||
| typeof ReservationStatus.WAITING
|
||||
| typeof ReservationStatus.CANCELED_BY_USER
|
||||
| typeof ReservationStatus.AUTOMATICALLY_CANCELED;
|
||||
| typeof ReservationStatus.CANCELED
|
||||
| typeof ReservationStatus.FAILED
|
||||
| typeof ReservationStatus.EXPIRED;
|
||||
|
||||
export interface MyReservationRetrieveResponse {
|
||||
id: string;
|
||||
themeName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
status: ReservationStatus;
|
||||
rank: number;
|
||||
paymentKey: string | null;
|
||||
amount: number | null;
|
||||
export interface PendingReservationCreateRequest {
|
||||
scheduleId: string,
|
||||
reserverName: string,
|
||||
reserverContact: string,
|
||||
participantCount: number,
|
||||
requirement: string
|
||||
}
|
||||
|
||||
export interface MyReservationRetrieveListResponse {
|
||||
reservations: MyReservationRetrieveResponse[];
|
||||
export interface PendingReservationCreateResponse {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface ReservationRetrieveResponse {
|
||||
id: string;
|
||||
date: string;
|
||||
member: MemberRetrieveResponse;
|
||||
time: TimeRetrieveResponse;
|
||||
theme: ThemeRetrieveResponse;
|
||||
status: ReservationStatus;
|
||||
}
|
||||
|
||||
export interface ReservationRetrieveListResponse {
|
||||
reservations: ReservationRetrieveResponse[];
|
||||
}
|
||||
|
||||
export interface AdminReservationCreateRequest {
|
||||
date: string;
|
||||
timeId: string;
|
||||
themeId: string;
|
||||
memberId: string;
|
||||
}
|
||||
|
||||
export interface ReservationCreateWithPaymentRequest {
|
||||
date: string;
|
||||
timeId: string;
|
||||
themeId: string;
|
||||
paymentKey: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
paymentType: string;
|
||||
}
|
||||
|
||||
export interface WaitingCreateRequest {
|
||||
date: string;
|
||||
timeId: string;
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
export interface ReservationSearchQuery {
|
||||
themeId?: string;
|
||||
memberId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export const PaymentStatus = {
|
||||
IN_PROGRESS: '결제 진행 중',
|
||||
DONE: '결제 완료',
|
||||
CANCELED: '결제 취소',
|
||||
ABORTED: '결제 중단',
|
||||
EXPIRED: '시간 만료',
|
||||
}
|
||||
|
||||
export type PaymentStatus =
|
||||
| typeof PaymentStatus.IN_PROGRESS
|
||||
| typeof PaymentStatus.DONE
|
||||
| typeof PaymentStatus.CANCELED
|
||||
| typeof PaymentStatus.ABORTED
|
||||
| typeof PaymentStatus.EXPIRED;
|
||||
|
||||
|
||||
export interface ReservationCreateRequest {
|
||||
date: string;
|
||||
timeId: string;
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
export interface ReservationCreateResponse {
|
||||
reservationId: string;
|
||||
memberEmail: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
export interface ReservationPaymentRequest {
|
||||
paymentKey: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
paymentType: PaymentType;
|
||||
}
|
||||
|
||||
export interface ReservationPaymentResponse {
|
||||
reservationId: string;
|
||||
reservationStatus: ReservationStatus;
|
||||
paymentId: string;
|
||||
paymentStatus: PaymentStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface ReservationDetailV2 {
|
||||
export interface ReservationSummaryRetrieveResponse {
|
||||
id: string;
|
||||
user: MemberSummaryRetrieveResponse;
|
||||
themeName: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
applicationDateTime: string;
|
||||
payment: PaymentRetrieveResponse;
|
||||
status: ReservationStatus;
|
||||
}
|
||||
|
||||
export interface ReservationSummaryRetrieveListResponse {
|
||||
reservations: ReservationSummaryRetrieveResponse[];
|
||||
}
|
||||
|
||||
export interface ReservationDetailRetrieveResponse {
|
||||
id: string;
|
||||
user: UserContactRetrieveResponse;
|
||||
applicationDateTime: string;
|
||||
payment: PaymentRetrieveResponse;
|
||||
}
|
||||
|
||||
export interface ReservationDetail {
|
||||
id: string;
|
||||
themeName: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
user: UserContactRetrieveResponse;
|
||||
applicationDateTime: string;
|
||||
payment?: PaymentRetrieveResponse;
|
||||
}
|
||||
|
||||
export interface MostReservedThemeIdListResponse {
|
||||
themeIds: string[];
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -2,7 +2,8 @@ import apiClient from '../apiClient';
|
||||
import type {
|
||||
AvailableThemeIdListResponse,
|
||||
ScheduleCreateRequest,
|
||||
ScheduleCreateResponse, ScheduleDetailRetrieveResponse,
|
||||
ScheduleCreateResponse,
|
||||
ScheduleDetailRetrieveResponse,
|
||||
ScheduleRetrieveListResponse,
|
||||
ScheduleUpdateRequest
|
||||
} from './scheduleTypes';
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
export enum ScheduleStatus {
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
HOLD = 'HOLD',
|
||||
RESERVED = 'RESERVED',
|
||||
BLOCKED = 'BLOCKED',
|
||||
}
|
||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||
|
||||
export const ScheduleStatus = {
|
||||
AVAILABLE: 'AVAILABLE' as ScheduleStatus,
|
||||
HOLD: 'HOLD' as ScheduleStatus,
|
||||
RESERVED: 'RESERVED' as ScheduleStatus,
|
||||
BLOCKED: 'BLOCKED' as ScheduleStatus,
|
||||
};
|
||||
|
||||
export interface AvailableThemeIdListResponse {
|
||||
themeIds: string[];
|
||||
|
||||
@ -3,29 +3,12 @@ import type {
|
||||
AdminThemeDetailRetrieveResponse,
|
||||
AdminThemeSummaryRetrieveListResponse,
|
||||
ThemeCreateRequest,
|
||||
ThemeCreateRequestV2, ThemeCreateResponse,
|
||||
ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse,
|
||||
ThemeRetrieveListResponseV2,
|
||||
ThemeUpdateRequest,
|
||||
UserThemeRetrieveListResponse
|
||||
ThemeCreateResponse,
|
||||
ThemeIdListResponse,
|
||||
ThemeInfoListResponse,
|
||||
ThemeUpdateRequest
|
||||
} from './themeTypes';
|
||||
|
||||
export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
||||
return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
|
||||
};
|
||||
|
||||
export const fetchThemes = async (): Promise<ThemeRetrieveListResponse> => {
|
||||
return await apiClient.get<ThemeRetrieveListResponse>('/themes', true);
|
||||
};
|
||||
|
||||
export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetrieveListResponse> => {
|
||||
return await apiClient.get<ThemeRetrieveListResponse>(`/themes/most-reserved-last-week?count=${count}`, false);
|
||||
};
|
||||
|
||||
export const delTheme = async (id: string): Promise<void> => {
|
||||
return await apiClient.del(`/themes/${id}`, true);
|
||||
};
|
||||
|
||||
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
|
||||
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes');
|
||||
};
|
||||
@ -34,8 +17,8 @@ export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetai
|
||||
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
|
||||
};
|
||||
|
||||
export const createThemeV2 = async (themeData: ThemeCreateRequestV2): Promise<ThemeCreateResponseV2> => {
|
||||
return await apiClient.post<ThemeCreateResponseV2>('/admin/themes', themeData);
|
||||
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
||||
return await apiClient.post<ThemeCreateResponse>('/admin/themes', themeData);
|
||||
};
|
||||
|
||||
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
|
||||
@ -46,10 +29,10 @@ export const deleteTheme = async (id: string): Promise<void> => {
|
||||
await apiClient.del<any>(`/admin/themes/${id}`);
|
||||
};
|
||||
|
||||
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
|
||||
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
|
||||
export const fetchUserThemes = async (): Promise<ThemeInfoListResponse> => {
|
||||
return await apiClient.get<ThemeInfoListResponse>('/themes');
|
||||
};
|
||||
|
||||
export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise<ThemeRetrieveListResponseV2> => {
|
||||
return await apiClient.post<ThemeRetrieveListResponseV2>('/themes/retrieve', request);
|
||||
export const findThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
|
||||
return await apiClient.post<ThemeInfoListResponse>('/themes/retrieve', request);
|
||||
};
|
||||
|
||||
@ -1,29 +1,4 @@
|
||||
export interface ThemeCreateRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ThemeCreateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveListResponse {
|
||||
themes: ThemeRetrieveResponse[];
|
||||
}
|
||||
|
||||
|
||||
export interface ThemeV2 {
|
||||
export interface AdminThemeDetailResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@ -42,7 +17,7 @@ export interface ThemeV2 {
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface ThemeCreateRequestV2 {
|
||||
export interface ThemeCreateRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnailUrl: string;
|
||||
@ -56,7 +31,7 @@ export interface ThemeCreateRequestV2 {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeCreateResponseV2 {
|
||||
export interface ThemeCreateResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
@ -105,7 +80,7 @@ export interface AdminThemeDetailRetrieveResponse {
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface UserThemeRetrieveResponse {
|
||||
export interface ThemeInfoResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailUrl: string;
|
||||
@ -119,37 +94,26 @@ export interface UserThemeRetrieveResponse {
|
||||
expectedMinutesTo: number;
|
||||
}
|
||||
|
||||
export interface UserThemeRetrieveListResponse {
|
||||
themes: UserThemeRetrieveResponse[];
|
||||
export interface ThemeInfoListResponse {
|
||||
themes: ThemeInfoResponse[];
|
||||
}
|
||||
|
||||
export interface ThemeListRetrieveRequest {
|
||||
export interface ThemeIdListResponse {
|
||||
themeIds: string[];
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveResponseV2 {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailUrl: string;
|
||||
description: string;
|
||||
difficulty: Difficulty;
|
||||
price: number;
|
||||
minParticipants: number;
|
||||
maxParticipants: number;
|
||||
availableMinutes: number;
|
||||
expectedMinutesFrom: number;
|
||||
expectedMinutesTo: number;
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveListResponseV2 {
|
||||
themes: ThemeRetrieveResponseV2[];
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export enum Difficulty {
|
||||
VERY_EASY = 'VERY_EASY',
|
||||
EASY = 'EASY',
|
||||
NORMAL = 'NORMAL',
|
||||
HARD = 'HARD',
|
||||
VERY_HARD = 'VERY_HARD',
|
||||
VERY_EASY = '매우 쉬움',
|
||||
EASY = '쉬움',
|
||||
NORMAL = '보통',
|
||||
HARD = '어려움',
|
||||
VERY_HARD = '매우 어려움',
|
||||
}
|
||||
|
||||
export function mapThemeResponse(res: any): ThemeInfoResponse {
|
||||
return {
|
||||
...res,
|
||||
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import apiClient from "@_api/apiClient";
|
||||
import type { TimeCreateRequest, TimeCreateResponse, TimeRetrieveListResponse, TimeWithAvailabilityListResponse } from "./timeTypes";
|
||||
|
||||
export const createTime = async (data: TimeCreateRequest): Promise<TimeCreateResponse> => {
|
||||
return await apiClient.post<TimeCreateResponse>('/times', data, true);
|
||||
}
|
||||
|
||||
export const fetchTimes = async (): Promise<TimeRetrieveListResponse> => {
|
||||
return await apiClient.get<TimeRetrieveListResponse>('/times', true);
|
||||
};
|
||||
|
||||
export const delTime = async (id: string): Promise<void> => {
|
||||
return await apiClient.del(`/times/${id}`, true);
|
||||
};
|
||||
|
||||
export const fetchTimesWithAvailability = async (date: string, themeId: string): Promise<TimeWithAvailabilityListResponse> => {
|
||||
return await apiClient.get<TimeWithAvailabilityListResponse>(`/times/search?date=${date}&themeId=${themeId}`, true);
|
||||
};
|
||||
@ -1,27 +0,0 @@
|
||||
export interface TimeCreateRequest {
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeCreateResponse {
|
||||
id: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeRetrieveResponse {
|
||||
id: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeRetrieveListResponse {
|
||||
times: TimeCreateResponse[];
|
||||
}
|
||||
|
||||
export interface TimeWithAvailabilityResponse {
|
||||
id: string;
|
||||
startAt: string;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface TimeWithAvailabilityListResponse {
|
||||
times: TimeWithAvailabilityResponse[];
|
||||
}
|
||||
10
frontend/src/api/user/userAPI.ts
Normal file
10
frontend/src/api/user/userAPI.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import apiClient from "@_api/apiClient";
|
||||
import type {UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse} from "./userTypes";
|
||||
|
||||
export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => {
|
||||
return await apiClient.post('/users', data, false);
|
||||
};
|
||||
|
||||
export const fetchContact = async (): Promise<UserContactRetrieveResponse> => {
|
||||
return await apiClient.get<UserContactRetrieveResponse>('/users/contact', true);
|
||||
}
|
||||
32
frontend/src/api/user/userTypes.ts
Normal file
32
frontend/src/api/user/userTypes.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export interface UserCreateRequest {
|
||||
/** not empty */
|
||||
name: string;
|
||||
|
||||
/** not empty, email format */
|
||||
email: string;
|
||||
|
||||
/** length >= 8 */
|
||||
password: string;
|
||||
|
||||
/** not empty, pattern: ^010([0-9]{3,4})([0-9]{4})$ */
|
||||
phone: string;
|
||||
|
||||
/** nullable */
|
||||
regionCode?: string | null;
|
||||
}
|
||||
|
||||
export interface UserCreateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserContactRetrieveResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface OperatorInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93"
|
||||
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {Navigate, useLocation} from 'react-router-dom';
|
||||
import {useAuth} from '../context/AuthContext';
|
||||
|
||||
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
const { loggedIn, role, loading } = useAuth();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import React, {type ReactNode} from 'react';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
interface LayoutProps {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from 'src/context/AuthContext';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useAuth} from 'src/context/AuthContext';
|
||||
import 'src/css/navbar.css';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
@ -21,20 +21,20 @@ const Navbar: React.FC = () => {
|
||||
<nav className="navbar-container">
|
||||
<div className="nav-links">
|
||||
<Link className="nav-link" to="/">홈</Link>
|
||||
<Link className="nav-link" to="/v2/reservation">예약하기</Link>
|
||||
<Link className="nav-link" to="/reservation">예약하기</Link>
|
||||
</div>
|
||||
<div className="nav-actions">
|
||||
{!loggedIn ? (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/v2/login')}>로그인</button>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/v2/signup')}>회원가입</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/login')}>로그인</button>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/signup')}>회원가입</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="profile-info">
|
||||
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
||||
<span>{userName}</span>
|
||||
<div className="dropdown-menu">
|
||||
<Link className="dropdown-item" to="/my-reservation/v2">내 예약</Link>
|
||||
<Link className="dropdown-item" to="/my-reservation">내 예약</Link>
|
||||
<div className="dropdown-divider" />
|
||||
<a className="dropdown-item" href="#" onClick={handleLogout}>로그아웃</a>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI';
|
||||
import type { LoginRequest, LoginResponse } from '@_api/auth/authTypes';
|
||||
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import {checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout} from '@_api/auth/authAPI';
|
||||
import {type LoginRequest, type LoginSuccessResponse, PrincipalType} from '@_api/auth/authTypes';
|
||||
import React, {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
loggedIn: boolean;
|
||||
userName: string | null;
|
||||
role: 'ADMIN' | 'MEMBER' | null;
|
||||
loading: boolean; // Add loading state to type
|
||||
login: (data: LoginRequest) => Promise<LoginResponse>;
|
||||
type: PrincipalType | null;
|
||||
loading: boolean;
|
||||
login: (data: LoginRequest) => Promise<LoginSuccessResponse>;
|
||||
logout: () => Promise<void>;
|
||||
checkLogin: () => Promise<void>;
|
||||
}
|
||||
@ -17,7 +17,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<'ADMIN' | 'MEMBER' | null>(null);
|
||||
const [type, setType] = useState<PrincipalType | null>(null);
|
||||
const [loading, setLoading] = useState(true); // Add loading state
|
||||
|
||||
const checkLogin = async () => {
|
||||
@ -25,11 +25,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const response = await apiCheckLogin();
|
||||
setLoggedIn(true);
|
||||
setUserName(response.name);
|
||||
setRole(response.role);
|
||||
setType(response.type);
|
||||
} catch (error) {
|
||||
setLoggedIn(false);
|
||||
setUserName(null);
|
||||
setRole(null);
|
||||
setType(null);
|
||||
localStorage.removeItem('accessToken');
|
||||
} finally {
|
||||
setLoading(false); // Set loading to false after check is complete
|
||||
@ -41,8 +41,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, []);
|
||||
|
||||
const login = async (data: LoginRequest) => {
|
||||
const response = await apiLogin(data);
|
||||
await checkLogin();
|
||||
const response = await apiLogin({ ...data });
|
||||
setLoggedIn(true);
|
||||
setType(data.principalType);
|
||||
return response;
|
||||
};
|
||||
|
||||
@ -52,13 +53,13 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
} finally {
|
||||
setLoggedIn(false);
|
||||
setUserName(null);
|
||||
setRole(null);
|
||||
setType(null);
|
||||
localStorage.removeItem('accessToken');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ loggedIn, userName, role, loading, login, logout, checkLogin }}>
|
||||
<AuthContext.Provider value={{ loggedIn, userName, type, loading, login, logout, checkLogin }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
/* /src/css/admin-time-page.css */
|
||||
.admin-time-container {
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 40px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f4f6f8;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.admin-time-container .page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #333d4b;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-container table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.table-container th,
|
||||
.table-container td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e8eb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-container th {
|
||||
background-color: #f9fafb;
|
||||
color: #505a67;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-container tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-container tr:hover {
|
||||
background-color: #f4f6f8;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #E5E8EB;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3182F6;
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3182F6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1B64DA;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #F2F4F6;
|
||||
color: #4E5968;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #E5E8EB;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #e53e3e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c53030;
|
||||
}
|
||||
|
||||
.editing-row td {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.editing-row .btn {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
/* /src/css/admin-waiting-page.css */
|
||||
.admin-waiting-container {
|
||||
max-width: 1200px;
|
||||
margin: 40px auto;
|
||||
padding: 40px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f4f6f8;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.admin-waiting-container .page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #333d4b;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.table-container table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.table-container th,
|
||||
.table-container td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e8eb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-container th {
|
||||
background-color: #f9fafb;
|
||||
color: #505a67;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-container tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-container tr:hover {
|
||||
background-color: #f4f6f8;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3182F6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1B64DA;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #e53e3e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c53030;
|
||||
}
|
||||
@ -31,6 +31,7 @@
|
||||
gap: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-ranking-item-v2:hover {
|
||||
@ -64,3 +65,101 @@
|
||||
color: #505a67;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.theme-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;
|
||||
}
|
||||
|
||||
.theme-modal-content {
|
||||
background-color: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.modal-thumbnail {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-theme-info h2 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #333d4b;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.modal-theme-info p {
|
||||
font-size: 16px;
|
||||
color: #505a67;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 15px;
|
||||
}
|
||||
|
||||
.theme-details {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.theme-details p {
|
||||
margin: 5px 0;
|
||||
font-size: 15px;
|
||||
color: #333d4b;
|
||||
}
|
||||
|
||||
.theme-details strong {
|
||||
color: #191919;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.modal-button.reserve {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button.reserve:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.modal-button.close {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button.close:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
@ -68,17 +68,77 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Canceled Card Style */
|
||||
.reservation-summary-card-v2.status-canceled_by_user {
|
||||
background-color: #f8f9fa;
|
||||
opacity: 0.6;
|
||||
/* --- Status Badge --- */
|
||||
.card-status-badge {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.reservation-summary-card-v2.status-canceled_by_user .summary-theme-name-v2,
|
||||
.reservation-summary-card-v2.status-canceled_by_user .summary-datetime-v2,
|
||||
.reservation-summary-card-v2.status-canceled_by_user .summary-details-v2 strong {
|
||||
/* --- Card Status Styles --- */
|
||||
.reservation-summary-card-v2 {
|
||||
position: relative; /* For badge positioning */
|
||||
}
|
||||
|
||||
/* Confirmed (Upcoming) */
|
||||
.reservation-summary-card-v2.status-confirmed {
|
||||
border-left: 5px solid #28a745; /* Green accent */
|
||||
}
|
||||
.status-confirmed .card-status-badge {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Completed (Past) */
|
||||
.reservation-summary-card-v2.status-completed {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 5px solid #6c757d; /* Gray accent */
|
||||
}
|
||||
.reservation-summary-card-v2.status-completed .summary-theme-name-v2,
|
||||
.reservation-summary-card-v2.status-completed .summary-datetime-v2 {
|
||||
color: #6c757d;
|
||||
}
|
||||
.reservation-summary-card-v2.status-completed .detail-button-v2 {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.status-completed .card-status-badge {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Canceled */
|
||||
.reservation-summary-card-v2.status-canceled {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 5px solid #dc3545; /* Red accent */
|
||||
opacity: 0.7;
|
||||
}
|
||||
.reservation-summary-card-v2.status-canceled .summary-theme-name-v2,
|
||||
.reservation-summary-card-v2.status-canceled .summary-datetime-v2 {
|
||||
color: #6c757d;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.reservation-summary-card-v2.status-canceled .detail-button-v2 {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.status-canceled .card-status-badge {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Pending */
|
||||
.reservation-summary-card-v2.status-pending {
|
||||
border-left: 5px solid #ffc107; /* Yellow accent */
|
||||
}
|
||||
.status-pending .card-status-badge {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Detail Button */
|
||||
.detail-button-v2 {
|
||||
|
||||
@ -327,13 +327,13 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
background-color: #ffffff !important;
|
||||
padding: 32px !important;
|
||||
border-radius: 16px !important;
|
||||
width: 90% !important;
|
||||
max-width: 500px !important;
|
||||
position: relative !important;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
|
||||
@ -1,175 +0,0 @@
|
||||
#root .flatpickr-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#root .modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1050;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#root .modal-dialog {
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
margin: 1.75rem auto;
|
||||
}
|
||||
|
||||
/* Toss-style Modal */
|
||||
#root .modal-content {
|
||||
background-color: #fff !important;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#root .modal-header {
|
||||
border-bottom: none;
|
||||
padding: 0 0 1rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#root .modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#root .btn-close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#root .modal-body {
|
||||
padding: 1rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root .modal-body p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#root .modal-footer {
|
||||
border-top: none;
|
||||
padding: 1rem 0 0 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Generic Button Styles --- */
|
||||
#root .btn-primary,
|
||||
#root .modal-footer .btn-primary,
|
||||
#root .btn-wrapper .btn-primary,
|
||||
#root .button-group .btn-primary,
|
||||
#root .success-page-actions .btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
color: #fff;
|
||||
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#root .btn-secondary,
|
||||
#root .modal-footer .btn-secondary,
|
||||
#root .success-page-actions .btn-secondary {
|
||||
background-color: #f0f2f5;
|
||||
border-color: #f0f2f5;
|
||||
color: #333;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#root .btn-primary:hover,
|
||||
#root .modal-footer .btn-primary:hover,
|
||||
#root .btn-wrapper .btn-primary:hover,
|
||||
#root .button-group .btn-primary:hover,
|
||||
#root .success-page-actions .btn-primary:hover {
|
||||
background-color: #0069d9;
|
||||
border-color: #0062cc;
|
||||
}
|
||||
|
||||
#root .btn-secondary:hover,
|
||||
#root .modal-footer .btn-secondary:hover,
|
||||
#root .success-page-actions .btn-secondary:hover {
|
||||
background-color: #e2e6ea;
|
||||
}
|
||||
|
||||
|
||||
/* --- Reservation Success Page Styles --- */
|
||||
.reservation-success-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.reservation-success-page .content-container-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.reservation-info-box {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
background-color: #fff;
|
||||
min-width: 380px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reservation-info-box h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reservation-info-box .info-item {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reservation-info-box .info-item strong {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success-page-actions {
|
||||
margin-top: 2.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#theme-slots .theme-slot.active, #time-slots .time-slot.active {
|
||||
background-color: #0a3711 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#time-slots .time-slot.disabled {
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@ -63,3 +63,9 @@
|
||||
.signup-form-v2 .btn-primary:hover {
|
||||
background-color: #1B64DA;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #E53E3E;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@ -3,9 +3,8 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||
import './css/style.css';
|
||||
import './css/reservation.css';
|
||||
import './css/toss-style.css';
|
||||
import '@_css/style.css';
|
||||
import '@_css/toss-style.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@ -1,31 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { mostReservedThemes } from '@_api/theme/themeAPI';
|
||||
import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
|
||||
import '@_css/home-page-v2.css';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {findThemesByIds} from '@_api/theme/themeAPI';
|
||||
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const [ranking, setRanking] = useState<any[]>([]);
|
||||
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await mostReservedThemes(10).then(response => setRanking(response.themes))
|
||||
try {
|
||||
const themeIds = await fetchMostReservedThemeIds().then(res => {
|
||||
const themeIds = res.themeIds;
|
||||
if (themeIds.length === 0) {
|
||||
setRanking([]);
|
||||
return;
|
||||
}
|
||||
return themeIds;
|
||||
})
|
||||
|
||||
if (themeIds === undefined) return;
|
||||
if (themeIds.length === 0) return;
|
||||
|
||||
const response = await findThemesByIds({ themeIds: themeIds });
|
||||
setRanking(response.themes.map(mapThemeResponse));
|
||||
} catch (err) {
|
||||
console.error('Error fetching ranking:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData().catch(err => console.error('Error fetching ranking:', err));
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleThemeClick = (theme: ThemeInfoResponse) => {
|
||||
setSelectedTheme(theme);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedTheme(null);
|
||||
};
|
||||
|
||||
const handleReservationClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (selectedTheme) {
|
||||
navigate('/reservation', { state: { themeId: selectedTheme.id } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-container">
|
||||
<h2 className="content-container-title">인기 테마</h2>
|
||||
<ul className="list-unstyled" id="theme-ranking">
|
||||
<div className="home-container-v2">
|
||||
<h2 className="page-title">인기 테마</h2>
|
||||
<div className="theme-ranking-list-v2">
|
||||
{ranking.map(theme => (
|
||||
<li key={theme.id} className="d-flex my-4">
|
||||
<img className="me-3 img-thumbnail" src={theme.thumbnail} alt={theme.name} style={{ width: '150px' }} />
|
||||
<div className="media-body">
|
||||
<h5 className="mt-0 mb-1">{theme.name}</h5>
|
||||
{theme.description}
|
||||
<div key={theme.id} className="theme-ranking-item-v2" onClick={() => handleThemeClick(theme)}>
|
||||
<img className="thumbnail" src={theme.thumbnailUrl} alt={theme.name} />
|
||||
<div className="theme-info">
|
||||
<h5 className="theme-name">{theme.name}</h5>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{selectedTheme && (
|
||||
<div className="theme-modal-overlay" onClick={handleCloseModal}>
|
||||
<div className="theme-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<img className="modal-thumbnail" src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} />
|
||||
<div className="modal-theme-info">
|
||||
<h2>{selectedTheme.name}</h2>
|
||||
<p>{selectedTheme.description}</p>
|
||||
<div className="theme-details">
|
||||
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||
<p><strong>예상 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||
<p><strong>이용 가능 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-buttons">
|
||||
<button onClick={handleReservationClick} className="modal-button reserve">예약하기</button>
|
||||
<button onClick={handleCloseModal} className="modal-button close">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import React, {useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useAuth} from '@_context/AuthContext';
|
||||
import '@_css/login-page-v2.css';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
@ -11,36 +12,53 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
const from = location.state?.from?.pathname || '/';
|
||||
|
||||
const handleLogin = async () => {
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login({email, password});
|
||||
|
||||
const principalType = from.startsWith('/admin') ? 'ADMIN' : 'USER';
|
||||
await login({ account: email, password: password, principalType: principalType });
|
||||
|
||||
alert('로그인에 성공했어요!');
|
||||
navigate(from, { replace: true });
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
|
||||
alert(message);
|
||||
console.error('로그인 실패:', error);
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-container" style={{ width: '300px' }}>
|
||||
<h2 className="content-container-title">Login</h2>
|
||||
<div className="form-group">
|
||||
<input type="email" className="form-control" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="button-group full-width">
|
||||
<button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
|
||||
<button className="btn btn-custom" onClick={handleLogin}>Login</button>
|
||||
</div>
|
||||
<div className="login-container-v2">
|
||||
<h2 className="page-title">로그인</h2>
|
||||
<form className="login-form-v2" onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="이메일"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="비밀번호"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="button-group">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/signup')}>회원가입</button>
|
||||
<button type="submit" className="btn btn-primary">로그인</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
export default LoginPage;
|
||||
|
||||
@ -1,88 +1,382 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cancelWaiting, fetchMyReservations } from '@_api/reservation/reservationAPI';
|
||||
import type { MyReservationRetrieveResponse } from '@_api/reservation/reservationTypes';
|
||||
import { ReservationStatus } from '@_api/reservation/reservationTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import {cancelPayment} from '@_api/payment/paymentAPI';
|
||||
import type {PaymentRetrieveResponse} from '@_api/payment/PaymentTypes';
|
||||
import {cancelReservation, fetchDetailById, fetchSummaryByMember} from '@_api/reservation/reservationAPI';
|
||||
import {
|
||||
type ReservationDetail,
|
||||
ReservationStatus,
|
||||
type ReservationSummaryRetrieveResponse
|
||||
} from '@_api/reservation/reservationTypes';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import '@_css/my-reservation-v2.css';
|
||||
|
||||
const getReservationStatus = (reservation: ReservationSummaryRetrieveResponse): { className: string, text: string } => {
|
||||
const now = new Date();
|
||||
const reservationDateTime = new Date(`${reservation.date}T${reservation.startAt}`);
|
||||
|
||||
switch (reservation.status) {
|
||||
case ReservationStatus.CANCELED:
|
||||
return { className: 'status-canceled', text: '취소됨' };
|
||||
case ReservationStatus.CONFIRMED:
|
||||
if (reservationDateTime < now) {
|
||||
return { className: 'status-completed', text: '이용완료' };
|
||||
}
|
||||
return { className: 'status-confirmed', text: '예약확정' };
|
||||
case ReservationStatus.PENDING:
|
||||
return { className: 'status-pending', text: '입금대기' };
|
||||
default:
|
||||
return { className: `status-${reservation.status.toLowerCase()}`, text: reservation.status };
|
||||
}
|
||||
};
|
||||
|
||||
const formatDisplayDateTime = (dateTime: any): string => {
|
||||
let date: Date;
|
||||
|
||||
if (typeof dateTime === 'string') {
|
||||
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
|
||||
date = new Date(dateTime);
|
||||
} else if (typeof dateTime === 'number') {
|
||||
// Unix 타임스탬프(초) 형식 처리
|
||||
date = new Date(dateTime * 1000);
|
||||
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
|
||||
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
|
||||
const year = dateTime[0];
|
||||
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
|
||||
const day = dateTime[2];
|
||||
const hour = dateTime[3];
|
||||
const minute = dateTime[4];
|
||||
const second = dateTime[5];
|
||||
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
|
||||
date = new Date(year, month, day, hour, minute, second, millisecond);
|
||||
} else {
|
||||
return '유효하지 않은 날짜 형식';
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return '유효하지 않은 날짜';
|
||||
}
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
second: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', options).format(date);
|
||||
};
|
||||
|
||||
const formatCardDateTime = (dateStr: string, timeStr: string): string => {
|
||||
const date = new Date(`${dateStr}T${timeStr}`);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const reservationYear = date.getFullYear();
|
||||
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const dayOfWeek = days[date.getDay()];
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
let hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const ampm = hours >= 12 ? '오후' : '오전';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
|
||||
let datePart = '';
|
||||
if (currentYear === reservationYear) {
|
||||
datePart = `${month}월 ${day}일(${dayOfWeek})`;
|
||||
} else {
|
||||
datePart = `${reservationYear}년 ${month}월 ${day}일(${dayOfWeek})`;
|
||||
}
|
||||
|
||||
let timePart = `${ampm} ${hours}시`;
|
||||
if (minutes !== 0) {
|
||||
timePart += ` ${minutes}분`;
|
||||
}
|
||||
|
||||
return `${datePart} ${timePart}`;
|
||||
};
|
||||
|
||||
// --- Cancellation View Component ---
|
||||
const CancellationView: React.FC<{
|
||||
reservation: ReservationDetail;
|
||||
onCancelSubmit: (reason: string) => void;
|
||||
onBack: () => void;
|
||||
isCancelling: boolean;
|
||||
}> = ({ reservation, onCancelSubmit, onBack, isCancelling }) => {
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!reason.trim()) {
|
||||
alert('취소 사유를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
onCancelSubmit(reason);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cancellation-view-v2">
|
||||
<h3>취소 정보 확인</h3>
|
||||
<div className="cancellation-summary-v2">
|
||||
<p><strong>테마:</strong> {reservation.themeName}</p>
|
||||
<p><strong>신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||
{reservation.payment && <p><strong>결제 금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>}
|
||||
</div>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="취소 사유를 입력해주세요."
|
||||
className="cancellation-reason-textarea-v2"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="modal-actions-v2">
|
||||
<button onClick={onBack} className="back-button-v2" disabled={isCancelling}>뒤로가기</button>
|
||||
<button onClick={handleSubmit} className="cancel-submit-button-v2" disabled={isCancelling}>
|
||||
{isCancelling ? '취소 중...' : '취소 요청'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const ReservationDetailView: React.FC<{
|
||||
reservation: ReservationDetail;
|
||||
onGoToCancel: () => void;
|
||||
}> = ({ reservation, onGoToCancel }) => {
|
||||
|
||||
const renderPaymentSubDetails = (payment: PaymentRetrieveResponse) => {
|
||||
if (!payment.detail) {
|
||||
return <p>결제 상세 정보를 찾을 수 없어요. 관리자에게 문의해주세요.</p>;
|
||||
}
|
||||
const { detail } = payment;
|
||||
|
||||
switch (detail.type) {
|
||||
case 'CARD':
|
||||
return (
|
||||
<>
|
||||
{payment.totalAmount !== detail.amount && (
|
||||
<>
|
||||
<p><strong>(카드)승인 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
||||
{detail.easypayDiscountAmount && (
|
||||
<p><strong>(간편결제)할인 금액:</strong> {detail.easypayDiscountAmount.toLocaleString()}원</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{detail.easypayProviderName && (
|
||||
<p><strong>간편결제사: </strong> {detail.easypayProviderName}</p>
|
||||
)}
|
||||
<p><strong>카드사 / 구분:</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
|
||||
<p><strong>카드 번호:</strong> {detail.cardNumber}</p>
|
||||
<p><strong>할부 방식:</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
|
||||
<p><strong>승인 번호:</strong> {detail.approvalNumber}</p>
|
||||
</>
|
||||
);
|
||||
case 'BANK_TRANSFER':
|
||||
return (
|
||||
<>
|
||||
<p><strong>은행:</strong> {detail.bankName}</p>
|
||||
<p><strong>정산 상태:</strong> {detail.settlementStatus}</p>
|
||||
</>
|
||||
);
|
||||
case 'EASYPAY_PREPAID':
|
||||
return (
|
||||
<>
|
||||
<p><strong>결제 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
||||
{detail.discountAmount > 0 && <p><strong>포인트 사용:</strong> {detail.discountAmount.toLocaleString()}원</p>}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <p>상세 결제 수단 정보를 표시할 수 없습니다.</p>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-section-v2">
|
||||
<h3>예약 정보</h3>
|
||||
<p><strong>예약 테마:</strong> {reservation.themeName}</p>
|
||||
<p><strong>이용 예정일:</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
|
||||
<p><strong>예약자 이름:</strong> {reservation.user.name}</p>
|
||||
<p><strong>예약자 이메일:</strong> {reservation.user.phone}</p>
|
||||
<p><strong>예약 신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||
</div>
|
||||
|
||||
{!reservation.payment ? (
|
||||
<div className="modal-section-v2">
|
||||
<h3>결제 정보</h3>
|
||||
<p>결제 정보를 찾을 수 없어요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="modal-section-v2">
|
||||
<h3>결제 정보</h3>
|
||||
<p><strong>주문 ID:</strong> {reservation.payment.orderId}</p>
|
||||
<p><strong>총 결제액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>
|
||||
<p><strong>결제 수단:</strong> {reservation.payment.method}</p>
|
||||
{reservation.payment.approvedAt && <p><strong>결제 승인 일시:</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>}
|
||||
</div>
|
||||
<div className="modal-section-v2">
|
||||
<h3>결제 상세 정보</h3>
|
||||
{renderPaymentSubDetails(reservation.payment)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{reservation.payment && reservation.payment.cancel && (
|
||||
<div className="modal-section-v2 cancellation-section-v2">
|
||||
<h3>취소 정보</h3>
|
||||
<p><strong>취소 요청 일시:</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</p>
|
||||
<p><strong>환불 완료 일시:</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</p>
|
||||
<p><strong>취소 사유:</strong> {reservation.payment.cancel.cancelReason}</p>
|
||||
<p><strong>취소 요청인:</strong> {reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p>
|
||||
</div>
|
||||
)}
|
||||
{reservation.payment && reservation.payment.status !== 'CANCELED' && (
|
||||
<div className="modal-actions-v2">
|
||||
<button onClick={onGoToCancel} className="cancel-button-v2">예약 취소하기</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main Page Component ---
|
||||
const MyReservationPage: React.FC = () => {
|
||||
const [reservations, setReservations] = useState<MyReservationRetrieveResponse[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail');
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const loadReservations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await fetchSummaryByMember();
|
||||
setReservations(data.reservations);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('예약 목록을 불러오는 데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyReservations()
|
||||
.then(res => setReservations(res.reservations))
|
||||
.catch(handleError);
|
||||
loadReservations();
|
||||
}, []);
|
||||
|
||||
const _cancelWaiting = (id: string) => {
|
||||
cancelWaiting(id)
|
||||
.then(() => {
|
||||
alert('예약 대기가 취소되었습니다.');
|
||||
setReservations(reservations.filter(r => r.id.toString() !== id));
|
||||
})
|
||||
.catch(handleError);
|
||||
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
|
||||
try {
|
||||
setIsDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setModalView('detail');
|
||||
const detailData = await fetchDetailById(id);
|
||||
setSelectedReservation({
|
||||
id: detailData.id,
|
||||
themeName: themeName,
|
||||
date: date,
|
||||
startAt: time,
|
||||
user: detailData.user,
|
||||
applicationDateTime: detailData.applicationDateTime,
|
||||
payment: detailData.payment
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
} catch (err) {
|
||||
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: ReservationStatus, rank: number) => {
|
||||
if (status === ReservationStatus.CONFIRMED) {
|
||||
return '예약';
|
||||
}
|
||||
if (status === ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) {
|
||||
return '예약 - 결제 필요';
|
||||
}
|
||||
if (status === ReservationStatus.WAITING) {
|
||||
return `${rank}번째 예약 대기`;
|
||||
}
|
||||
return '';
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedReservation(null);
|
||||
};
|
||||
|
||||
const handleCancelSubmit = async (reason: string) => {
|
||||
if (!selectedReservation) return;
|
||||
|
||||
if (!window.confirm('정말 취소하시겠어요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCancelling(true);
|
||||
setDetailError(null);
|
||||
await cancelReservation(selectedReservation.id, reason);
|
||||
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
|
||||
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
|
||||
handleCloseModal();
|
||||
await loadReservations(); // Refresh the list
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
|
||||
} finally {
|
||||
setIsCancelling(true);
|
||||
}
|
||||
};
|
||||
console.log("reservations=", reservations);
|
||||
|
||||
return (
|
||||
<div className="content-container">
|
||||
<h2 className="content-container-title">내 예약</h2>
|
||||
<div className="table-container"></div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>테마</th>
|
||||
<th>날짜</th>
|
||||
<th>시간</th>
|
||||
<th>상태</th>
|
||||
<th>대기 취소</th>
|
||||
<th>paymentKey</th>
|
||||
<th>결제금액</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reservations.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.themeName}</td>
|
||||
<td>{r.date}</td>
|
||||
<td>{r.time}</td>
|
||||
<td>{getStatusText(r.status, r.rank)}</td>
|
||||
<td>
|
||||
{r.status === ReservationStatus.WAITING &&
|
||||
<button className="btn btn-danger" onClick={() => _cancelWaiting(r.id.toString())}>취소</button>}
|
||||
</td>
|
||||
<td>{r.paymentKey}</td>
|
||||
<td>{r.amount}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="my-reservation-container-v2">
|
||||
<h1>내 예약 V2</h1>
|
||||
|
||||
{isLoading && <p>목록 로딩 중...</p>}
|
||||
{error && <p className="error-message-v2">{error}</p>}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="reservation-list-v2">
|
||||
{reservations.map((res) => {
|
||||
const status = getReservationStatus(res);
|
||||
return (
|
||||
<div key={res.id} className={`reservation-summary-card-v2 ${status.className}`}>
|
||||
<div className="card-status-badge">{status.text}</div>
|
||||
<div className="summary-details-v2">
|
||||
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
|
||||
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
|
||||
disabled={isDetailLoading}
|
||||
className="detail-button-v2"
|
||||
>
|
||||
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && selectedReservation && (
|
||||
<div className="modal-overlay-v2" onClick={handleCloseModal}>
|
||||
<div className="modal-content-v2" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close-button-v2" onClick={handleCloseModal}>×</button>
|
||||
<h2>{modalView === 'detail' ? '예약 상세 정보' : '예약 취소'}</h2>
|
||||
{detailError && <p className="error-message-v2">{detailError}</p>}
|
||||
|
||||
{modalView === 'detail' ? (
|
||||
<ReservationDetailView
|
||||
reservation={selectedReservation}
|
||||
onGoToCancel={() => setModalView('cancel')}
|
||||
/>
|
||||
) : (
|
||||
<CancellationView
|
||||
reservation={selectedReservation}
|
||||
onCancelSubmit={handleCancelSubmit}
|
||||
onBack={() => setModalView('detail')}
|
||||
isCancelling={isCancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { createPendingReservation } from '@_api/reservation/reservationAPIV2';
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {createPendingReservation} from '@_api/reservation/reservationAPI';
|
||||
import {fetchContact} from '@_api/user/userAPI';
|
||||
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';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||
|
||||
const ReservationFormPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -12,8 +13,25 @@ const ReservationFormPage: React.FC = () => {
|
||||
|
||||
const [reserverName, setReserverName] = useState('');
|
||||
const [reserverContact, setReserverContact] = useState('');
|
||||
const [participantCount, setParticipantCount] = useState(2);
|
||||
const [participantCount, setParticipantCount] = useState(theme.minParticipants || 1);
|
||||
const [requirement, setRequirement] = useState('');
|
||||
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const userContact = await fetchContact();
|
||||
setReserverName(userContact.name || '');
|
||||
setReserverContact(userContact.phone || '');
|
||||
} catch (err) {
|
||||
console.warn('사용자 정보를 가져오지 못했습니다:', err);
|
||||
} finally {
|
||||
setIsLoadingUserInfo(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserInfo();
|
||||
}, []);
|
||||
|
||||
const handleError = (err: any) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
@ -26,10 +44,6 @@ const ReservationFormPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCountChange = (delta: number) => {
|
||||
setParticipantCount(prev => Math.max(theme.minParticipants, Math.min(theme.maxParticipants, prev + delta)));
|
||||
};
|
||||
|
||||
const handlePayment = () => {
|
||||
if (!reserverName || !reserverContact) {
|
||||
alert('예약자명과 연락처를 입력해주세요.');
|
||||
@ -46,7 +60,7 @@ const ReservationFormPage: React.FC = () => {
|
||||
|
||||
createPendingReservation(reservationData)
|
||||
.then(res => {
|
||||
navigate('/v2-1/reservation/payment', {
|
||||
navigate('/reservation/payment', {
|
||||
state: {
|
||||
reservationId: res.id,
|
||||
themeName: theme.name,
|
||||
@ -60,7 +74,6 @@ const ReservationFormPage: React.FC = () => {
|
||||
};
|
||||
|
||||
if (!scheduleId || !theme) {
|
||||
// Handle case where state is not passed correctly
|
||||
return (
|
||||
<div className="reservation-v21-container">
|
||||
<h2 className="page-title">잘못된 접근</h2>
|
||||
@ -85,11 +98,25 @@ const ReservationFormPage: React.FC = () => {
|
||||
<h3>예약자 정보</h3>
|
||||
<div className="form-group">
|
||||
<label htmlFor="reserverName">예약자명</label>
|
||||
<input type="text" id="reserverName" value={reserverName} onChange={e => setReserverName(e.target.value)} />
|
||||
<input
|
||||
type="text"
|
||||
id="reserverName"
|
||||
value={reserverName}
|
||||
onChange={e => setReserverName(e.target.value)}
|
||||
disabled={isLoadingUserInfo}
|
||||
placeholder={isLoadingUserInfo ? "로딩 중..." : "예약자명을 입력하세요"}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="reserverContact">연락처</label>
|
||||
<input type="tel" id="reserverContact" value={reserverContact} onChange={e => setReserverContact(e.target.value)} placeholder="'-' 없이 입력"/>
|
||||
<input
|
||||
type="tel"
|
||||
id="reserverContact"
|
||||
value={reserverContact}
|
||||
onChange={e => setReserverContact(e.target.value)}
|
||||
disabled={isLoadingUserInfo}
|
||||
placeholder={isLoadingUserInfo ? "로딩 중..." : "'-' 없이 입력"}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>인원</label>
|
||||
@ -1,198 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Flatpickr from 'react-flatpickr';
|
||||
import 'flatpickr/dist/flatpickr.min.css';
|
||||
import { fetchThemes } from '@_api/theme/themeAPI';
|
||||
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
|
||||
import { createReservationWithPayment, createWaiting } from '@_api/reservation/reservationAPI';
|
||||
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PaymentWidget: any;
|
||||
}
|
||||
}
|
||||
|
||||
const ReservationPage: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
||||
const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<string | null>(null);
|
||||
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
|
||||
const [selectedTime, setSelectedTime] = useState<{ id: string, isAvailable: boolean } | null>(null);
|
||||
const paymentWidgetRef = useRef<any>(null);
|
||||
const paymentMethodsRef = useRef<any>(null);
|
||||
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 script = document.createElement('script');
|
||||
script.src = 'https://js.tosspayments.com/v1/payment-widget';
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
|
||||
const paymentWidget = window.PaymentWidget(widgetClientKey, window.PaymentWidget.ANONYMOUS);
|
||||
paymentWidgetRef.current = paymentWidget;
|
||||
|
||||
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||
"#payment-method",
|
||||
{ value: 1000 },
|
||||
{ variantKey: "DEFAULT" }
|
||||
);
|
||||
paymentMethodsRef.current = paymentMethods;
|
||||
};
|
||||
|
||||
fetchThemes().then(res => setThemes(res.themes)).catch(handleError);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && selectedTheme) {
|
||||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||||
fetchTimesWithAvailability(dateStr, selectedTheme)
|
||||
.then(res => {
|
||||
setTimes(res.times);
|
||||
setSelectedTime(null);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
}, [selectedDate, selectedTheme]);
|
||||
|
||||
const handleReservation = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime || !paymentWidgetRef.current) {
|
||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reservationData = {
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
themeId: selectedTheme,
|
||||
timeId: selectedTime.id,
|
||||
};
|
||||
|
||||
const generateRandomString = () =>
|
||||
crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
paymentWidgetRef.current.requestPayment({
|
||||
orderId: generateRandomString(),
|
||||
orderName: "테스트 방탈출 예약 결제 1건",
|
||||
amount: 1000,
|
||||
}).then(function (data: any) {
|
||||
const reservationPaymentRequest = {
|
||||
...reservationData,
|
||||
paymentKey: data.paymentKey,
|
||||
orderId: data.orderId,
|
||||
amount: data.amount,
|
||||
paymentType: data.paymentType,
|
||||
};
|
||||
createReservationWithPayment(reservationPaymentRequest)
|
||||
.then(() => {
|
||||
alert("예약이 완료되었습니다.");
|
||||
window.location.href = "/";
|
||||
})
|
||||
.catch(handleError);
|
||||
}).catch(function (error: any) {
|
||||
// This is a client-side error from Toss Payments, not our API
|
||||
console.error("Payment request error:", error);
|
||||
alert("결제 요청 중 오류가 발생했습니다.");
|
||||
});
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) {
|
||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reservationData = {
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
themeId: selectedTheme,
|
||||
timeId: selectedTime.id,
|
||||
};
|
||||
|
||||
createWaiting(reservationData)
|
||||
.then(() => {
|
||||
alert('예약 대기가 완료되었습니다.');
|
||||
window.location.href = "/";
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
const isReserveButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
|
||||
const isWaitButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || selectedTime.isAvailable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="content-container col-md-10 offset-md-1 p-5">
|
||||
<h2 className="content-container-title">예약 페이지</h2>
|
||||
<div className="d-flex" id="reservation-container">
|
||||
<div className="section border rounded col-md-4 p-3" id="date-section">
|
||||
<h3 className="fs-5 text-center mb-3">날짜 선택</h3>
|
||||
<div className="d-flex justify-content-center p-3">
|
||||
<Flatpickr
|
||||
value={selectedDate || undefined}
|
||||
onChange={([date]) => setSelectedDate(date)}
|
||||
options={{ inline: true, defaultDate: new Date() }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`section border rounded col-md-4 p-3 ${!selectedDate ? 'disabled' : ''}`} id="theme-section">
|
||||
<h3 className="fs-5 text-center mb-3">테마 선택</h3>
|
||||
<div className="p-3" id="theme-slots">
|
||||
{themes.map(theme => (
|
||||
<div key={theme.id}
|
||||
className={`theme-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTheme === theme.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTheme(theme.id)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`section border rounded col-md-4 p-3 ${!selectedTheme ? 'disabled' : ''}`} id="time-section">
|
||||
<h3 className="fs-5 text-center mb-3">시간 선택</h3>
|
||||
<div className="p-3" id="time-slots">
|
||||
{times.length > 0 ? times.map(time => (
|
||||
<div key={time.id}
|
||||
className={`time-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTime?.id === time.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTime({ id: time.id, isAvailable: time.isAvailable })}>
|
||||
{time.startAt}
|
||||
</div>
|
||||
)) : <div className="no-times">선택할 수 있는 시간이 없습니다.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="button-group float-end">
|
||||
<button id="wait-button" className="btn btn-secondary mt-3" disabled={isWaitButtonDisabled} onClick={handleWaiting}>예약대기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wrapper w-100">
|
||||
<div className="max-w-540 w-100">
|
||||
<div id="payment-method" className="w-100"></div>
|
||||
<div id="agreement" className="w-100"></div>
|
||||
<div className="btn-wrapper w-100">
|
||||
<button id="reserve-button" className="btn primary w-100" disabled={isReserveButtonDisabled} onClick={handleReservation}>예약하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationPage;
|
||||
@ -1,49 +1,19 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { holdSchedule, findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
|
||||
import { ScheduleStatus, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { findThemesByIds } from '@_api/theme/themeAPI';
|
||||
import { Difficulty } from '@_api/theme/themeTypes';
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||||
import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes';
|
||||
import {findThemesByIds} from '@_api/theme/themeAPI';
|
||||
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||
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';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||
|
||||
interface ThemeV21 {
|
||||
id: string;
|
||||
name: string;
|
||||
difficulty: Difficulty;
|
||||
description: string;
|
||||
thumbnailUrl: string;
|
||||
price: number;
|
||||
minParticipants: number;
|
||||
maxParticipants: number;
|
||||
expectedMinutesFrom: number;
|
||||
expectedMinutesTo: number;
|
||||
availableMinutes: number;
|
||||
}
|
||||
|
||||
const getDifficultyText = (difficulty: Difficulty): string => {
|
||||
switch (difficulty) {
|
||||
case Difficulty.VERY_EASY:
|
||||
return '매우 쉬움';
|
||||
case Difficulty.EASY:
|
||||
return '쉬움';
|
||||
case Difficulty.NORMAL:
|
||||
return '보통';
|
||||
case Difficulty.HARD:
|
||||
return '어려움';
|
||||
case Difficulty.VERY_HARD:
|
||||
return '매우 어려움';
|
||||
default:
|
||||
return difficulty;
|
||||
}
|
||||
};
|
||||
|
||||
const ReservationStep1PageV21: React.FC = () => {
|
||||
const ReservationStep1Page: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
|
||||
const [themes, setThemes] = useState<ThemeV21[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null);
|
||||
const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
|
||||
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
@ -77,9 +47,17 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
}
|
||||
})
|
||||
.then(themeResponse => {
|
||||
setThemes(themeResponse.themes as ThemeV21[]);
|
||||
setThemes(themeResponse.themes.map(mapThemeResponse));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
setThemes([]);
|
||||
} else {
|
||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||
alert(message);
|
||||
console.error(err);
|
||||
}
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
setSelectedTheme(null);
|
||||
setSchedules([]);
|
||||
@ -96,7 +74,16 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
setSchedules(res.schedules);
|
||||
setSelectedSchedule(null);
|
||||
})
|
||||
.catch(handleError);
|
||||
.catch((err) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
setSchedules([]);
|
||||
} else {
|
||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||
alert(message);
|
||||
console.error(err);
|
||||
}
|
||||
setSelectedSchedule(null);
|
||||
});
|
||||
}
|
||||
}, [selectedDate, selectedTheme]);
|
||||
|
||||
@ -117,7 +104,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
|
||||
holdSchedule(selectedSchedule.id)
|
||||
.then(() => {
|
||||
navigate('/v2/reservation/form', {
|
||||
navigate('/reservation/form', {
|
||||
state: {
|
||||
scheduleId: selectedSchedule.id,
|
||||
theme: selectedTheme,
|
||||
@ -197,7 +184,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const openThemeModal = (theme: ThemeV21) => {
|
||||
const openThemeModal = (theme: ThemeInfoResponse) => {
|
||||
setSelectedTheme(theme);
|
||||
setIsThemeModalOpen(true);
|
||||
};
|
||||
@ -237,7 +224,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<h4>{theme.name}</h4>
|
||||
<div className="theme-meta">
|
||||
<p><strong>1인당 요금:</strong> {theme.price.toLocaleString()}원</p>
|
||||
<p><strong>난이도:</strong> {getDifficultyText(theme.difficulty)}</p>
|
||||
<p><strong>난이도:</strong> {theme.difficulty}</p>
|
||||
<p><strong>참여 가능 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
||||
<p><strong>이용 가능 시간:</strong> {theme.availableMinutes}분</p>
|
||||
@ -279,7 +266,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<h2>{selectedTheme.name}</h2>
|
||||
<div className="modal-section">
|
||||
<h3>테마 정보</h3>
|
||||
<p><strong>난이도:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
|
||||
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||
<p><strong>1인당 요금:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||
@ -313,4 +300,4 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationStep1PageV21;
|
||||
export default ReservationStep1Page;
|
||||
@ -1,11 +1,11 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { confirmPayment } from '@_api/payment/paymentAPI';
|
||||
import { PaymentType, type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
||||
import { confirmReservation } from '@_api/reservation/reservationAPIV2';
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {confirmPayment} from '@_api/payment/paymentAPI';
|
||||
import {type PaymentConfirmRequest, PaymentType} from '@_api/payment/PaymentTypes';
|
||||
import {confirmReservation} from '@_api/reservation/reservationAPI';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -13,7 +13,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const ReservationStep2PageV21: React.FC = () => {
|
||||
const ReservationStep2Page: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const paymentWidgetRef = useRef<any>(null);
|
||||
@ -35,7 +35,7 @@ const ReservationStep2PageV21: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!reservationId) {
|
||||
alert('잘못된 접근입니다.');
|
||||
navigate('/v2-1/reservation');
|
||||
navigate('/reservation');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -85,7 +85,7 @@ const ReservationStep2PageV21: React.FC = () => {
|
||||
})
|
||||
.then(() => {
|
||||
alert('결제가 완료되었어요!');
|
||||
navigate('/v2-1/reservation/success', {
|
||||
navigate('/reservation/success', {
|
||||
state: {
|
||||
themeName,
|
||||
date,
|
||||
@ -128,4 +128,4 @@ const ReservationStep2PageV21: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationStep2PageV21;
|
||||
export default ReservationStep2Page;
|
||||
@ -1,9 +1,9 @@
|
||||
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 {Link, useLocation} from 'react-router-dom';
|
||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||
|
||||
const ReservationSuccessPageV21: React.FC = () => {
|
||||
const ReservationSuccessPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const { themeName, date, startAt } = (location.state as {
|
||||
themeName: string;
|
||||
@ -25,7 +25,7 @@ const ReservationSuccessPageV21: React.FC = () => {
|
||||
<p><strong>시간:</strong> {formattedTime}</p>
|
||||
</div>
|
||||
<div className="success-page-actions">
|
||||
<Link to="/my-reservation/v2" className="action-button">
|
||||
<Link to="/my-reservation" className="action-button">
|
||||
내 예약 목록
|
||||
</Link>
|
||||
<Link to="/" className="action-button secondary">
|
||||
@ -36,4 +36,4 @@ const ReservationSuccessPageV21: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationSuccessPageV21;
|
||||
export default ReservationSuccessPage;
|
||||
@ -1,42 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { signup } from '@_api/member/memberAPI';
|
||||
import type { SignupRequest } from '@_api/member/memberTypes';
|
||||
import {signup} from '@_api/user/userAPI';
|
||||
import type {UserCreateRequest, UserCreateResponse} from '@_api/user/userTypes';
|
||||
import '@_css/signup-page-v2.css';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
const SignupPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignup = async () => {
|
||||
const request: SignupRequest = { email, password, name };
|
||||
await signup(request)
|
||||
.then((response) => {
|
||||
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
|
||||
navigate('/login')
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = '이름을 입력해주세요.';
|
||||
}
|
||||
if (!email.trim()) {
|
||||
newErrors.email = '이메일을 입력해주세요.';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
newErrors.password = `비밀번호는 최소 ${MIN_PASSWORD_LENGTH}자리 이상이어야 합니다.`;
|
||||
}
|
||||
if (!phone.trim()) {
|
||||
newErrors.phone = '전화번호를 입력해주세요.';
|
||||
} else if (!/^010([0-9]{3,4})([0-9]{4})$/.test(phone)) {
|
||||
newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)';
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
// 제출 이후에는 입력값이 바뀔 때마다 다시 validate 실행
|
||||
useEffect(() => {
|
||||
if (hasSubmitted) {
|
||||
setErrors(validate());
|
||||
}
|
||||
}, [email, password, name, phone, hasSubmitted]);
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setHasSubmitted(true);
|
||||
|
||||
const newErrors = validate();
|
||||
setErrors(newErrors);
|
||||
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
const request: UserCreateRequest = { email, password, name, phone, regionCode: null };
|
||||
try {
|
||||
const response: UserCreateResponse = await signup(request);
|
||||
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
|
||||
navigate('/login');
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ||
|
||||
'회원가입에 실패했어요. 입력 정보를 확인해주세요.';
|
||||
alert(message);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-container" style={{ width: '400px' }}>
|
||||
<h2 className="content-container-title">Signup</h2>
|
||||
<div className="form-group">
|
||||
<label>Email address</label>
|
||||
<input type="email" className="form-control" placeholder="Enter email" value={email} onChange={e => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" className="form-control" placeholder="Enter password" value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" className="form-control" placeholder="Enter name" value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-custom" onClick={handleSignup}>Register</button>
|
||||
<div className="signup-container-v2">
|
||||
<h2 className="page-title">회원가입</h2>
|
||||
<form className="signup-form-v2" onSubmit={handleSignup}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="이메일을 입력하세요"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{hasSubmitted && errors.email && (
|
||||
<p className="error-text">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{hasSubmitted && errors.password && (
|
||||
<p className="error-text">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="이름을 입력하세요"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{hasSubmitted && errors.name && (
|
||||
<p className="error-text">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">전화번호</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="전화번호를 입력하세요('-' 제외)"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{hasSubmitted && errors.phone && (
|
||||
<p className="error-text">{errors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={hasSubmitted && Object.keys(errors).length > 0}
|
||||
>
|
||||
가입하기
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import React, {type ReactNode} from 'react';
|
||||
import AdminNavbar from './AdminNavbar';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import '../../css/navbar.css';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useAuth} from '@_context/AuthContext';
|
||||
import '@_css/navbar.css';
|
||||
|
||||
const AdminNavbar: React.FC = () => {
|
||||
const { loggedIn, userName, logout } = useAuth();
|
||||
@ -21,10 +21,7 @@ const AdminNavbar: React.FC = () => {
|
||||
<nav className="navbar-container">
|
||||
<div className="nav-links">
|
||||
<Link className="nav-link" to="/admin">홈</Link>
|
||||
<Link className="nav-link" to="/admin/reservation">예약</Link>
|
||||
<Link className="nav-link" to="/admin/waiting">대기</Link>
|
||||
<Link className="nav-link" to="/admin/theme">테마</Link>
|
||||
<Link className="nav-link" to="/admin/time">시간</Link>
|
||||
<Link className="nav-link" to="/admin/schedule">일정</Link>
|
||||
</div>
|
||||
<div className="nav-actions">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import '../../css/admin-page.css';
|
||||
import '@_css/admin-page.css';
|
||||
|
||||
const AdminPage: React.FC = () => {
|
||||
return (
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { createSchedule, deleteSchedule, findScheduleById, findSchedules, updateSchedule } from '@_api/schedule/scheduleAPI';
|
||||
import { ScheduleStatus, type ScheduleDetailRetrieveResponse, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { fetchAdminThemes } from '@_api/theme/themeAPI';
|
||||
import type { AdminThemeSummaryRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
findScheduleById,
|
||||
findSchedules,
|
||||
updateSchedule
|
||||
} from '@_api/schedule/scheduleAPI';
|
||||
import {
|
||||
type ScheduleDetailRetrieveResponse,
|
||||
type ScheduleRetrieveResponse,
|
||||
ScheduleStatus
|
||||
} from '@_api/schedule/scheduleTypes';
|
||||
import {fetchAdminThemes} from '@_api/theme/themeAPI';
|
||||
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
|
||||
import '@_css/admin-schedule-page.css';
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import React, {Fragment, useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
|
||||
const getScheduleStatusText = (status: ScheduleStatus): string => {
|
||||
switch (status) {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
|
||||
import {
|
||||
createThemeV2,
|
||||
deleteTheme,
|
||||
fetchAdminThemeDetail,
|
||||
updateTheme
|
||||
} from '@_api/theme/themeAPI';
|
||||
import { Difficulty, type ThemeCreateRequestV2, type ThemeUpdateRequest, type ThemeV2 } from '@_api/theme/themeTypes';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import '../../css/admin-theme-edit-page.css';
|
||||
type AdminThemeDetailResponse,
|
||||
Difficulty,
|
||||
type ThemeCreateRequest,
|
||||
type ThemeUpdateRequest
|
||||
} from '@_api/theme/themeTypes';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
||||
import '@_css/admin-theme-edit-page.css';
|
||||
|
||||
const AdminThemeEditPage: React.FC = () => {
|
||||
const { themeId } = useParams<{ themeId: string }>();
|
||||
@ -17,8 +17,8 @@ const AdminThemeEditPage: React.FC = () => {
|
||||
|
||||
const isNew = themeId === 'new';
|
||||
|
||||
const [theme, setTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null);
|
||||
const [originalTheme, setOriginalTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null);
|
||||
const [theme, setTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
||||
const [originalTheme, setOriginalTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
|
||||
@ -35,7 +35,7 @@ const AdminThemeEditPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
const newTheme: ThemeCreateRequestV2 = {
|
||||
const newTheme: ThemeCreateRequest = {
|
||||
name: '',
|
||||
description: '',
|
||||
thumbnailUrl: '',
|
||||
@ -55,7 +55,7 @@ const AdminThemeEditPage: React.FC = () => {
|
||||
fetchAdminThemeDetail(themeId)
|
||||
.then(data => {
|
||||
// Map AdminThemeDetailRetrieveResponse to ThemeV2
|
||||
const fetchedTheme: ThemeV2 = {
|
||||
const fetchedTheme: AdminThemeDetailResponse = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
@ -112,7 +112,7 @@ const AdminThemeEditPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
await createThemeV2(theme as ThemeCreateRequestV2);
|
||||
await createTheme(theme as ThemeCreateRequest);
|
||||
alert('테마가 성공적으로 생성되었습니다.');
|
||||
navigate(`/admin/theme`);
|
||||
} else {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { fetchAdminThemes } from '@_api/theme/themeAPI';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {fetchAdminThemes} from '@_api/theme/themeAPI';
|
||||
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import '../../css/admin-theme-page.css';
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import '@_css/admin-theme-page.css';
|
||||
|
||||
const AdminThemePage: React.FC = () => {
|
||||
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
|
||||
@ -1,204 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
cancelReservationByAdmin,
|
||||
createReservationByAdmin,
|
||||
fetchReservations,
|
||||
searchReservations
|
||||
} from '@_api/reservation/reservationAPI';
|
||||
import { fetchMembers } from '@_api/member/memberAPI';
|
||||
import { fetchThemes } from '@_api/theme/themeAPI';
|
||||
import { fetchTimes } from '@_api/time/timeAPI';
|
||||
import type { ReservationRetrieveResponse } from '@_api/reservation/reservationTypes';
|
||||
import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
|
||||
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import '../../css/admin-reservation-page.css';
|
||||
|
||||
const AdminReservationPage: React.FC = () => {
|
||||
const [reservations, setReservations] = useState<ReservationRetrieveResponse[]>([]);
|
||||
const [members, setMembers] = useState<MemberRetrieveResponse[]>([]);
|
||||
const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]);
|
||||
const [times, setTimes] = useState<TimeRetrieveResponse[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [newReservation, setNewReservation] = useState({ memberId: '', themeId: '', date: '', timeId: '' });
|
||||
const [filter, setFilter] = useState({ memberId: '', themeId: '', dateFrom: '', dateTo: '' });
|
||||
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(() => {
|
||||
_fetchReservations();
|
||||
fetchMembers().then(res => setMembers(res.members)).catch(handleError);
|
||||
fetchThemes().then(res => setThemes(res.themes)).catch(handleError);
|
||||
fetchTimes().then(res => setTimes(res.times)).catch(handleError);
|
||||
}, []);
|
||||
|
||||
const _fetchReservations = () => {
|
||||
fetchReservations()
|
||||
.then(res => setReservations(res.reservations))
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setFilter({ ...filter, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const applyFilter = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params = {
|
||||
memberId: filter.memberId ? filter.memberId : undefined,
|
||||
themeId: filter.themeId ? filter.themeId : undefined,
|
||||
dateFrom: filter.dateFrom,
|
||||
dateTo: filter.dateTo,
|
||||
};
|
||||
searchReservations(params)
|
||||
.then(res => setReservations(res.reservations))
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
const handleAddClick = () => setIsEditing(true);
|
||||
const handleCancelClick = () => setIsEditing(false);
|
||||
|
||||
const handleSaveClick = async () => {
|
||||
if (!newReservation.memberId || !newReservation.themeId || !newReservation.date || !newReservation.timeId) {
|
||||
alert('모든 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
const request = {
|
||||
memberId: newReservation.memberId,
|
||||
themeId: newReservation.themeId,
|
||||
date: newReservation.date,
|
||||
timeId: newReservation.timeId,
|
||||
};
|
||||
await createReservationByAdmin(request)
|
||||
.then(() => {
|
||||
alert('예약을 추가했어요. 결제는 별도로 진행해주세요.');
|
||||
_fetchReservations();
|
||||
handleCancelClick();
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
const deleteReservation = async(id: string) => {
|
||||
if (!window.confirm('정말 삭제하시겠어요?')) {
|
||||
return;
|
||||
}
|
||||
await cancelReservationByAdmin(id)
|
||||
.then(() => {
|
||||
setReservations(reservations.filter(r => r.id !== id))
|
||||
alert('예약을 삭제했어요.');
|
||||
}).catch(handleError);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-reservation-container">
|
||||
<h2 className="page-title">예약 관리</h2>
|
||||
<div className="admin-reservation-content">
|
||||
<div className="reservations-main section-card">
|
||||
<div className="table-header">
|
||||
<button className="btn btn-primary" onClick={handleAddClick}>예약 추가</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>예약번호</th>
|
||||
<th>예약자</th>
|
||||
<th>테마</th>
|
||||
<th>날짜</th>
|
||||
<th>시간</th>
|
||||
<th>상태</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reservations.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td>{r.member.name}</td>
|
||||
<td>{r.theme.name}</td>
|
||||
<td>{r.date}</td>
|
||||
<td>{r.time.startAt}</td>
|
||||
<td>{r.status === 'CONFIRMED' ? '결제 완료' : '결제 대기'}</td>
|
||||
<td><button className="btn btn-danger" onClick={() => deleteReservation(r.id)}>삭제</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{isEditing && (
|
||||
<tr className="editing-row">
|
||||
<td></td>
|
||||
<td>
|
||||
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, memberId: e.target.value })}>
|
||||
<option value="">멤버 선택</option>
|
||||
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, themeId: e.target.value })}>
|
||||
<option value="">테마 선택</option>
|
||||
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="date" className="form-input" onChange={e => setNewReservation({ ...newReservation, date: e.target.value })} /></td>
|
||||
<td>
|
||||
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, timeId: e.target.value })}>
|
||||
<option value="">시간 선택</option>
|
||||
{times.map(t => <option key={t.id} value={t.id}>{t.startAt}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<button className="btn btn-primary" onClick={handleSaveClick}>확인</button>
|
||||
<button className="btn btn-secondary" onClick={handleCancelClick}>취소</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-section section-card">
|
||||
<h3 className="card-title">검색 필터</h3>
|
||||
<form id="filter-form" onSubmit={applyFilter}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="member">예약자</label>
|
||||
<select id="member" name="memberId" className="form-select" onChange={handleFilterChange}>
|
||||
<option value="">전체</option>
|
||||
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="theme">테마</label>
|
||||
<select id="theme" name="themeId" className="form-select" onChange={handleFilterChange}>
|
||||
<option value="">전체</option>
|
||||
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="date-from">From</label>
|
||||
<input type="date" id="date-from" name="dateFrom" className="form-input" onChange={handleFilterChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="date-to">To</label>
|
||||
<input type="date" id="date-to" name="dateTo" className="form-input" onChange={handleFilterChange} />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">적용</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminReservationPage;
|
||||
@ -1,123 +0,0 @@
|
||||
import { createTime, delTime, fetchTimes } from '@_api/time/timeAPI';
|
||||
import type { TimeCreateRequest } from '@_api/time/timeTypes';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import '../../css/admin-time-page.css';
|
||||
|
||||
const AdminTimePage: React.FC = () => {
|
||||
const [times, setTimes] = useState<any[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [newTime, setNewTime] = useState('');
|
||||
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 fetchData = async () => {
|
||||
await fetchTimes()
|
||||
.then(response => setTimes(response.times))
|
||||
.catch(handleError);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleAddClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelClick = () => {
|
||||
setIsEditing(false);
|
||||
setNewTime('');
|
||||
};
|
||||
|
||||
const handleSaveClick = async () => {
|
||||
if (!newTime) {
|
||||
alert('시간을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!/^\d{2}:\d{2}$/.test(newTime)) {
|
||||
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
const request: TimeCreateRequest = {
|
||||
startAt: newTime
|
||||
};
|
||||
|
||||
await createTime(request)
|
||||
.then((response) => {
|
||||
setTimes([...times, response]);
|
||||
alert('시간을 추가했어요.');
|
||||
handleCancelClick();
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
const deleteTime = async (id: string) => {
|
||||
if (!window.confirm('정말 삭제하시겠어요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await delTime(id)
|
||||
.then(() => {
|
||||
setTimes(times.filter(time => time.id !== id));
|
||||
alert('시간을 삭제했어요.');
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-time-container">
|
||||
<h2 className="page-title">시간 관리</h2>
|
||||
<div className="section-card">
|
||||
<div className="table-header">
|
||||
<button className="btn btn-primary" onClick={handleAddClick}>시간 추가</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>시간</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{times.map(time => (
|
||||
<tr key={time.id}>
|
||||
<td>{time.id}</td>
|
||||
<td>{time.startAt}</td>
|
||||
<td>
|
||||
<button className="btn btn-danger" onClick={() => deleteTime(time.id)}>삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{isEditing && (
|
||||
<tr className="editing-row">
|
||||
<td></td>
|
||||
<td><input type="time" className="form-input" value={newTime} onChange={e => setNewTime(e.target.value)} /></td>
|
||||
<td>
|
||||
<button className="btn btn-primary" onClick={handleSaveClick}>확인</button>
|
||||
<button className="btn btn-secondary" onClick={handleCancelClick}>취소</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminTimePage;
|
||||
@ -1,89 +0,0 @@
|
||||
import { confirmWaiting, fetchWaitingReservations, rejectWaiting } from '@_api/reservation/reservationAPI';
|
||||
import type { ReservationRetrieveResponse } from '@_api/reservation/reservationTypes';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import '../../css/admin-waiting-page.css';
|
||||
|
||||
const AdminWaitingPage: React.FC = () => {
|
||||
const [waitings, setWaitings] = useState<ReservationRetrieveResponse[]>([]);
|
||||
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 fetchData = async () => {
|
||||
await fetchWaitingReservations()
|
||||
.then(res => setWaitings(res.reservations))
|
||||
.catch(handleError);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const approveWaiting = async (id: string) => {
|
||||
await confirmWaiting(id)
|
||||
.then(() => {
|
||||
alert('대기 중인 예약을 승인했어요. 결제는 별도로 진행해주세요.');
|
||||
setWaitings(waitings.filter(w => w.id !== id));
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
const denyWaiting = async (id: string) => {
|
||||
await rejectWaiting(id)
|
||||
.then(() => {
|
||||
alert('대기 중인 예약을 거절했어요.');
|
||||
setWaitings(waitings.filter(w => w.id !== id));
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-waiting-container">
|
||||
<h2 className="page-title">예약 대기 관리</h2>
|
||||
<div className="section-card">
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>예약대기 번호</th>
|
||||
<th>예약자</th>
|
||||
<th>테마</th>
|
||||
<th>날짜</th>
|
||||
<th>시간</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{waitings.map(w => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.id}</td>
|
||||
<td>{w.member.name}</td>
|
||||
<td>{w.theme.name}</td>
|
||||
<td>{w.date}</td>
|
||||
<td>{w.time.startAt}</td>
|
||||
<td>
|
||||
<button className="btn btn-primary" onClick={() => approveWaiting(w.id)}>승인</button>
|
||||
<button className="btn btn-danger" onClick={() => denyWaiting(w.id)}>거절</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminWaitingPage;
|
||||
@ -1,39 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { mostReservedThemes } from '../../api/theme/themeAPI';
|
||||
import '../../css/home-page-v2.css';
|
||||
|
||||
const HomePageV2: React.FC = () => {
|
||||
const [ranking, setRanking] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await mostReservedThemes(10);
|
||||
setRanking(response.themes);
|
||||
} catch (err) {
|
||||
console.error('Error fetching ranking:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home-container-v2">
|
||||
<h2 className="page-title">인기 테마</h2>
|
||||
<div className="theme-ranking-list-v2">
|
||||
{ranking.map(theme => (
|
||||
<div key={theme.id} className="theme-ranking-item-v2">
|
||||
<img className="thumbnail" src={theme.thumbnail} alt={theme.name} />
|
||||
<div className="theme-info">
|
||||
<h5 className="theme-name">{theme.name}</h5>
|
||||
<p className="theme-description">{theme.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePageV2;
|
||||
@ -1,63 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import '../../css/login-page-v2.css';
|
||||
|
||||
const LoginPageV2: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || '/';
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login({email, password});
|
||||
|
||||
alert('로그인에 성공했어요!');
|
||||
navigate(from, { replace: true });
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
|
||||
alert(message);
|
||||
console.error('로그인 실패:', error);
|
||||
setPassword('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container-v2">
|
||||
<h2 className="page-title">로그인</h2>
|
||||
<form className="login-form-v2" onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="이메일"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="비밀번호"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="button-group">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/signup')}>회원가입</button>
|
||||
<button type="submit" className="btn btn-primary">로그인</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPageV2;
|
||||
@ -1,342 +0,0 @@
|
||||
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 '../../css/my-reservation-v2.css';
|
||||
|
||||
const formatDisplayDateTime = (dateTime: any): string => {
|
||||
let date: Date;
|
||||
|
||||
if (typeof dateTime === 'string') {
|
||||
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
|
||||
date = new Date(dateTime);
|
||||
} else if (typeof dateTime === 'number') {
|
||||
// Unix 타임스탬프(초) 형식 처리
|
||||
date = new Date(dateTime * 1000);
|
||||
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
|
||||
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
|
||||
const year = dateTime[0];
|
||||
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
|
||||
const day = dateTime[2];
|
||||
const hour = dateTime[3];
|
||||
const minute = dateTime[4];
|
||||
const second = dateTime[5];
|
||||
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
|
||||
date = new Date(year, month, day, hour, minute, second, millisecond);
|
||||
} else {
|
||||
return '유효하지 않은 날짜 형식';
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return '유효하지 않은 날짜';
|
||||
}
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
second: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', options).format(date);
|
||||
};
|
||||
|
||||
const formatCardDateTime = (dateStr: string, timeStr: string): string => {
|
||||
const date = new Date(`${dateStr}T${timeStr}`);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const reservationYear = date.getFullYear();
|
||||
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const dayOfWeek = days[date.getDay()];
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
let hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const ampm = hours >= 12 ? '오후' : '오전';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
|
||||
let datePart = '';
|
||||
if (currentYear === reservationYear) {
|
||||
datePart = `${month}월 ${day}일(${dayOfWeek})`;
|
||||
} else {
|
||||
datePart = `${reservationYear}년 ${month}월 ${day}일(${dayOfWeek})`;
|
||||
}
|
||||
|
||||
let timePart = `${ampm} ${hours}시`;
|
||||
if (minutes !== 0) {
|
||||
timePart += ` ${minutes}분`;
|
||||
}
|
||||
|
||||
return `${datePart} ${timePart}`;
|
||||
};
|
||||
|
||||
// --- Cancellation View Component ---
|
||||
const CancellationView: React.FC<{
|
||||
reservation: ReservationDetail;
|
||||
onCancelSubmit: (reason: string) => void;
|
||||
onBack: () => void;
|
||||
isCancelling: boolean;
|
||||
}> = ({ reservation, onCancelSubmit, onBack, isCancelling }) => {
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!reason.trim()) {
|
||||
alert('취소 사유를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
onCancelSubmit(reason);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cancellation-view-v2">
|
||||
<h3>취소 정보 확인</h3>
|
||||
<div className="cancellation-summary-v2">
|
||||
<p><strong>테마:</strong> {reservation.themeName}</p>
|
||||
<p><strong>신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||
<p><strong>결제 금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>
|
||||
</div>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="취소 사유를 입력해주세요."
|
||||
className="cancellation-reason-textarea-v2"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="modal-actions-v2">
|
||||
<button onClick={onBack} className="back-button-v2" disabled={isCancelling}>뒤로가기</button>
|
||||
<button onClick={handleSubmit} className="cancel-submit-button-v2" disabled={isCancelling}>
|
||||
{isCancelling ? '취소 중...' : '취소 요청'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const ReservationDetailView: React.FC<{
|
||||
reservation: ReservationDetail;
|
||||
onGoToCancel: () => void;
|
||||
}> = ({ reservation, onGoToCancel }) => {
|
||||
|
||||
const renderPaymentDetails = (payment: PaymentRetrieveResponse) => {
|
||||
const { detail } = payment;
|
||||
|
||||
switch (detail.type) {
|
||||
case 'CARD':
|
||||
return (
|
||||
<>
|
||||
<p><strong>주문 ID:</strong> {payment.orderId}</p>
|
||||
{payment.totalAmount === detail.amount ? (
|
||||
<p><strong>결제 금액:</strong> {payment.totalAmount.toLocaleString()}원</p>
|
||||
) : (
|
||||
<>
|
||||
<p><strong>전체 금액:</strong> {payment.totalAmount.toLocaleString()}원</p>
|
||||
<p><strong>승인 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
||||
{detail.easypayDiscountAmount && (
|
||||
<p><strong>할인 금액:</strong> {detail.easypayDiscountAmount.toLocaleString()}원</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p><strong>결제 수단:</strong> {detail.easypayProviderName ? `간편결제 / ${detail.easypayProviderName}` : '카드'}</p>
|
||||
<p><strong>카드사 / 구분:</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
|
||||
<p><strong>카드 번호:</strong> {detail.cardNumber}</p>
|
||||
<p><strong>할부 방식:</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
|
||||
<p><strong>승인 번호:</strong> {detail.approvalNumber}</p>
|
||||
</>
|
||||
);
|
||||
case 'BANK_TRANSFER':
|
||||
return (
|
||||
<>
|
||||
<p><strong>결제 수단:</strong> 계좌이체</p>
|
||||
<p><strong>은행:</strong> {detail.bankName}</p>
|
||||
</>
|
||||
);
|
||||
case 'EASYPAY_PREPAID':
|
||||
return (
|
||||
<>
|
||||
<p><strong>결제 수단:</strong> 간편결제 / {detail.providerName}</p>
|
||||
<p><strong>총 금액 :</strong> {payment.totalAmount.toLocaleString()}원</p>
|
||||
<p><strong>결제 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
||||
{detail.discountAmount > 0 && <p><strong>포인트:</strong> {detail.discountAmount.toLocaleString()}원</p>}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <p><strong>결제 수단:</strong> {payment.method}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-section-v2">
|
||||
<h3>예약 정보</h3>
|
||||
<p><strong>예약 테마:</strong> {reservation.themeName}</p>
|
||||
<p><strong>이용 예정일:</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
|
||||
<p><strong>예약자 이름:</strong> {reservation.member.name}</p>
|
||||
<p><strong>예약자 이메일:</strong> {reservation.member.email}</p>
|
||||
<p><strong>예약 신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||
</div>
|
||||
<div className="modal-section-v2">
|
||||
<h3>결제 정보</h3>
|
||||
{/* <p><strong>결제금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p> */}
|
||||
{renderPaymentDetails(reservation.payment)}
|
||||
<p><strong>결제 승인 일시:</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
|
||||
</div>
|
||||
{reservation.payment.cancellation && (
|
||||
<div className="modal-section-v2 cancellation-section-v2">
|
||||
<h3>취소 정보</h3>
|
||||
<p><strong>취소 요청 일시:</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationRequestedAt)}</p>
|
||||
<p><strong>환불 완료 일시:</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationApprovedAt)}</p>
|
||||
<p><strong>취소 사유:</strong> {reservation.payment.cancellation.cancelReason}</p>
|
||||
<p><strong>취소 요청자:</strong> {reservation.payment.cancellation.canceledBy == reservation.member.id ? '회원 본인' : '관리자'}</p>
|
||||
</div>
|
||||
)}
|
||||
{reservation.payment.status !== 'CANCELED' && (
|
||||
<div className="modal-actions-v2">
|
||||
<button onClick={onGoToCancel} className="cancel-button-v2">예약 취소하기</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main Page Component ---
|
||||
const MyReservationPageV2: React.FC = () => {
|
||||
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail');
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const loadReservations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await fetchSummaryByMember();
|
||||
setReservations(data.reservations);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('예약 목록을 불러오는 데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadReservations();
|
||||
}, []);
|
||||
|
||||
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
|
||||
try {
|
||||
setIsDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setModalView('detail');
|
||||
const detailData = await fetchDetailById(id);
|
||||
setSelectedReservation({
|
||||
id: detailData.id,
|
||||
themeName: themeName,
|
||||
date: date,
|
||||
startAt: time,
|
||||
member: detailData.member,
|
||||
applicationDateTime: detailData.applicationDateTime,
|
||||
payment: detailData.payment
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
} catch (err) {
|
||||
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedReservation(null);
|
||||
};
|
||||
|
||||
const handleCancelSubmit = async (reason: string) => {
|
||||
if (!selectedReservation) return;
|
||||
|
||||
if (!window.confirm('정말 취소하시겠어요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCancelling(true);
|
||||
setDetailError(null);
|
||||
await cancelReservation(selectedReservation.id, reason);
|
||||
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
|
||||
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
|
||||
handleCloseModal();
|
||||
await loadReservations(); // Refresh the list
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
|
||||
} finally {
|
||||
setIsCancelling(true);
|
||||
}
|
||||
};
|
||||
console.log("reservations=", reservations);
|
||||
|
||||
return (
|
||||
<div className="my-reservation-container-v2">
|
||||
<h1>내 예약 V2</h1>
|
||||
|
||||
{isLoading && <p>목록 로딩 중...</p>}
|
||||
{error && <p className="error-message-v2">{error}</p>}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="reservation-list-v2">
|
||||
{reservations.map((res) => (
|
||||
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toString().toLowerCase()}`}>
|
||||
<div className="summary-details-v2">
|
||||
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
|
||||
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
|
||||
disabled={isDetailLoading}
|
||||
className="detail-button-v2"
|
||||
>
|
||||
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && selectedReservation && (
|
||||
<div className="modal-overlay-v2" onClick={handleCloseModal}>
|
||||
<div className="modal-content-v2" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close-button-v2" onClick={handleCloseModal}>×</button>
|
||||
<h2>{modalView === 'detail' ? '예약 상세 정보' : '예약 취소'}</h2>
|
||||
{detailError && <p className="error-message-v2">{detailError}</p>}
|
||||
|
||||
{modalView === 'detail' ? (
|
||||
<ReservationDetailView
|
||||
reservation={selectedReservation}
|
||||
onGoToCancel={() => setModalView('cancel')}
|
||||
/>
|
||||
) : (
|
||||
<CancellationView
|
||||
reservation={selectedReservation}
|
||||
onCancelSubmit={handleCancelSubmit}
|
||||
onBack={() => setModalView('detail')}
|
||||
isCancelling={isCancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyReservationPageV2;
|
||||
@ -1,156 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Flatpickr from 'react-flatpickr';
|
||||
import 'flatpickr/dist/flatpickr.min.css';
|
||||
import '@_css/reservation-v2.css';
|
||||
import { fetchThemes } from '@_api/theme/themeAPI';
|
||||
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
|
||||
import { createPendingReservation } from '@_api/reservation/reservationAPI';
|
||||
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
|
||||
const ReservationStep1Page: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
||||
const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeRetrieveResponse | null>(null);
|
||||
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
|
||||
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleError = (err: any) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
alert('로그인이 필요해요.');
|
||||
navigate('/login', { state: { from: location } });
|
||||
} else {
|
||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||
alert(message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchThemes().then(res => setThemes(res.themes)).catch(handleError);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && selectedTheme) {
|
||||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||||
fetchTimesWithAvailability(dateStr, selectedTheme.id)
|
||||
.then(res => {
|
||||
setTimes(res.times);
|
||||
setSelectedTime(null);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
}, [selectedDate, selectedTheme]);
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) {
|
||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTime.isAvailable) {
|
||||
alert('예약할 수 없는 시간입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmPayment = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) return;
|
||||
|
||||
const reservationData = {
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
themeId: selectedTheme.id,
|
||||
timeId: selectedTime.id,
|
||||
};
|
||||
|
||||
createPendingReservation(reservationData)
|
||||
.then((res) => {
|
||||
navigate('/v2/reservation/payment', { state: { reservation: res } });
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setIsModalOpen(false));
|
||||
};
|
||||
|
||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
|
||||
|
||||
return (
|
||||
<div className="content-container col-md-10 offset-md-1 p-5">
|
||||
<h2 className="content-container-title">방탈출 예약</h2>
|
||||
<div className="d-flex" id="reservation-container">
|
||||
<div className="section border rounded col-md-4 p-3" id="date-section">
|
||||
<h3 className="fs-5 text-center mb-3">날짜 선택</h3>
|
||||
<div className="d-flex justify-content-center">
|
||||
<Flatpickr
|
||||
value={selectedDate || undefined}
|
||||
onChange={([date]) => setSelectedDate(date)}
|
||||
options={{ inline: true, defaultDate: new Date(), minDate: "today" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`section border rounded col-md-4 p-3 ${!selectedDate ? 'disabled' : ''}`} id="theme-section">
|
||||
<h3 className="fs-5 text-center mb-3">테마 선택</h3>
|
||||
<div className="p-3" id="theme-slots">
|
||||
{themes.map(theme => (
|
||||
<div key={theme.id}
|
||||
className={`theme-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTheme?.id === theme.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTheme(theme)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`section border rounded col-md-4 p-3 ${!selectedTheme ? 'disabled' : ''}`} id="time-section">
|
||||
<h3 className="fs-5 text-center mb-3">시간 선택</h3>
|
||||
<div className="p-3" id="time-slots">
|
||||
{times.length > 0 ? times.map(time => (
|
||||
<div key={time.id}
|
||||
className={`time-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`}
|
||||
onClick={() => time.isAvailable && setSelectedTime(time)}>
|
||||
{time.startAt} {!time.isAvailable && '(예약불가)'}
|
||||
</div>
|
||||
)) : <div className="no-times">선택할 수 있는 시간이 없습니다.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="button-group float-end mt-3">
|
||||
<button className="btn btn-primary" disabled={isButtonDisabled} onClick={handleNextStep}>
|
||||
결제하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">예약 정보를 확인해주세요</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setIsModalOpen(false)}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p><strong>날짜:</strong> {selectedDate?.toLocaleDateString('ko-KR')}</p>
|
||||
<p><strong>테마:</strong> {selectedTheme?.name}</p>
|
||||
<p><strong>시간:</strong> {selectedTime?.startAt}</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setIsModalOpen(false)}>취소</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleConfirmPayment}>결제하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationStep1Page;
|
||||
@ -1,120 +0,0 @@
|
||||
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 { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PaymentType } from '@_api/payment/PaymentTypes';
|
||||
import '@_css/reservation-v2.css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PaymentWidget: any;
|
||||
}
|
||||
}
|
||||
|
||||
const ReservationStep2Page: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const paymentWidgetRef = useRef<any>(null);
|
||||
const paymentMethodsRef = useRef<any>(null);
|
||||
|
||||
const reservation: ReservationCreateResponse | undefined = location.state?.reservation;
|
||||
|
||||
const handleError = (err: any) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
alert('로그인이 필요해요.');
|
||||
navigate('/login', { state: { from: location } });
|
||||
} else {
|
||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||
alert(message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!reservation) {
|
||||
alert('잘못된 접근입니다.');
|
||||
navigate('/v2/reservation');
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://js.tosspayments.com/v1/payment-widget';
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
|
||||
const paymentWidget = window.PaymentWidget(widgetClientKey, window.PaymentWidget.ANONYMOUS);
|
||||
paymentWidgetRef.current = paymentWidget;
|
||||
|
||||
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||
"#payment-method",
|
||||
{ value: 1000 }, // TODO: 테마별 요금 적용
|
||||
{ variantKey: "DEFAULT" }
|
||||
);
|
||||
paymentMethodsRef.current = paymentMethods;
|
||||
};
|
||||
}, [reservation, navigate]);
|
||||
|
||||
const handlePayment = () => {
|
||||
if (!paymentWidgetRef.current || !reservation) {
|
||||
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const generateRandomString = () =>
|
||||
crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
paymentWidgetRef.current.requestPayment({
|
||||
orderId: generateRandomString(),
|
||||
orderName: "테스트 방탈출 예약 결제 1건",
|
||||
amount: 1000,
|
||||
}).then((data: any) => {
|
||||
const paymentData: ReservationPaymentRequest = {
|
||||
paymentKey: data.paymentKey,
|
||||
orderId: data.orderId,
|
||||
amount: data.amount,
|
||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||
};
|
||||
confirmReservationPayment(reservation.reservationId, paymentData)
|
||||
.then((res) => {
|
||||
navigate('/v2/reservation/success', {
|
||||
state: {
|
||||
reservation: res,
|
||||
themeName: reservation.themeName,
|
||||
date: reservation.date,
|
||||
startAt: reservation.startAt,
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(handleError);
|
||||
}).catch((error: any) => {
|
||||
console.error("Payment request error:", error);
|
||||
alert("결제 요청 중 오류가 발생했습니다.");
|
||||
});
|
||||
};
|
||||
|
||||
if (!reservation) {
|
||||
return null; // or a loading spinner
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-container col-md-10 offset-md-1 p-5">
|
||||
<div className="wrapper w-100">
|
||||
<div className="max-w-540 w-100">
|
||||
<div id="payment-method" className="w-100"></div>
|
||||
<div id="agreement" className="w-100"></div>
|
||||
<div className="btn-wrapper w-100 mt-3">
|
||||
<button onClick={handlePayment} className="btn btn-primary w-100">
|
||||
1,000원 결제하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationStep2Page;
|
||||
@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import type { ReservationPaymentResponse } from '@_api/reservation/reservationTypes';
|
||||
import '@_css/reservation-v2.css';
|
||||
|
||||
const ReservationSuccessPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { reservation, themeName, date, startAt } = (location.state as {
|
||||
reservation: ReservationPaymentResponse;
|
||||
themeName: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
}) || {};
|
||||
|
||||
if (!reservation) {
|
||||
React.useEffect(() => {
|
||||
navigate('/');
|
||||
}, [navigate]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reservation-success-page">
|
||||
<h2 className="content-container-title">예약이 확정되었습니다!</h2>
|
||||
<div className="reservation-info-box">
|
||||
<h3>최종 예약 정보</h3>
|
||||
<div className="info-item"><strong>테마:</strong> <span>{themeName}</span></div>
|
||||
<div className="info-item"><strong>날짜:</strong> <span>{date}</span></div>
|
||||
<div className="info-item"><strong>시간:</strong> <span>{startAt}</span></div>
|
||||
</div>
|
||||
<div className="success-page-actions">
|
||||
<Link to="/my-reservation/v2" className="btn btn-secondary">
|
||||
내 예약 목록
|
||||
</Link>
|
||||
<Link to="/" className="btn btn-secondary">
|
||||
메인 페이지로 이동
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationSuccessPage;
|
||||
@ -1,70 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { signup } from '../../api/member/memberAPI';
|
||||
import type { SignupRequest } from '../../api/member/memberTypes';
|
||||
import '../../css/signup-page-v2.css';
|
||||
|
||||
const SignupPageV2: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const request: SignupRequest = { email, password, name };
|
||||
try {
|
||||
const response = await signup(request);
|
||||
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
|
||||
navigate('/v2/login');
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || '회원가입에 실패했어요. 입력 정보를 확인해주세요.';
|
||||
alert(message);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="signup-container-v2">
|
||||
<h2 className="page-title">회원가입</h2>
|
||||
<form className="signup-form-v2" onSubmit={handleSignup}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="이메일을 입력하세요"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="이름을 입력하세요"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">가입하기</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupPageV2;
|
||||
@ -29,6 +29,7 @@
|
||||
"@_api/*": ["src/api/*"],
|
||||
"@_assets/*": ["src/assets/*"],
|
||||
"@_components/*": ["src/components/*"],
|
||||
"@_context/*": ["src/context/*"],
|
||||
"@_css/*": ["src/css/*"],
|
||||
"@_hooks/*": ["src/hooks/*"],
|
||||
"@_pages/*": ["src/pages/*"],
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
|
||||
70
src/main/kotlin/roomescape/admin/business/AdminService.kt
Normal file
70
src/main/kotlin/roomescape/admin/business/AdminService.kt
Normal file
@ -0,0 +1,70 @@
|
||||
package roomescape.admin.business
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import roomescape.admin.exception.AdminErrorCode
|
||||
import roomescape.admin.exception.AdminException
|
||||
import roomescape.admin.infrastructure.persistence.AdminEntity
|
||||
import roomescape.admin.infrastructure.persistence.AdminRepository
|
||||
import roomescape.common.dto.AdminLoginCredentials
|
||||
import roomescape.common.dto.CurrentUserContext
|
||||
import roomescape.common.dto.OperatorInfo
|
||||
import roomescape.common.dto.PrincipalType
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class AdminService(
|
||||
private val adminRepository: AdminRepository,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun findContextById(id: Long): CurrentUserContext {
|
||||
log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 시작: id=${id}" }
|
||||
|
||||
val admin: AdminEntity = findOrThrow(id)
|
||||
|
||||
return CurrentUserContext(admin.id, admin.name, PrincipalType.ADMIN).also {
|
||||
log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 완료: id=${id}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
|
||||
log.info { "[AdminService.findInfoByAccount] 관리자 조회 시작: account=${account}" }
|
||||
|
||||
return adminRepository.findByAccount(account)
|
||||
?.let {
|
||||
log.info { "[AdminService.findByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" }
|
||||
AdminLoginCredentials(it.id, it.password, it.permissionLevel)
|
||||
}
|
||||
?: run {
|
||||
log.info { "[AdminService.findInfoByAccount] 관리자 조회 실패: account=${account}" }
|
||||
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findOperatorById(id: Long): OperatorInfo {
|
||||
log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" }
|
||||
|
||||
val admin: AdminEntity = findOrThrow(id)
|
||||
|
||||
return OperatorInfo(admin.id, admin.name).also {
|
||||
log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOrThrow(id: Long): AdminEntity {
|
||||
log.info { "[AdminService.findOrThrow] 조회 시작: id=${id}" }
|
||||
|
||||
return adminRepository.findByIdOrNull(id)
|
||||
?.also { log.info { "[AdminService.findOrThrow] 조회 완료: id=${id}, name=${it.name}" } }
|
||||
?: run {
|
||||
log.info { "[AdminService.findOrThrow] 조회 실패: id=${id}" }
|
||||
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/main/kotlin/roomescape/admin/exception/AdminException.kt
Normal file
18
src/main/kotlin/roomescape/admin/exception/AdminException.kt
Normal file
@ -0,0 +1,18 @@
|
||||
package roomescape.admin.exception
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import roomescape.common.exception.ErrorCode
|
||||
import roomescape.common.exception.RoomescapeException
|
||||
|
||||
class AdminException(
|
||||
override val errorCode: AdminErrorCode,
|
||||
override val message: String = errorCode.message
|
||||
) : RoomescapeException(errorCode, message)
|
||||
|
||||
enum class AdminErrorCode(
|
||||
override val httpStatus: HttpStatus,
|
||||
override val errorCode: String,
|
||||
override val message: String
|
||||
) : ErrorCode {
|
||||
ADMIN_NOT_FOUND(HttpStatus.NOT_FOUND, "A001", "관리자를 찾을 수 없어요."),
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package roomescape.admin.infrastructure.persistence
|
||||
|
||||
import jakarta.persistence.*
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import roomescape.common.entity.AuditingBaseEntity
|
||||
|
||||
@Entity
|
||||
@Table(name = "admin")
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
class AdminEntity(
|
||||
id: Long,
|
||||
|
||||
val account: String,
|
||||
var password: String,
|
||||
val name: String,
|
||||
var phone: String,
|
||||
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var permissionLevel: AdminPermissionLevel
|
||||
|
||||
) : AuditingBaseEntity(id)
|
||||
|
||||
enum class AdminPermissionLevel(
|
||||
val privileges: Set<Privilege>
|
||||
) {
|
||||
FULL_ACCESS(
|
||||
privileges = setOf(Privilege.MANAGE)
|
||||
),
|
||||
WRITABLE(
|
||||
privileges = (Privilege.entries.toSet() - Privilege.MANAGE)
|
||||
),
|
||||
READ_ALL(
|
||||
privileges = setOf(Privilege.READ_DETAIL, Privilege.READ_SUMMARY)
|
||||
),
|
||||
READ_SUMMARY(
|
||||
privileges = setOf(Privilege.READ_SUMMARY)
|
||||
);
|
||||
|
||||
fun hasPrivilege(privilege: Privilege): Boolean {
|
||||
return this == FULL_ACCESS || this.privileges.contains(privilege)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Privilege {
|
||||
MANAGE,
|
||||
CREATE,
|
||||
UPDATE,
|
||||
DELETE,
|
||||
READ_DETAIL,
|
||||
READ_SUMMARY,
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package roomescape.admin.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AdminRepository : JpaRepository<AdminEntity, Long> {
|
||||
fun findByAccount(account: String): AdminEntity?
|
||||
}
|
||||
@ -4,55 +4,111 @@ import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import roomescape.admin.business.AdminService
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.auth.infrastructure.jwt.JwtHandler
|
||||
import roomescape.auth.web.LoginCheckResponse
|
||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import roomescape.auth.web.LoginContext
|
||||
import roomescape.auth.web.LoginRequest
|
||||
import roomescape.auth.web.LoginResponse
|
||||
import roomescape.member.implement.MemberFinder
|
||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||
import roomescape.auth.web.LoginSuccessResponse
|
||||
import roomescape.common.dto.CurrentUserContext
|
||||
import roomescape.common.dto.LoginCredentials
|
||||
import roomescape.common.dto.PrincipalType
|
||||
import roomescape.user.business.UserService
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
const val CLAIM_PERMISSION_KEY = "permission"
|
||||
const val CLAIM_TYPE_KEY = "principal_type"
|
||||
|
||||
@Service
|
||||
class AuthService(
|
||||
private val memberFinder: MemberFinder,
|
||||
private val jwtHandler: JwtHandler,
|
||||
private val adminService: AdminService,
|
||||
private val userService: UserService,
|
||||
private val loginHistoryService: LoginHistoryService,
|
||||
private val jwtUtils: JwtUtils,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun login(request: LoginRequest): LoginResponse {
|
||||
val params = "email=${request.email}, password=${request.password}"
|
||||
log.debug { "[AuthService.login] 시작: $params" }
|
||||
fun login(
|
||||
request: LoginRequest,
|
||||
context: LoginContext
|
||||
): LoginSuccessResponse {
|
||||
log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
||||
|
||||
val member: MemberEntity = fetchOrThrow(AuthErrorCode.LOGIN_FAILED) {
|
||||
memberFinder.findByEmailAndPassword(request.email, request.password)
|
||||
val (credentials, extraClaims) = getCredentials(request)
|
||||
|
||||
try {
|
||||
verifyPasswordOrThrow(request, credentials)
|
||||
|
||||
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
||||
|
||||
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
|
||||
|
||||
return LoginSuccessResponse(accessToken).also {
|
||||
log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
|
||||
|
||||
when (e) {
|
||||
is AuthException -> {
|
||||
log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" }
|
||||
throw e
|
||||
}
|
||||
|
||||
else -> {
|
||||
log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
||||
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
val accessToken: String = jwtHandler.createToken(member.id!!)
|
||||
|
||||
return LoginResponse(accessToken)
|
||||
.also { log.info { "[AuthService.login] 완료: email=${request.email}, memberId=${member.id}" } }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun checkLogin(memberId: Long): LoginCheckResponse {
|
||||
log.debug { "[AuthService.checkLogin] 시작: memberId=$memberId" }
|
||||
fun findContextById(id: Long, type: PrincipalType): CurrentUserContext {
|
||||
log.info { "[AuthService.checkLogin] 로그인 확인 시작: id=${id}, type=${type}" }
|
||||
|
||||
val member: MemberEntity = fetchOrThrow(AuthErrorCode.MEMBER_NOT_FOUND) { memberFinder.findById(memberId) }
|
||||
return when (type) {
|
||||
PrincipalType.ADMIN -> {
|
||||
adminService.findContextById(id)
|
||||
}
|
||||
|
||||
return LoginCheckResponse(member.name, member.role.name)
|
||||
.also { log.info { "[AuthService.checkLogin] 완료: memberId=$memberId, role=${it.role}" } }
|
||||
}
|
||||
|
||||
private fun fetchOrThrow(errorCode: AuthErrorCode, block: () -> MemberEntity): MemberEntity {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Exception) {
|
||||
throw AuthException(errorCode, e.message ?: errorCode.message)
|
||||
PrincipalType.USER -> {
|
||||
userService.findContextById(id)
|
||||
}
|
||||
}.also {
|
||||
log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(memberId: Long) {
|
||||
log.info { "[AuthService.logout] 로그아웃: memberId=$memberId" }
|
||||
private fun verifyPasswordOrThrow(
|
||||
request: LoginRequest,
|
||||
credentials: LoginCredentials
|
||||
) {
|
||||
if (credentials.password != request.password) {
|
||||
log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
||||
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCredentials(request: LoginRequest): Pair<LoginCredentials, Map<String, Any>> {
|
||||
val extraClaims: MutableMap<String, Any> = mutableMapOf()
|
||||
val credentials: LoginCredentials = when (request.principalType) {
|
||||
PrincipalType.ADMIN -> {
|
||||
adminService.findCredentialsByAccount(request.account).also {
|
||||
extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel)
|
||||
extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN)
|
||||
}
|
||||
}
|
||||
|
||||
PrincipalType.USER -> {
|
||||
userService.findCredentialsByAccount(request.account).also {
|
||||
extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return credentials to extraClaims
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
package roomescape.auth.business
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidFactory
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||
import roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||
import roomescape.auth.web.LoginContext
|
||||
import roomescape.common.config.next
|
||||
import roomescape.common.dto.PrincipalType
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class LoginHistoryService(
|
||||
private val loginHistoryRepository: LoginHistoryRepository,
|
||||
private val tsidFactory: TsidFactory,
|
||||
) {
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun createSuccessHistory(
|
||||
principalId: Long,
|
||||
principalType: PrincipalType,
|
||||
context: LoginContext
|
||||
) {
|
||||
createHistory(principalId, principalType, success = true, context = context)
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun createFailureHistory(
|
||||
principalId: Long,
|
||||
principalType: PrincipalType,
|
||||
context: LoginContext
|
||||
) {
|
||||
createHistory(principalId, principalType, success = false, context = context)
|
||||
}
|
||||
|
||||
private fun createHistory(
|
||||
principalId: Long,
|
||||
principalType: PrincipalType,
|
||||
success: Boolean,
|
||||
context: LoginContext
|
||||
) {
|
||||
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
|
||||
|
||||
runCatching {
|
||||
LoginHistoryEntity(
|
||||
id = tsidFactory.next(),
|
||||
principalId = principalId,
|
||||
principalType = principalType,
|
||||
success = success,
|
||||
ipAddress = context.ipAddress,
|
||||
userAgent = context.userAgent,
|
||||
).also {
|
||||
loginHistoryRepository.save(it)
|
||||
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
|
||||
}
|
||||
}.onFailure {
|
||||
log.warn { "[LoginHistoryService] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,46 +1,52 @@
|
||||
package roomescape.auth.docs
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import roomescape.auth.web.LoginCheckResponse
|
||||
import roomescape.auth.web.LoginRequest
|
||||
import roomescape.auth.web.LoginResponse
|
||||
import roomescape.auth.web.support.LoginRequired
|
||||
import roomescape.auth.web.support.MemberId
|
||||
import roomescape.auth.web.LoginSuccessResponse
|
||||
import roomescape.auth.web.support.CurrentUser
|
||||
import roomescape.auth.web.support.Public
|
||||
import roomescape.common.dto.CurrentUserContext
|
||||
import roomescape.common.dto.response.CommonApiResponse
|
||||
|
||||
@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다")
|
||||
interface AuthAPI {
|
||||
|
||||
@Public
|
||||
@Operation(summary = "로그인")
|
||||
@ApiResponses(
|
||||
ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."),
|
||||
)
|
||||
fun login(
|
||||
@Valid @RequestBody loginRequest: LoginRequest
|
||||
): ResponseEntity<CommonApiResponse<LoginResponse>>
|
||||
@Valid @RequestBody loginRequest: LoginRequest,
|
||||
servletRequest: HttpServletRequest
|
||||
): ResponseEntity<CommonApiResponse<LoginSuccessResponse>>
|
||||
|
||||
@Operation(summary = "로그인 상태 확인")
|
||||
@ApiResponses(
|
||||
ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "로그인 상태이며, 로그인된 회원의 이름 / 권한을 반환합니다.",
|
||||
description = "입력된 ID / 결과(Boolean)을 반환합니다.",
|
||||
useReturnTypeSchema = true
|
||||
),
|
||||
)
|
||||
fun checkLogin(
|
||||
@MemberId @Parameter(hidden = true) memberId: Long
|
||||
): ResponseEntity<CommonApiResponse<LoginCheckResponse>>
|
||||
@CurrentUser user: CurrentUserContext
|
||||
): ResponseEntity<CommonApiResponse<CurrentUserContext>>
|
||||
|
||||
@LoginRequired
|
||||
@Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"])
|
||||
@ApiResponses(
|
||||
ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."),
|
||||
ApiResponse(responseCode = "200"),
|
||||
)
|
||||
fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>>
|
||||
fun logout(
|
||||
@CurrentUser user: CurrentUserContext,
|
||||
servletResponse: HttpServletResponse
|
||||
): ResponseEntity<CommonApiResponse<Unit>>
|
||||
}
|
||||
|
||||
@ -14,4 +14,6 @@ enum class AuthErrorCode(
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없어요."),
|
||||
LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "A005", "이메일과 비밀번호를 확인해주세요."),
|
||||
MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A006", "회원 정보를 찾을 수 없어요."),
|
||||
|
||||
TEMPORARY_AUTH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "A999", "일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.");
|
||||
}
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
package roomescape.auth.infrastructure.jwt
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.jsonwebtoken.ExpiredJwtException
|
||||
import io.jsonwebtoken.Jwts
|
||||
import io.jsonwebtoken.security.Keys
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import java.util.*
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class JwtHandler(
|
||||
@Value("\${security.jwt.token.secret-key}")
|
||||
private val secretKeyString: String,
|
||||
|
||||
@Value("\${security.jwt.token.ttl-seconds}")
|
||||
private val tokenTtlSeconds: Long
|
||||
) {
|
||||
private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray())
|
||||
|
||||
fun createToken(memberId: Long): String {
|
||||
log.debug { "[JwtHandler.createToken] 시작: memberId=$memberId" }
|
||||
val date = Date()
|
||||
val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000))
|
||||
|
||||
return Jwts.builder()
|
||||
.claim(MEMBER_ID_CLAIM_KEY, memberId)
|
||||
.issuedAt(date)
|
||||
.expiration(accessTokenExpiredAt)
|
||||
.signWith(secretKey)
|
||||
.compact()
|
||||
.also { log.debug { "[JwtHandler.createToken] 완료. memberId=$memberId, token=$it" } }
|
||||
}
|
||||
|
||||
fun getMemberIdFromToken(token: String?): Long {
|
||||
try {
|
||||
log.debug { "[JwtHandler.getMemberIdFromToken] 시작: token=$token" }
|
||||
return Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.payload
|
||||
.get(MEMBER_ID_CLAIM_KEY, Number::class.java)
|
||||
.toLong()
|
||||
.also { log.debug { "[JwtHandler.getMemberIdFromToken] 완료. memberId=$it, token=$token" } }
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)
|
||||
} catch (_: ExpiredJwtException) {
|
||||
throw AuthException(AuthErrorCode.EXPIRED_TOKEN)
|
||||
} catch (_: Exception) {
|
||||
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEMBER_ID_CLAIM_KEY = "memberId"
|
||||
}
|
||||
}
|
||||
100
src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt
Normal file
100
src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt
Normal file
@ -0,0 +1,100 @@
|
||||
package roomescape.auth.infrastructure.jwt
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.jsonwebtoken.Claims
|
||||
import io.jsonwebtoken.ExpiredJwtException
|
||||
import io.jsonwebtoken.Jwts
|
||||
import io.jsonwebtoken.security.Keys
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.auth.business.CLAIM_TYPE_KEY
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
|
||||
import roomescape.common.dto.PrincipalType
|
||||
import java.util.*
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class JwtUtils(
|
||||
@Value("\${security.jwt.token.secret-key}")
|
||||
private val secretKeyString: String,
|
||||
|
||||
@Value("\${security.jwt.token.ttl-seconds}")
|
||||
private val tokenTtlSeconds: Long
|
||||
) {
|
||||
private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray())
|
||||
|
||||
fun createToken(subject: String, claims: Map<String, Any>): String {
|
||||
log.debug { "[JwtUtils.createToken] 토큰 생성 시작: subject=$subject, claims=${claims}" }
|
||||
|
||||
val date = Date()
|
||||
val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000))
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(subject)
|
||||
.claims(claims)
|
||||
.issuedAt(date)
|
||||
.expiration(accessTokenExpiredAt)
|
||||
.signWith(secretKey)
|
||||
.compact()
|
||||
.also {
|
||||
log.debug { "[JwtUtils.createToken] 토큰 생성 완료. token=${it}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun extractIdAndType(token: String?): Pair<Long, PrincipalType> {
|
||||
val id: Long = extractSubject(token)
|
||||
.also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) }
|
||||
.toLong()
|
||||
|
||||
val type: PrincipalType = extractClaim(token, CLAIM_TYPE_KEY)
|
||||
?.let { PrincipalType.valueOf(it) }
|
||||
?: run {
|
||||
log.info { "[JwtUtils.extractIdAndType] 회원 타입 조회 실패. id=$id" }
|
||||
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
||||
}
|
||||
|
||||
return id to type
|
||||
}
|
||||
|
||||
fun extractSubject(token: String?): String {
|
||||
if (token.isNullOrBlank()) {
|
||||
throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)
|
||||
}
|
||||
val claims = extractAllClaims(token)
|
||||
|
||||
return claims.subject ?: run {
|
||||
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
||||
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
||||
}
|
||||
}
|
||||
|
||||
fun extractClaim(token: String?, key: String): String? {
|
||||
if (token.isNullOrBlank()) {
|
||||
throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)
|
||||
}
|
||||
val claims = extractAllClaims(token)
|
||||
|
||||
return claims.get(key, String::class.java)
|
||||
}
|
||||
|
||||
private fun extractAllClaims(token: String): Claims {
|
||||
try {
|
||||
return Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.payload
|
||||
} catch (_: ExpiredJwtException) {
|
||||
throw AuthException(AuthErrorCode.EXPIRED_TOKEN)
|
||||
} catch (ex: Exception) {
|
||||
log.warn { "[JwtUtils] 유효하지 않은 토큰 요청: ${ex.message}" }
|
||||
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package roomescape.auth.infrastructure.persistence
|
||||
|
||||
import jakarta.persistence.*
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import roomescape.common.dto.PrincipalType
|
||||
import roomescape.common.entity.PersistableBaseEntity
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Entity
|
||||
@Table(name = "login_history")
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
class LoginHistoryEntity(
|
||||
id: Long,
|
||||
|
||||
val principalId: Long,
|
||||
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
val principalType: PrincipalType,
|
||||
|
||||
val success: Boolean,
|
||||
val ipAddress: String,
|
||||
val userAgent: String,
|
||||
|
||||
@Column(updatable = false)
|
||||
@CreatedDate
|
||||
var createdAt: LocalDateTime? = null,
|
||||
) : PersistableBaseEntity(id)
|
||||
@ -0,0 +1,8 @@
|
||||
package roomescape.auth.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface LoginHistoryRepository : JpaRepository<LoginHistoryEntity, Long> {
|
||||
|
||||
fun findByPrincipalId(principalId: Long): List<LoginHistoryEntity>
|
||||
}
|
||||
@ -1,44 +1,46 @@
|
||||
package roomescape.auth.web
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import roomescape.auth.business.AuthService
|
||||
import roomescape.auth.docs.AuthAPI
|
||||
import roomescape.auth.web.support.MemberId
|
||||
import roomescape.auth.web.support.CurrentUser
|
||||
import roomescape.common.dto.CurrentUserContext
|
||||
import roomescape.common.dto.response.CommonApiResponse
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
class AuthController(
|
||||
private val authService: AuthService
|
||||
private val authService: AuthService,
|
||||
) : AuthAPI {
|
||||
|
||||
@PostMapping("/login")
|
||||
override fun login(
|
||||
@Valid @RequestBody loginRequest: LoginRequest,
|
||||
): ResponseEntity<CommonApiResponse<LoginResponse>> {
|
||||
val response: LoginResponse = authService.login(loginRequest)
|
||||
loginRequest: LoginRequest,
|
||||
servletRequest: HttpServletRequest
|
||||
): ResponseEntity<CommonApiResponse<LoginSuccessResponse>> {
|
||||
val response = authService.login(request = loginRequest, context = servletRequest.toLoginContext())
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/login/check")
|
||||
override fun checkLogin(
|
||||
@MemberId @Parameter(hidden = true) memberId: Long
|
||||
): ResponseEntity<CommonApiResponse<LoginCheckResponse>> {
|
||||
val response: LoginCheckResponse = authService.checkLogin(memberId)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
@CurrentUser user: CurrentUserContext,
|
||||
): ResponseEntity<CommonApiResponse<CurrentUserContext>> {
|
||||
return ResponseEntity.ok(CommonApiResponse(user))
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
override fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
authService.logout(memberId)
|
||||
|
||||
return ResponseEntity.noContent().build()
|
||||
override fun logout(
|
||||
@CurrentUser user: CurrentUserContext,
|
||||
servletResponse: HttpServletResponse
|
||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
return ResponseEntity.ok().build()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +1,24 @@
|
||||
package roomescape.auth.web
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import jakarta.validation.constraints.Email
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import roomescape.common.dto.PrincipalType
|
||||
|
||||
data class LoginResponse(
|
||||
val accessToken: String
|
||||
data class LoginContext(
|
||||
val ipAddress: String,
|
||||
val userAgent: String,
|
||||
)
|
||||
|
||||
data class LoginCheckResponse(
|
||||
@Schema(description = "로그인된 회원의 이름")
|
||||
val name: String,
|
||||
@Schema(description = "회원(MEMBER) / 관리자(ADMIN)")
|
||||
val role: String,
|
||||
fun HttpServletRequest.toLoginContext() = LoginContext(
|
||||
ipAddress = this.remoteAddr,
|
||||
userAgent = this.getHeader("User-Agent")
|
||||
)
|
||||
|
||||
data class LoginRequest(
|
||||
@Email(message = "이메일 형식이 일치하지 않습니다. 예시: abc123@gmail.com")
|
||||
val email: String,
|
||||
|
||||
@NotBlank(message = "비밀번호는 공백일 수 없습니다.")
|
||||
val password: String
|
||||
val account: String,
|
||||
val password: String,
|
||||
val principalType: PrincipalType
|
||||
)
|
||||
|
||||
data class LoginSuccessResponse(
|
||||
val accessToken: String
|
||||
)
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
package roomescape.auth.web.support
|
||||
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Admin
|
||||
import roomescape.admin.infrastructure.persistence.Privilege
|
||||
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class LoginRequired
|
||||
annotation class AdminOnly(
|
||||
val privilege: Privilege
|
||||
)
|
||||
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class UserOnly
|
||||
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Authenticated
|
||||
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Public
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class MemberId
|
||||
annotation class CurrentUser
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
package roomescape.auth.web.support
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.auth.infrastructure.jwt.JwtHandler
|
||||
import roomescape.member.implement.MemberFinder
|
||||
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,
|
||||
private val jwtHandler: JwtHandler
|
||||
) : HandlerInterceptor {
|
||||
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
|
||||
if (handler !is HandlerMethod) {
|
||||
return true
|
||||
}
|
||||
|
||||
val loginRequired = handler.getMethodAnnotation(LoginRequired::class.java)
|
||||
val admin = handler.getMethodAnnotation(Admin::class.java)
|
||||
|
||||
if (loginRequired == null && admin == null) {
|
||||
return true
|
||||
}
|
||||
|
||||
val accessToken: String? = request.accessToken()
|
||||
log.info { "[AuthInterceptor] 인증 시작. accessToken=${accessToken}" }
|
||||
val member: MemberEntity = findMember(accessToken)
|
||||
|
||||
if (admin != null && !member.isAdmin()) {
|
||||
log.info { "[AuthInterceptor] 관리자 인증 실패. memberId=${member.id}, role=${member.role}" }
|
||||
throw AuthException(AuthErrorCode.ACCESS_DENIED)
|
||||
}
|
||||
|
||||
MDC.put(MDC_MEMBER_ID_KEY, "${member.id}")
|
||||
log.info { "[AuthInterceptor] 인증 완료. memberId=${member.id}, role=${member.role}" }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun findMember(accessToken: String?): MemberEntity {
|
||||
try {
|
||||
val memberId = jwtHandler.getMemberIdFromToken(accessToken)
|
||||
return memberFinder.findById(memberId)
|
||||
.also { MDC.put(MDC_MEMBER_ID_KEY, "$memberId") }
|
||||
} catch (e: Exception) {
|
||||
log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = $accessToken" }
|
||||
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
throw AuthException(errorCode, e.message ?: errorCode.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package roomescape.auth.web.support.interceptors
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||
import roomescape.auth.business.CLAIM_PERMISSION_KEY
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import roomescape.auth.web.support.AdminOnly
|
||||
import roomescape.auth.web.support.accessToken
|
||||
import roomescape.common.dto.PrincipalType
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class AdminInterceptor(
|
||||
private val jwtUtils: JwtUtils
|
||||
) : HandlerInterceptor {
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
handler: Any
|
||||
): Boolean {
|
||||
if (handler !is HandlerMethod) {
|
||||
return true
|
||||
}
|
||||
|
||||
val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true
|
||||
|
||||
val token: String? = request.accessToken()
|
||||
val (id, type) = jwtUtils.extractIdAndType(token)
|
||||
|
||||
val permission: AdminPermissionLevel = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY)
|
||||
?.let {
|
||||
AdminPermissionLevel.valueOf(it)
|
||||
}
|
||||
?: run {
|
||||
if (type != PrincipalType.ADMIN) {
|
||||
log.warn { "[AdminInterceptor] 회원의 관리자 API 접근: id=${id}" }
|
||||
throw AuthException(AuthErrorCode.ACCESS_DENIED)
|
||||
}
|
||||
log.warn { "[AdminInterceptor] 토큰에서 이용자 권한이 조회되지 않음: id=${id}" }
|
||||
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
||||
}
|
||||
|
||||
if (!permission.hasPrivilege(annotation.privilege)) {
|
||||
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" }
|
||||
throw AuthException(AuthErrorCode.ACCESS_DENIED)
|
||||
}
|
||||
|
||||
log.info { "[AdminInterceptor] 인증 완료. adminId=$id, permission=${permission}" }
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package roomescape.auth.web.support.interceptors
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import roomescape.auth.business.AuthService
|
||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import roomescape.auth.web.support.Authenticated
|
||||
import roomescape.auth.web.support.accessToken
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class AuthenticatedInterceptor(
|
||||
private val jwtUtils: JwtUtils,
|
||||
private val authService: AuthService
|
||||
) : HandlerInterceptor {
|
||||
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
handler: Any
|
||||
): Boolean {
|
||||
if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(Authenticated::class.java) == null)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val token: String? = request.accessToken()
|
||||
val (id, type) = jwtUtils.extractIdAndType(token)
|
||||
|
||||
try {
|
||||
authService.findContextById(id, type)
|
||||
log.info { "[AuthenticatedInterceptor] 인증 완료. id=$id, type=${type}" }
|
||||
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package roomescape.auth.web.support.interceptors
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import roomescape.auth.web.support.UserOnly
|
||||
import roomescape.auth.web.support.accessToken
|
||||
import roomescape.common.dto.PrincipalType
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class UserInterceptor(
|
||||
private val jwtUtils: JwtUtils
|
||||
) : HandlerInterceptor {
|
||||
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
handler: Any
|
||||
): Boolean {
|
||||
if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val token: String? = request.accessToken()
|
||||
val (id, type) = jwtUtils.extractIdAndType(token)
|
||||
|
||||
if (type != PrincipalType.USER) {
|
||||
log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${id}" }
|
||||
throw AuthException(AuthErrorCode.ACCESS_DENIED)
|
||||
}
|
||||
|
||||
log.info { "[UserInterceptor] 인증 완료. userId=$id" }
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -1,28 +1,31 @@
|
||||
package roomescape.auth.web.support
|
||||
package roomescape.auth.web.support.resolver
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.core.MethodParameter
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory
|
||||
import org.springframework.web.context.request.NativeWebRequest
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||
import org.springframework.web.method.support.ModelAndViewContainer
|
||||
import roomescape.auth.business.AuthService
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.auth.infrastructure.jwt.JwtHandler
|
||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import roomescape.auth.web.support.CurrentUser
|
||||
import roomescape.auth.web.support.accessToken
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class MemberIdResolver(
|
||||
private val jwtHandler: JwtHandler
|
||||
class CurrentUserContextResolver(
|
||||
private val jwtUtils: JwtUtils,
|
||||
private val authService: AuthService
|
||||
) : HandlerMethodArgumentResolver {
|
||||
|
||||
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||
return parameter.hasParameterAnnotation(MemberId::class.java)
|
||||
return parameter.hasParameterAnnotation(CurrentUser::class.java)
|
||||
}
|
||||
|
||||
override fun resolveArgument(
|
||||
@ -30,17 +33,17 @@ class MemberIdResolver(
|
||||
mavContainer: ModelAndViewContainer?,
|
||||
webRequest: NativeWebRequest,
|
||||
binderFactory: WebDataBinderFactory?
|
||||
): Any {
|
||||
): Any? {
|
||||
val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest
|
||||
val token: String? = request.accessToken()
|
||||
|
||||
try {
|
||||
return jwtHandler.getMemberIdFromToken(token)
|
||||
.also { MDC.put("member_id", "$it") }
|
||||
val (id, type) = jwtUtils.extractIdAndType(token)
|
||||
|
||||
return authService.findContextById(id, type)
|
||||
} catch (e: Exception) {
|
||||
log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" }
|
||||
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
throw AuthException(errorCode, e.message ?: errorCode.message)
|
||||
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ 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 roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
|
||||
import java.util.*
|
||||
|
||||
@Configuration
|
||||
@ -18,7 +18,7 @@ class JpaConfig {
|
||||
|
||||
class MdcAuditorAware : AuditorAware<Long> {
|
||||
override fun getCurrentAuditor(): Optional<Long> {
|
||||
val memberIdStr: String? = MDC.get(MDC_MEMBER_ID_KEY)
|
||||
val memberIdStr: String? = MDC.get(MDC_PRINCIPAL_ID_KEY)
|
||||
|
||||
if (memberIdStr == null) {
|
||||
return Optional.empty()
|
||||
|
||||
@ -4,20 +4,26 @@ import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
import roomescape.auth.web.support.AuthInterceptor
|
||||
import roomescape.auth.web.support.MemberIdResolver
|
||||
import roomescape.auth.web.support.interceptors.AdminInterceptor
|
||||
import roomescape.auth.web.support.interceptors.AuthenticatedInterceptor
|
||||
import roomescape.auth.web.support.interceptors.UserInterceptor
|
||||
import roomescape.auth.web.support.resolver.CurrentUserContextResolver
|
||||
|
||||
@Configuration
|
||||
class WebMvcConfig(
|
||||
private val memberIdResolver: MemberIdResolver,
|
||||
private val authInterceptor: AuthInterceptor
|
||||
private val adminInterceptor: AdminInterceptor,
|
||||
private val userInterceptor: UserInterceptor,
|
||||
private val authenticatedInterceptor: AuthenticatedInterceptor,
|
||||
private val currentUserContextResolver: CurrentUserContextResolver
|
||||
) : WebMvcConfigurer {
|
||||
|
||||
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
|
||||
resolvers.add(memberIdResolver)
|
||||
resolvers.add(currentUserContextResolver)
|
||||
}
|
||||
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
registry.addInterceptor(adminInterceptor)
|
||||
registry.addInterceptor(userInterceptor)
|
||||
registry.addInterceptor(authenticatedInterceptor)
|
||||
}
|
||||
}
|
||||
|
||||
36
src/main/kotlin/roomescape/common/dto/CommonAuth.kt
Normal file
36
src/main/kotlin/roomescape/common/dto/CommonAuth.kt
Normal file
@ -0,0 +1,36 @@
|
||||
package roomescape.common.dto
|
||||
|
||||
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||
|
||||
const val MDC_PRINCIPAL_ID_KEY: String = "principal_id"
|
||||
|
||||
abstract class LoginCredentials {
|
||||
abstract val id: Long
|
||||
abstract val password: String
|
||||
}
|
||||
|
||||
data class AdminLoginCredentials(
|
||||
override val id: Long,
|
||||
override val password: String,
|
||||
val permissionLevel: AdminPermissionLevel
|
||||
) : LoginCredentials()
|
||||
|
||||
data class UserLoginCredentials(
|
||||
override val id: Long,
|
||||
override val password: String,
|
||||
) : LoginCredentials()
|
||||
|
||||
data class CurrentUserContext(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val type: PrincipalType
|
||||
);
|
||||
|
||||
enum class PrincipalType {
|
||||
USER, ADMIN
|
||||
}
|
||||
|
||||
data class OperatorInfo(
|
||||
val id: Long,
|
||||
val name: String
|
||||
)
|
||||
@ -1,7 +1,9 @@
|
||||
package roomescape.common.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
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
|
||||
@ -10,28 +12,24 @@ import kotlin.jvm.Transient
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
abstract class BaseEntity(
|
||||
abstract class AuditingBaseEntity(
|
||||
id: Long,
|
||||
) : PersistableBaseEntity(id) {
|
||||
@Column(updatable = false)
|
||||
@CreatedDate
|
||||
var createdAt: LocalDateTime? = null,
|
||||
lateinit var createdAt: LocalDateTime
|
||||
|
||||
@Column(updatable = false)
|
||||
@CreatedBy
|
||||
var createdBy: Long = 0L
|
||||
|
||||
@Column
|
||||
@LastModifiedDate
|
||||
var lastModifiedAt: LocalDateTime? = null,
|
||||
) : Persistable<Long> {
|
||||
|
||||
@Transient
|
||||
private var isNewEntity: Boolean = true
|
||||
|
||||
@PostLoad
|
||||
@PostPersist
|
||||
fun markNotNew() {
|
||||
isNewEntity = false
|
||||
}
|
||||
|
||||
override fun isNew(): Boolean = isNewEntity
|
||||
|
||||
abstract override fun getId(): Long?
|
||||
lateinit var updatedAt: LocalDateTime
|
||||
|
||||
@Column
|
||||
@LastModifiedBy
|
||||
var updatedBy: Long = 0L
|
||||
}
|
||||
|
||||
@MappedSuperclass
|
||||
@ -43,12 +41,13 @@ abstract class PersistableBaseEntity(
|
||||
@Transient
|
||||
private var isNewEntity: Boolean = true
|
||||
) : Persistable<Long> {
|
||||
|
||||
@PostLoad
|
||||
@PostPersist
|
||||
@PrePersist
|
||||
fun markNotNew() {
|
||||
isNewEntity = false
|
||||
}
|
||||
|
||||
override fun isNew(): Boolean = isNewEntity
|
||||
override fun getId(): Long = _id
|
||||
override fun isNew(): Boolean = isNewEntity
|
||||
}
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
package roomescape.common.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
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
|
||||
import kotlin.jvm.Transient
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
abstract class AuditingBaseEntity(
|
||||
id: Long,
|
||||
) : BaseEntityV2(id) {
|
||||
@Column(updatable = false)
|
||||
@CreatedDate
|
||||
lateinit var createdAt: LocalDateTime
|
||||
|
||||
@Column(updatable = false)
|
||||
@CreatedBy
|
||||
var createdBy: Long = 0L
|
||||
|
||||
@Column
|
||||
@LastModifiedDate
|
||||
lateinit var updatedAt: LocalDateTime
|
||||
|
||||
@Column
|
||||
@LastModifiedBy
|
||||
var updatedBy: Long = 0L
|
||||
}
|
||||
|
||||
@MappedSuperclass
|
||||
abstract class BaseEntityV2(
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private val _id: Long,
|
||||
|
||||
@Transient
|
||||
private var isNewEntity: Boolean = true
|
||||
) : Persistable<Long> {
|
||||
|
||||
@PostLoad
|
||||
@PrePersist
|
||||
fun markNotNew() {
|
||||
isNewEntity = false
|
||||
}
|
||||
|
||||
override fun getId(): Long = _id
|
||||
override fun isNew(): Boolean = isNewEntity
|
||||
}
|
||||
@ -3,7 +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
|
||||
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
|
||||
|
||||
enum class LogType {
|
||||
INCOMING_HTTP_REQUEST,
|
||||
@ -34,8 +34,8 @@ class ApiLogMessageConverter(
|
||||
controllerPayload: Map<String, Any>,
|
||||
): String {
|
||||
val payload: MutableMap<String, Any> = commonRequestPayload(LogType.CONTROLLER_INVOKED, request)
|
||||
val memberId: Long? = MDC.get(MDC_MEMBER_ID_KEY)?.toLong()
|
||||
if (memberId != null) payload["member_id"] = memberId else payload["member_id"] = "NONE"
|
||||
val memberId: Long? = MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong()
|
||||
if (memberId != null) payload["principal_id"] = memberId else payload["principal_id"] = "NONE"
|
||||
|
||||
payload.putAll(controllerPayload)
|
||||
|
||||
@ -48,9 +48,9 @@ class ApiLogMessageConverter(
|
||||
payload["endpoint"] = request.endpoint
|
||||
payload["status_code"] = request.httpStatus
|
||||
|
||||
MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull()
|
||||
?.let { payload["member_id"] = it }
|
||||
?: run { payload["member_id"] = "NONE" }
|
||||
MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLongOrNull()
|
||||
?.let { payload["principal_id"] = it }
|
||||
?: run { payload["principal_id"] = "NONE" }
|
||||
|
||||
request.startTime?.let { payload["duration_ms"] = System.currentTimeMillis() - it }
|
||||
request.body?.let { payload["response_body"] = it }
|
||||
|
||||
@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.node.TextNode
|
||||
import roomescape.common.config.JacksonConfig
|
||||
|
||||
private const val MASK: String = "****"
|
||||
private val SENSITIVE_KEYS = setOf("password", "accessToken")
|
||||
private val SENSITIVE_KEYS = setOf("password", "accessToken", "phone")
|
||||
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
|
||||
|
||||
class RoomescapeLogMaskingConverter : MessageConverter() {
|
||||
|
||||
11
src/main/kotlin/roomescape/common/util/DateUtils.kt
Normal file
11
src/main/kotlin/roomescape/common/util/DateUtils.kt
Normal file
@ -0,0 +1,11 @@
|
||||
package roomescape.common.util
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
object DateUtils {
|
||||
fun getSundayOfPreviousWeek(date: LocalDate): LocalDate = date
|
||||
.minusWeeks(1)
|
||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package roomescape.member.business
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import roomescape.member.implement.MemberFinder
|
||||
import roomescape.member.implement.MemberWriter
|
||||
import roomescape.member.infrastructure.persistence.Role
|
||||
import roomescape.member.web.*
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class MemberService(
|
||||
private val memberWriter: MemberWriter,
|
||||
private val memberFinder: MemberFinder,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun findMembers(): MemberRetrieveListResponse {
|
||||
log.debug { "[MemberService.findMembers] 시작" }
|
||||
|
||||
return memberFinder.findAll()
|
||||
.toRetrieveListResponse()
|
||||
.also { log.info { "[MemberService.findMembers] 완료. ${it.members.size}명 반환" } }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findSummaryById(id: Long): MemberSummaryRetrieveResponse {
|
||||
log.debug { "[MemberService.findSummaryById] 시작" }
|
||||
|
||||
return memberFinder.findById(id)
|
||||
.toSummaryRetrieveResponse()
|
||||
.also {
|
||||
log.info { "[MemberService.findSummaryById] 완료. memberId=${id}, email=${it.email}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createMember(request: SignupRequest): SignupResponse {
|
||||
log.debug { "[MemberService.createMember] 시작" }
|
||||
|
||||
return memberWriter.create(request.name, request.email, request.password, Role.MEMBER)
|
||||
.toSignupResponse()
|
||||
.also { log.info { "[MemberService.create] 완료: email=${request.email} memberId=${it.id}" } }
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
package roomescape.member.exception
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import roomescape.common.exception.ErrorCode
|
||||
|
||||
enum class MemberErrorCode(
|
||||
override val httpStatus: HttpStatus,
|
||||
override val errorCode: String,
|
||||
override val message: String
|
||||
) : ErrorCode {
|
||||
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없어요."),
|
||||
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "M002", "이미 가입된 이메일이에요.")
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
package roomescape.member.exception
|
||||
|
||||
import roomescape.common.exception.RoomescapeException
|
||||
|
||||
class MemberException(
|
||||
override val errorCode: MemberErrorCode,
|
||||
override val message: String = errorCode.message
|
||||
) : RoomescapeException(errorCode, message)
|
||||
@ -1,47 +0,0 @@
|
||||
package roomescape.member.implement
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.member.exception.MemberErrorCode
|
||||
import roomescape.member.exception.MemberException
|
||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||
import roomescape.member.infrastructure.persistence.MemberRepository
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class MemberFinder(
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
|
||||
fun findAll(): List<MemberEntity> {
|
||||
log.debug { "[MemberFinder.findAll] 회원 조회 시작" }
|
||||
|
||||
return memberRepository.findAll()
|
||||
.also { log.debug { "[MemberFinder.findAll] 회원 ${it.size}명 조회 완료" } }
|
||||
}
|
||||
|
||||
fun findById(id: Long): MemberEntity {
|
||||
log.debug { "[MemberFinder.findById] 조회 시작: memberId=$id" }
|
||||
|
||||
return memberRepository.findByIdOrNull(id)
|
||||
?.also { log.debug { "[MemberFinder.findById] 조회 완료: memberId=$id, email=${it.email}" } }
|
||||
?: run {
|
||||
log.info { "[MemberFinder.findById] 조회 실패: id=$id" }
|
||||
throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
fun findByEmailAndPassword(email: String, password: String): MemberEntity {
|
||||
log.debug { "[MemberFinder.findByEmailAndPassword] 조회 시작: email=$email, password=$password" }
|
||||
|
||||
return memberRepository.findByEmailAndPassword(email, password)
|
||||
?.also { log.debug { "[MemberFinder.findByEmailAndPassword] 조회 완료: email=${email}, memberId=${it.id}" } }
|
||||
?: run {
|
||||
log.info { "[MemberFinder.findByEmailAndPassword] 조회 실패: email=${email}, password=${password}" }
|
||||
throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
package roomescape.member.implement
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.member.exception.MemberErrorCode
|
||||
import roomescape.member.exception.MemberException
|
||||
import roomescape.member.infrastructure.persistence.MemberRepository
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class MemberValidator(
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
fun validateCanSignup(email: String) {
|
||||
log.debug { "[MemberValidator.validateCanSignup] 시작: email=$email" }
|
||||
|
||||
if (memberRepository.existsByEmail(email)) {
|
||||
log.info { "[MemberValidator.validateCanSignup] 중복 이메일: email=$email" }
|
||||
throw MemberException(MemberErrorCode.DUPLICATE_EMAIL)
|
||||
}
|
||||
|
||||
log.debug { "[MemberValidator.validateCanSignup] 완료: email=$email" }
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
package roomescape.member.implement
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidFactory
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.common.config.next
|
||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||
import roomescape.member.infrastructure.persistence.MemberRepository
|
||||
import roomescape.member.infrastructure.persistence.Role
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class MemberWriter(
|
||||
private val tsidFactory: TsidFactory,
|
||||
private val memberValidator: MemberValidator,
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
fun create(name: String, email: String, password: String, role: Role): MemberEntity {
|
||||
log.debug { "[MemberWriter.create] 시작: email=$email" }
|
||||
memberValidator.validateCanSignup(email)
|
||||
|
||||
val member = MemberEntity(
|
||||
_id = tsidFactory.next(),
|
||||
name = name,
|
||||
email = email,
|
||||
password = password,
|
||||
role = role
|
||||
)
|
||||
|
||||
return memberRepository.save(member)
|
||||
.also { log.debug { "[MemberWriter.create] 완료: email=$email, memberId=${it.id}" } }
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
package roomescape.member.infrastructure.persistence
|
||||
|
||||
import jakarta.persistence.*
|
||||
import roomescape.common.entity.BaseEntity
|
||||
|
||||
@Entity
|
||||
@Table(name = "members")
|
||||
class MemberEntity(
|
||||
@Id
|
||||
@Column(name = "member_id")
|
||||
private var _id: Long?,
|
||||
|
||||
@Column(name = "name", nullable = false)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "email", nullable = false)
|
||||
var email: String,
|
||||
|
||||
@Column(name = "password", nullable = false)
|
||||
var password: String,
|
||||
|
||||
@Column(name = "role", nullable = false, length = 20)
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var role: Role
|
||||
) : BaseEntity() {
|
||||
override fun getId(): Long? = _id
|
||||
|
||||
fun isAdmin(): Boolean = role == Role.ADMIN
|
||||
}
|
||||
|
||||
enum class Role {
|
||||
MEMBER,
|
||||
ADMIN,
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
package roomescape.member.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface MemberRepository : JpaRepository<MemberEntity, Long> {
|
||||
fun existsByEmail(email: String): Boolean
|
||||
|
||||
fun findByEmailAndPassword(email: String, password: String): MemberEntity?
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
package roomescape.member.web
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import roomescape.common.dto.response.CommonApiResponse
|
||||
import roomescape.member.business.MemberService
|
||||
import roomescape.member.docs.MemberAPI
|
||||
import java.net.URI
|
||||
|
||||
@RestController
|
||||
class MemberController(
|
||||
private val memberService: MemberService
|
||||
) : MemberAPI {
|
||||
|
||||
@PostMapping("/members")
|
||||
override fun signup(@RequestBody request: SignupRequest): ResponseEntity<CommonApiResponse<SignupResponse>> {
|
||||
val response: SignupResponse = memberService.createMember(request)
|
||||
return ResponseEntity.created(URI.create("/members/${response.id}"))
|
||||
.body(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/members")
|
||||
override fun findMembers(): ResponseEntity<CommonApiResponse<MemberRetrieveListResponse>> {
|
||||
val response: MemberRetrieveListResponse = memberService.findMembers()
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user