[#34] 회원 / 인증 도메인 재정의 (#43)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#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:
이상진 2025-09-13 10:13:45 +00:00 committed by 이상진
parent 675a5b8854
commit 5658f6c31f
150 changed files with 4676 additions and 4517 deletions

View File

@ -3,7 +3,7 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config' import {globalIgnores} from 'eslint/config'
export default tseslint.config([ export default tseslint.config([
globalIgnores(['dist']), globalIgnores(['dist']),

View File

@ -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

View File

@ -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 Layout from './components/Layout';
import HomePage from './pages/HomePage'; import {AuthProvider} from './context/AuthContext';
import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage';
import ReservationPage from './pages/ReservationPage';
import MyReservationPage from './pages/MyReservationPage';
import AdminLayout from './pages/admin/AdminLayout'; import AdminLayout from './pages/admin/AdminLayout';
import AdminPage from './pages/admin/AdminPage'; 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 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 = () => ( const AdminRoutes = () => (
<AdminLayout> <AdminLayout>
<Routes> <Routes>
<Route path="/" element={<AdminPage />} /> <Route path="/" element={<AdminPage />} />
<Route path="/reservation" element={<AdminReservationPage />} />
<Route path="/time" element={<AdminTimePage />} />
<Route path="/theme" element={<AdminThemePage />} /> <Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} /> <Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/waiting" element={<AdminWaitingPage />} />
<Route path="/schedule" element={<AdminSchedulePage />} /> <Route path="/schedule" element={<AdminSchedulePage />} />
</Routes> </Routes>
</AdminLayout> </AdminLayout>
@ -54,28 +40,14 @@ function App() {
<Route path="/*" element={ <Route path="/*" element={
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage/>} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} /> <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" 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> </Routes>
</Layout> </Layout>
} /> } />

View File

@ -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'; import JSONbig from 'json-bigint';
// Create a JSONbig instance that stores big integers as strings // Create a JSONbig instance that stores big integers as strings

View File

@ -1,19 +1,19 @@
import apiClient from '@_api/apiClient'; 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> => { export const login = async (data: LoginRequest): Promise<LoginSuccessResponse> => {
const response = await apiClient.post<LoginResponse>('/login', data, false); const response = await apiClient.post<LoginSuccessResponse>('/auth/login', data, false);
localStorage.setItem('accessToken', response.accessToken); localStorage.setItem('accessToken', response.accessToken);
return response; return response;
}; };
export const checkLogin = async (): Promise<LoginCheckResponse> => { export const checkLogin = async (): Promise<CurrentUserContext> => {
return await apiClient.get<LoginCheckResponse>('/login/check', true); return await apiClient.get<CurrentUserContext>('/auth/login/check', true);
}; };
export const logout = async (): Promise<void> => { export const logout = async (): Promise<void> => {
await apiClient.post('/logout', {}, true); await apiClient.post('/auth/logout', {}, true);
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
}; };

View File

@ -1,14 +1,22 @@
export const PrincipalType = {
ADMIN: 'ADMIN',
USER: 'USER',
} as const;
export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType];
export interface LoginRequest { export interface LoginRequest {
email: string; account: string,
password: string; password: string;
principalType: PrincipalType;
} }
export interface LoginResponse { export interface LoginSuccessResponse {
accessToken: string; accessToken: string;
} }
export interface LoginCheckResponse { export interface CurrentUserContext {
id: string;
name: string; name: string;
role: 'ADMIN' | 'MEMBER'; type: PrincipalType;
} }

View File

@ -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);
};

View File

@ -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;
}

View File

@ -34,8 +34,8 @@ export interface PaymentRetrieveResponse {
status: 'DONE' | 'CANCELED'; status: 'DONE' | 'CANCELED';
requestedAt: string; requestedAt: string;
approvedAt: string; approvedAt: string;
detail: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail; detail?: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
cancellation?: CanceledPaymentDetailResponse; cancel?: CanceledPaymentDetailResponse;
} }
export interface CardPaymentDetail { export interface CardPaymentDetail {

View File

@ -1,5 +1,5 @@
import apiClient from "@_api/apiClient"; 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> => { export const confirmPayment = async (reservationId: string, request: PaymentConfirmRequest): Promise<PaymentCreateResponseV2> => {
return await apiClient.post<PaymentCreateResponseV2>(`/payments?reservationId=${reservationId}`, request); return await apiClient.post<PaymentCreateResponseV2>(`/payments?reservationId=${reservationId}`, request);

View File

@ -1,98 +1,33 @@
import apiClient from "@_api/apiClient"; import apiClient from '../apiClient';
import type { import type {
AdminReservationCreateRequest, MostReservedThemeIdListResponse,
MyReservationRetrieveListResponse, PendingReservationCreateRequest,
ReservationCreateRequest, PendingReservationCreateResponse,
ReservationCreateResponse, ReservationDetailRetrieveResponse,
ReservationCreateWithPaymentRequest, ReservationSummaryRetrieveListResponse
ReservationDetailV2, } from './reservationTypes';
ReservationPaymentRequest,
ReservationPaymentResponse,
ReservationRetrieveListResponse,
ReservationRetrieveResponse,
ReservationSearchQuery,
ReservationSummaryListV2,
WaitingCreateRequest
} from "./reservationTypes";
// GET /reservations export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
export const fetchReservations = async (): Promise<ReservationRetrieveListResponse> => { return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
return await apiClient.get<ReservationRetrieveListResponse>('/reservations', true);
}; };
// GET /reservations-mine export const confirmReservation = async (reservationId: string): Promise<void> => {
export const fetchMyReservations = async (): Promise<MyReservationRetrieveListResponse> => { await apiClient.post(`/reservations/${reservationId}/confirm`, {});
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 cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
// GET /v2/reservations return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {
return await apiClient.get<ReservationSummaryListV2>('/v2/reservations', true);
}; };
// GET /v2/reservations/{id}/details export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
export const fetchReservationDetailV2 = async (id: string): Promise<ReservationDetailV2> => { return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
return await apiClient.get<ReservationDetailV2>(`/v2/reservations/${id}/details`, true); }
};
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);
}

View File

@ -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`);
}

View File

@ -1,135 +1,62 @@
import type { MemberRetrieveResponse, MemberSummaryRetrieveResponse } from '@_api/member/memberTypes'; import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
import type { PaymentRetrieveResponse, PaymentType } from '@_api/payment/PaymentTypes'; import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
export const ReservationStatus = { export const ReservationStatus = {
PENDING: 'PENDING', PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED', CONFIRMED: 'CONFIRMED',
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED', CANCELED: 'CANCELED',
WAITING: 'WAITING', FAILED: 'FAILED',
CANCELED_BY_USER: 'CANCELED_BY_USER', EXPIRED: 'EXPIRED'
AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED'
} as const; } as const;
export type ReservationStatus = export type ReservationStatus =
| typeof ReservationStatus.PENDING | typeof ReservationStatus.PENDING
| typeof ReservationStatus.CONFIRMED | typeof ReservationStatus.CONFIRMED
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED | typeof ReservationStatus.CANCELED
| typeof ReservationStatus.WAITING | typeof ReservationStatus.FAILED
| typeof ReservationStatus.CANCELED_BY_USER | typeof ReservationStatus.EXPIRED;
| typeof ReservationStatus.AUTOMATICALLY_CANCELED;
export interface MyReservationRetrieveResponse { export interface PendingReservationCreateRequest {
id: string; scheduleId: string,
themeName: string; reserverName: string,
date: string; reserverContact: string,
time: string; participantCount: number,
status: ReservationStatus; requirement: string
rank: number;
paymentKey: string | null;
amount: number | null;
} }
export interface MyReservationRetrieveListResponse { export interface PendingReservationCreateResponse {
reservations: MyReservationRetrieveResponse[]; id: string
} }
export interface ReservationRetrieveResponse { export interface ReservationSummaryRetrieveResponse {
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 {
id: string; id: string;
user: MemberSummaryRetrieveResponse;
themeName: string; themeName: string;
date: string; date: string;
startAt: string; startAt: string;
applicationDateTime: string; status: ReservationStatus;
payment: PaymentRetrieveResponse; }
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[];
} }

View File

@ -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;
}

View File

@ -2,7 +2,8 @@ import apiClient from '../apiClient';
import type { import type {
AvailableThemeIdListResponse, AvailableThemeIdListResponse,
ScheduleCreateRequest, ScheduleCreateRequest,
ScheduleCreateResponse, ScheduleDetailRetrieveResponse, ScheduleCreateResponse,
ScheduleDetailRetrieveResponse,
ScheduleRetrieveListResponse, ScheduleRetrieveListResponse,
ScheduleUpdateRequest ScheduleUpdateRequest
} from './scheduleTypes'; } from './scheduleTypes';

View File

@ -1,9 +1,11 @@
export enum ScheduleStatus { export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
AVAILABLE = 'AVAILABLE',
HOLD = 'HOLD', export const ScheduleStatus = {
RESERVED = 'RESERVED', AVAILABLE: 'AVAILABLE' as ScheduleStatus,
BLOCKED = 'BLOCKED', HOLD: 'HOLD' as ScheduleStatus,
} RESERVED: 'RESERVED' as ScheduleStatus,
BLOCKED: 'BLOCKED' as ScheduleStatus,
};
export interface AvailableThemeIdListResponse { export interface AvailableThemeIdListResponse {
themeIds: string[]; themeIds: string[];

View File

@ -3,29 +3,12 @@ import type {
AdminThemeDetailRetrieveResponse, AdminThemeDetailRetrieveResponse,
AdminThemeSummaryRetrieveListResponse, AdminThemeSummaryRetrieveListResponse,
ThemeCreateRequest, ThemeCreateRequest,
ThemeCreateRequestV2, ThemeCreateResponse, ThemeCreateResponse,
ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse, ThemeIdListResponse,
ThemeRetrieveListResponseV2, ThemeInfoListResponse,
ThemeUpdateRequest, ThemeUpdateRequest
UserThemeRetrieveListResponse
} from './themeTypes'; } 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> => { export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes'); 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}`); return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
}; };
export const createThemeV2 = async (themeData: ThemeCreateRequestV2): Promise<ThemeCreateResponseV2> => { export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.post<ThemeCreateResponseV2>('/admin/themes', themeData); return await apiClient.post<ThemeCreateResponse>('/admin/themes', themeData);
}; };
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => { 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}`); await apiClient.del<any>(`/admin/themes/${id}`);
}; };
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => { export const fetchUserThemes = async (): Promise<ThemeInfoListResponse> => {
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes'); return await apiClient.get<ThemeInfoListResponse>('/themes');
}; };
export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise<ThemeRetrieveListResponseV2> => { export const findThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
return await apiClient.post<ThemeRetrieveListResponseV2>('/themes/retrieve', request); return await apiClient.post<ThemeInfoListResponse>('/themes/retrieve', request);
}; };

View File

@ -1,29 +1,4 @@
export interface ThemeCreateRequest { export interface AdminThemeDetailResponse {
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 {
id: string; id: string;
name: string; name: string;
description: string; description: string;
@ -42,7 +17,7 @@ export interface ThemeV2 {
updatedBy: string; updatedBy: string;
} }
export interface ThemeCreateRequestV2 { export interface ThemeCreateRequest {
name: string; name: string;
description: string; description: string;
thumbnailUrl: string; thumbnailUrl: string;
@ -56,7 +31,7 @@ export interface ThemeCreateRequestV2 {
isOpen: boolean; isOpen: boolean;
} }
export interface ThemeCreateResponseV2 { export interface ThemeCreateResponse {
id: string; id: string;
} }
@ -105,7 +80,7 @@ export interface AdminThemeDetailRetrieveResponse {
updatedBy: string; updatedBy: string;
} }
export interface UserThemeRetrieveResponse { export interface ThemeInfoResponse {
id: string; id: string;
name: string; name: string;
thumbnailUrl: string; thumbnailUrl: string;
@ -119,37 +94,26 @@ export interface UserThemeRetrieveResponse {
expectedMinutesTo: number; expectedMinutesTo: number;
} }
export interface UserThemeRetrieveListResponse { export interface ThemeInfoListResponse {
themes: UserThemeRetrieveResponse[]; themes: ThemeInfoResponse[];
} }
export interface ThemeListRetrieveRequest { export interface ThemeIdListResponse {
themeIds: string[]; 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 // @ts-ignore
export enum Difficulty { export enum Difficulty {
VERY_EASY = 'VERY_EASY', VERY_EASY = '매우 쉬움',
EASY = 'EASY', EASY = '쉬움',
NORMAL = 'NORMAL', NORMAL = '보통',
HARD = 'HARD', HARD = '어려움',
VERY_HARD = 'VERY_HARD', VERY_HARD = '매우 어려움',
}
export function mapThemeResponse(res: any): ThemeInfoResponse {
return {
...res,
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
}
} }

View File

@ -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);
};

View File

@ -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[];
}

View 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);
}

View 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;
}

View File

@ -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

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Navigate, useLocation } from 'react-router-dom'; import {Navigate, useLocation} from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import {useAuth} from '../context/AuthContext';
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => { const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { loggedIn, role, loading } = useAuth(); const { loggedIn, role, loading } = useAuth();

View File

@ -1,4 +1,4 @@
import React, { type ReactNode } from 'react'; import React, {type ReactNode} from 'react';
import Navbar from './Navbar'; import Navbar from './Navbar';
interface LayoutProps { interface LayoutProps {

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Link, useNavigate } from 'react-router-dom'; import {Link, useNavigate} from 'react-router-dom';
import { useAuth } from 'src/context/AuthContext'; import {useAuth} from 'src/context/AuthContext';
import 'src/css/navbar.css'; import 'src/css/navbar.css';
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
@ -21,20 +21,20 @@ const Navbar: React.FC = () => {
<nav className="navbar-container"> <nav className="navbar-container">
<div className="nav-links"> <div className="nav-links">
<Link className="nav-link" to="/"></Link> <Link className="nav-link" to="/"></Link>
<Link className="nav-link" to="/v2/reservation"></Link> <Link className="nav-link" to="/reservation"></Link>
</div> </div>
<div className="nav-actions"> <div className="nav-actions">
{!loggedIn ? ( {!loggedIn ? (
<> <>
<button className="btn btn-secondary" onClick={() => navigate('/v2/login')}></button> <button className="btn btn-secondary" onClick={() => navigate('/login')}></button>
<button className="btn btn-primary" onClick={() => navigate('/v2/signup')}></button> <button className="btn btn-primary" onClick={() => navigate('/signup')}></button>
</> </>
) : ( ) : (
<div className="profile-info"> <div className="profile-info">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" /> <img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span>{userName}</span> <span>{userName}</span>
<div className="dropdown-menu"> <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" /> <div className="dropdown-divider" />
<a className="dropdown-item" href="#" onClick={handleLogout}></a> <a className="dropdown-item" href="#" onClick={handleLogout}></a>
</div> </div>

View File

@ -1,13 +1,13 @@
import { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI'; import {checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout} from '@_api/auth/authAPI';
import type { LoginRequest, LoginResponse } from '@_api/auth/authTypes'; import {type LoginRequest, type LoginSuccessResponse, PrincipalType} from '@_api/auth/authTypes';
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import React, {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
interface AuthContextType { interface AuthContextType {
loggedIn: boolean; loggedIn: boolean;
userName: string | null; userName: string | null;
role: 'ADMIN' | 'MEMBER' | null; type: PrincipalType | null;
loading: boolean; // Add loading state to type loading: boolean;
login: (data: LoginRequest) => Promise<LoginResponse>; login: (data: LoginRequest) => Promise<LoginSuccessResponse>;
logout: () => Promise<void>; logout: () => Promise<void>;
checkLogin: () => Promise<void>; checkLogin: () => Promise<void>;
} }
@ -17,7 +17,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [userName, setUserName] = useState<string | null>(null); 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 [loading, setLoading] = useState(true); // Add loading state
const checkLogin = async () => { const checkLogin = async () => {
@ -25,11 +25,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const response = await apiCheckLogin(); const response = await apiCheckLogin();
setLoggedIn(true); setLoggedIn(true);
setUserName(response.name); setUserName(response.name);
setRole(response.role); setType(response.type);
} catch (error) { } catch (error) {
setLoggedIn(false); setLoggedIn(false);
setUserName(null); setUserName(null);
setRole(null); setType(null);
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
} finally { } finally {
setLoading(false); // Set loading to false after check is complete 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 login = async (data: LoginRequest) => {
const response = await apiLogin(data); const response = await apiLogin({ ...data });
await checkLogin(); setLoggedIn(true);
setType(data.principalType);
return response; return response;
}; };
@ -52,13 +53,13 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} finally { } finally {
setLoggedIn(false); setLoggedIn(false);
setUserName(null); setUserName(null);
setRole(null); setType(null);
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
} }
}; };
return ( return (
<AuthContext.Provider value={{ loggedIn, userName, role, loading, login, logout, checkLogin }}> <AuthContext.Provider value={{ loggedIn, userName, type, loading, login, logout, checkLogin }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@ -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;
}

View File

@ -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;
}

View File

@ -31,6 +31,7 @@
gap: 20px; gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); 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; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer;
} }
.theme-ranking-item-v2:hover { .theme-ranking-item-v2:hover {
@ -64,3 +65,101 @@
color: #505a67; color: #505a67;
margin: 0; 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;
}

View File

@ -68,17 +68,77 @@
margin: 0; margin: 0;
} }
/* Canceled Card Style */ /* --- Status Badge --- */
.reservation-summary-card-v2.status-canceled_by_user { .card-status-badge {
background-color: #f8f9fa; position: absolute;
opacity: 0.6; 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, /* --- Card Status Styles --- */
.reservation-summary-card-v2.status-canceled_by_user .summary-datetime-v2, .reservation-summary-card-v2 {
.reservation-summary-card-v2.status-canceled_by_user .summary-details-v2 strong { 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; 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 */
.detail-button-v2 { .detail-button-v2 {

View File

@ -327,13 +327,13 @@
} }
.modal-content { .modal-content {
background-color: #ffffff; background-color: #ffffff !important;
padding: 32px; padding: 32px !important;
border-radius: 16px; border-radius: 16px !important;
width: 90%; width: 90% !important;
max-width: 500px; max-width: 500px !important;
position: relative; position: relative !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important;
} }
.modal-close-button { .modal-close-button {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -63,3 +63,9 @@
.signup-form-v2 .btn-primary:hover { .signup-form-v2 .btn-primary:hover {
background-color: #1B64DA; background-color: #1B64DA;
} }
.error-text {
color: #E53E3E;
font-size: 12px;
margin-top: 4px;
}

View File

@ -3,9 +3,8 @@ import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js'; import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import './css/style.css'; import '@_css/style.css';
import './css/reservation.css'; import '@_css/toss-style.css';
import './css/toss-style.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

@ -1,31 +1,90 @@
import React, { useEffect, useState } from 'react'; import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
import { mostReservedThemes } from '@_api/theme/themeAPI'; 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 HomePage: React.FC = () => {
const [ranking, setRanking] = useState<any[]>([]); const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const fetchData = async () => { 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 ( return (
<div className="content-container"> <div className="home-container-v2">
<h2 className="content-container-title"> </h2> <h2 className="page-title"> </h2>
<ul className="list-unstyled" id="theme-ranking"> <div className="theme-ranking-list-v2">
{ranking.map(theme => ( {ranking.map(theme => (
<li key={theme.id} className="d-flex my-4"> <div key={theme.id} className="theme-ranking-item-v2" onClick={() => handleThemeClick(theme)}>
<img className="me-3 img-thumbnail" src={theme.thumbnail} alt={theme.name} style={{ width: '150px' }} /> <img className="thumbnail" src={theme.thumbnailUrl} alt={theme.name} />
<div className="media-body"> <div className="theme-info">
<h5 className="mt-0 mb-1">{theme.name}</h5> <h5 className="theme-name">{theme.name}</h5>
{theme.description}
</div> </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> </div>
); );
}; };

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, {useState} from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import {useAuth} from '@_context/AuthContext';
import '@_css/login-page-v2.css';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -11,9 +12,11 @@ const LoginPage: React.FC = () => {
const from = location.state?.from?.pathname || '/'; const from = location.state?.from?.pathname || '/';
const handleLogin = async () => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try { try {
await login({email, password}); const principalType = from.startsWith('/admin') ? 'ADMIN' : 'USER';
await login({ account: email, password: password, principalType: principalType });
alert('로그인에 성공했어요!'); alert('로그인에 성공했어요!');
navigate(from, { replace: true }); navigate(from, { replace: true });
@ -21,24 +24,39 @@ const LoginPage: React.FC = () => {
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.'; const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
alert(message); alert(message);
console.error('로그인 실패:', error); console.error('로그인 실패:', error);
setEmail('');
setPassword(''); setPassword('');
} }
} }
return ( return (
<div className="content-container" style={{ width: '300px' }}> <div className="login-container-v2">
<h2 className="content-container-title">Login</h2> <h2 className="page-title"></h2>
<div className="form-group"> <form className="login-form-v2" onSubmit={handleLogin}>
<input type="email" className="form-control" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> <div className="form-group">
</div> <input
<div className="form-group"> type="email"
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> className="form-input"
</div> placeholder="이메일"
<div className="button-group full-width"> value={email}
<button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button> onChange={e => setEmail(e.target.value)}
<button className="btn btn-custom" onClick={handleLogin}>Login</button> required
</div> />
</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> </div>
); );
}; };

View File

@ -1,88 +1,382 @@
import React, { useEffect, useState } from 'react'; import {cancelPayment} from '@_api/payment/paymentAPI';
import { useNavigate } from 'react-router-dom'; import type {PaymentRetrieveResponse} from '@_api/payment/PaymentTypes';
import { cancelWaiting, fetchMyReservations } from '@_api/reservation/reservationAPI'; import {cancelReservation, fetchDetailById, fetchSummaryByMember} from '@_api/reservation/reservationAPI';
import type { MyReservationRetrieveResponse } from '@_api/reservation/reservationTypes'; import {
import { ReservationStatus } from '@_api/reservation/reservationTypes'; type ReservationDetail,
import { isLoginRequiredError } from '@_api/apiClient'; 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 MyReservationPage: React.FC = () => {
const [reservations, setReservations] = useState<MyReservationRetrieveResponse[]>([]); const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const handleError = (err: any) => { const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
if (isLoginRequiredError(err)) { const [isModalOpen, setIsModalOpen] = useState(false);
alert('로그인이 필요해요.'); const [isDetailLoading, setIsDetailLoading] = useState(false);
navigate('/login', { state: { from: location } }); const [detailError, setDetailError] = useState<string | null>(null);
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail');
alert(message); const [isCancelling, setIsCancelling] = useState(false);
console.error(err);
const loadReservations = async () => {
try {
setIsLoading(true);
const data = await fetchSummaryByMember();
setReservations(data.reservations);
setError(null);
} catch (err) {
setError('예약 목록을 불러오는 데 실패했습니다.');
} finally {
setIsLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchMyReservations() loadReservations();
.then(res => setReservations(res.reservations))
.catch(handleError);
}, []); }, []);
const _cancelWaiting = (id: string) => { const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
cancelWaiting(id) try {
.then(() => { setIsDetailLoading(true);
alert('예약 대기가 취소되었습니다.'); setDetailError(null);
setReservations(reservations.filter(r => r.id.toString() !== id)); setModalView('detail');
}) const detailData = await fetchDetailById(id);
.catch(handleError); 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) => { const handleCloseModal = () => {
if (status === ReservationStatus.CONFIRMED) { setIsModalOpen(false);
return '예약'; setSelectedReservation(null);
}
if (status === ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) {
return '예약 - 결제 필요';
}
if (status === ReservationStatus.WAITING) {
return `${rank}번째 예약 대기`;
}
return '';
}; };
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 ( return (
<div className="content-container"> <div className="my-reservation-container-v2">
<h2 className="content-container-title"> </h2> <h1> V2</h1>
<div className="table-container"></div>
<table className="table"> {isLoading && <p> ...</p>}
<thead> {error && <p className="error-message-v2">{error}</p>}
<tr>
<th></th> {!isLoading && !error && (
<th></th> <div className="reservation-list-v2">
<th></th> {reservations.map((res) => {
<th></th> const status = getReservationStatus(res);
<th> </th> return (
<th>paymentKey</th> <div key={res.id} className={`reservation-summary-card-v2 ${status.className}`}>
<th></th> <div className="card-status-badge">{status.text}</div>
<th></th> <div className="summary-details-v2">
</tr> <h3 className="summary-theme-name-v2">{res.themeName}</h3>
</thead> <p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
<tbody> </div>
{reservations.map(r => ( <button
<tr key={r.id}> onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
<td>{r.themeName}</td> disabled={isDetailLoading}
<td>{r.date}</td> className="detail-button-v2"
<td>{r.time}</td> >
<td>{getStatusText(r.status, r.rank)}</td> {isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
<td> </button>
{r.status === ReservationStatus.WAITING && </div>
<button className="btn btn-danger" onClick={() => _cancelWaiting(r.id.toString())}></button>} );
</td> })}
<td>{r.paymentKey}</td> </div>
<td>{r.amount}</td> )}
<td></td>
</tr> {isModalOpen && selectedReservation && (
))} <div className="modal-overlay-v2" onClick={handleCloseModal}>
</tbody> <div className="modal-content-v2" onClick={(e) => e.stopPropagation()}>
</table> <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> </div>
); );
}; };

View File

@ -1,9 +1,10 @@
import { isLoginRequiredError } from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPIV2'; import {createPendingReservation} from '@_api/reservation/reservationAPI';
import {fetchContact} from '@_api/user/userAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, { useState } from 'react'; import React, {useEffect, useState} from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
const ReservationFormPage: React.FC = () => { const ReservationFormPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -12,8 +13,25 @@ const ReservationFormPage: React.FC = () => {
const [reserverName, setReserverName] = useState(''); const [reserverName, setReserverName] = useState('');
const [reserverContact, setReserverContact] = useState(''); const [reserverContact, setReserverContact] = useState('');
const [participantCount, setParticipantCount] = useState(2); const [participantCount, setParticipantCount] = useState(theme.minParticipants || 1);
const [requirement, setRequirement] = useState(''); 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) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { 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 = () => { const handlePayment = () => {
if (!reserverName || !reserverContact) { if (!reserverName || !reserverContact) {
alert('예약자명과 연락처를 입력해주세요.'); alert('예약자명과 연락처를 입력해주세요.');
@ -46,7 +60,7 @@ const ReservationFormPage: React.FC = () => {
createPendingReservation(reservationData) createPendingReservation(reservationData)
.then(res => { .then(res => {
navigate('/v2-1/reservation/payment', { navigate('/reservation/payment', {
state: { state: {
reservationId: res.id, reservationId: res.id,
themeName: theme.name, themeName: theme.name,
@ -60,7 +74,6 @@ const ReservationFormPage: React.FC = () => {
}; };
if (!scheduleId || !theme) { if (!scheduleId || !theme) {
// Handle case where state is not passed correctly
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
<h2 className="page-title"> </h2> <h2 className="page-title"> </h2>
@ -85,11 +98,25 @@ const ReservationFormPage: React.FC = () => {
<h3> </h3> <h3> </h3>
<div className="form-group"> <div className="form-group">
<label htmlFor="reserverName"></label> <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>
<div className="form-group"> <div className="form-group">
<label htmlFor="reserverContact"></label> <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>
<div className="form-group"> <div className="form-group">
<label></label> <label></label>

View File

@ -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;

View File

@ -1,49 +1,19 @@
import { isLoginRequiredError } from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import { holdSchedule, findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI'; import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes'; import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes';
import { findThemesByIds } from '@_api/theme/themeAPI'; import {findThemesByIds} from '@_api/theme/themeAPI';
import { Difficulty } from '@_api/theme/themeTypes'; import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react'; import React, {useEffect, useState} from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
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 => { const ReservationStep1Page: React.FC = () => {
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 [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
const [themes, setThemes] = useState<ThemeV21[]>([]); const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null); const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]); const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null); const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
@ -77,9 +47,17 @@ const ReservationStep1PageV21: React.FC = () => {
} }
}) })
.then(themeResponse => { .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(() => { .finally(() => {
setSelectedTheme(null); setSelectedTheme(null);
setSchedules([]); setSchedules([]);
@ -96,7 +74,16 @@ const ReservationStep1PageV21: React.FC = () => {
setSchedules(res.schedules); setSchedules(res.schedules);
setSelectedSchedule(null); 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]); }, [selectedDate, selectedTheme]);
@ -117,7 +104,7 @@ const ReservationStep1PageV21: React.FC = () => {
holdSchedule(selectedSchedule.id) holdSchedule(selectedSchedule.id)
.then(() => { .then(() => {
navigate('/v2/reservation/form', { navigate('/reservation/form', {
state: { state: {
scheduleId: selectedSchedule.id, scheduleId: selectedSchedule.id,
theme: selectedTheme, theme: selectedTheme,
@ -197,7 +184,7 @@ const ReservationStep1PageV21: React.FC = () => {
); );
}; };
const openThemeModal = (theme: ThemeV21) => { const openThemeModal = (theme: ThemeInfoResponse) => {
setSelectedTheme(theme); setSelectedTheme(theme);
setIsThemeModalOpen(true); setIsThemeModalOpen(true);
}; };
@ -237,7 +224,7 @@ const ReservationStep1PageV21: React.FC = () => {
<h4>{theme.name}</h4> <h4>{theme.name}</h4>
<div className="theme-meta"> <div className="theme-meta">
<p><strong>1 :</strong> {theme.price.toLocaleString()}</p> <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.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p> <p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
<p><strong> :</strong> {theme.availableMinutes}</p> <p><strong> :</strong> {theme.availableMinutes}</p>
@ -279,7 +266,7 @@ const ReservationStep1PageV21: React.FC = () => {
<h2>{selectedTheme.name}</h2> <h2>{selectedTheme.name}</h2>
<div className="modal-section"> <div className="modal-section">
<h3> </h3> <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.minParticipants} ~ {selectedTheme.maxParticipants}</p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p> <p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p>
<p><strong>1 :</strong> {selectedTheme.price.toLocaleString()}</p> <p><strong>1 :</strong> {selectedTheme.price.toLocaleString()}</p>
@ -313,4 +300,4 @@ const ReservationStep1PageV21: React.FC = () => {
); );
}; };
export default ReservationStep1PageV21; export default ReservationStep1Page;

View File

@ -1,11 +1,11 @@
import { isLoginRequiredError } from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import { confirmPayment } from '@_api/payment/paymentAPI'; import {confirmPayment} from '@_api/payment/paymentAPI';
import { PaymentType, type PaymentConfirmRequest } from '@_api/payment/PaymentTypes'; import {type PaymentConfirmRequest, PaymentType} from '@_api/payment/PaymentTypes';
import { confirmReservation } from '@_api/reservation/reservationAPIV2'; import {confirmReservation} from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, { useEffect, useRef } from 'react'; import React, {useEffect, useRef} from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
declare global { declare global {
interface Window { interface Window {
@ -13,7 +13,7 @@ declare global {
} }
} }
const ReservationStep2PageV21: React.FC = () => { const ReservationStep2Page: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const paymentWidgetRef = useRef<any>(null); const paymentWidgetRef = useRef<any>(null);
@ -35,7 +35,7 @@ const ReservationStep2PageV21: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!reservationId) { if (!reservationId) {
alert('잘못된 접근입니다.'); alert('잘못된 접근입니다.');
navigate('/v2-1/reservation'); navigate('/reservation');
return; return;
} }
@ -85,7 +85,7 @@ const ReservationStep2PageV21: React.FC = () => {
}) })
.then(() => { .then(() => {
alert('결제가 완료되었어요!'); alert('결제가 완료되었어요!');
navigate('/v2-1/reservation/success', { navigate('/reservation/success', {
state: { state: {
themeName, themeName,
date, date,
@ -128,4 +128,4 @@ const ReservationStep2PageV21: React.FC = () => {
); );
}; };
export default ReservationStep2PageV21; export default ReservationStep2Page;

View File

@ -1,9 +1,9 @@
import '@_css/reservation-v2-1.css'; // Reuse the new CSS import '@_css/reservation-v2-1.css'; // Reuse the new CSS
import React from 'react'; import React from 'react';
import { Link, useLocation } from 'react-router-dom'; import {Link, useLocation} from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
const ReservationSuccessPageV21: React.FC = () => { const ReservationSuccessPage: React.FC = () => {
const location = useLocation(); const location = useLocation();
const { themeName, date, startAt } = (location.state as { const { themeName, date, startAt } = (location.state as {
themeName: string; themeName: string;
@ -25,7 +25,7 @@ const ReservationSuccessPageV21: React.FC = () => {
<p><strong>:</strong> {formattedTime}</p> <p><strong>:</strong> {formattedTime}</p>
</div> </div>
<div className="success-page-actions"> <div className="success-page-actions">
<Link to="/my-reservation/v2" className="action-button"> <Link to="/my-reservation" className="action-button">
</Link> </Link>
<Link to="/" className="action-button secondary"> <Link to="/" className="action-button secondary">
@ -36,4 +36,4 @@ const ReservationSuccessPageV21: React.FC = () => {
); );
}; };
export default ReservationSuccessPageV21; export default ReservationSuccessPage;

View File

@ -1,42 +1,146 @@
import React, { useState } from 'react'; import {signup} from '@_api/user/userAPI';
import { useNavigate } from 'react-router-dom'; import type {UserCreateRequest, UserCreateResponse} from '@_api/user/userTypes';
import { signup } from '@_api/member/memberAPI'; import '@_css/signup-page-v2.css';
import type { SignupRequest } from '@_api/member/memberTypes'; import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
const MIN_PASSWORD_LENGTH = 8;
const SignupPage: React.FC = () => { const SignupPage: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [name, setName] = 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 navigate = useNavigate();
const handleSignup = async () => { const validate = () => {
const request: SignupRequest = { email, password, name }; const newErrors: Record<string, string> = {};
await signup(request)
.then((response) => { if (!name.trim()) {
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`); newErrors.name = '이름을 입력해주세요.';
navigate('/login') }
}) if (!email.trim()) {
.catch(error => { newErrors.email = '이메일을 입력해주세요.';
console.error(error); } 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 ( return (
<div className="content-container" style={{ width: '400px' }}> <div className="signup-container-v2">
<h2 className="content-container-title">Signup</h2> <h2 className="page-title"></h2>
<div className="form-group"> <form className="signup-form-v2" onSubmit={handleSignup}>
<label>Email address</label> <div className="form-group">
<input type="email" className="form-control" placeholder="Enter email" value={email} onChange={e => setEmail(e.target.value)} /> <label className="form-label"></label>
</div> <input
<div className="form-group"> type="email"
<label>Password</label> className="form-input"
<input type="password" className="form-control" placeholder="Enter password" value={password} onChange={e => setPassword(e.target.value)} /> placeholder="이메일을 입력하세요"
</div> value={email}
<div className="form-group"> onChange={e => setEmail(e.target.value)}
<label>Name</label> required
<input type="text" className="form-control" placeholder="Enter name" value={name} onChange={e => setName(e.target.value)} /> />
</div> {hasSubmitted && errors.email && (
<button className="btn btn-custom" onClick={handleSignup}>Register</button> <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> </div>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { type ReactNode } from 'react'; import React, {type ReactNode} from 'react';
import AdminNavbar from './AdminNavbar'; import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps { interface AdminLayoutProps {

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Link, useNavigate } from 'react-router-dom'; import {Link, useNavigate} from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import {useAuth} from '@_context/AuthContext';
import '../../css/navbar.css'; import '@_css/navbar.css';
const AdminNavbar: React.FC = () => { const AdminNavbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth(); const { loggedIn, userName, logout } = useAuth();
@ -21,10 +21,7 @@ const AdminNavbar: React.FC = () => {
<nav className="navbar-container"> <nav className="navbar-container">
<div className="nav-links"> <div className="nav-links">
<Link className="nav-link" to="/admin"></Link> <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/theme"></Link>
<Link className="nav-link" to="/admin/time"></Link>
<Link className="nav-link" to="/admin/schedule"></Link> <Link className="nav-link" to="/admin/schedule"></Link>
</div> </div>
<div className="nav-actions"> <div className="nav-actions">

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import '../../css/admin-page.css'; import '@_css/admin-page.css';
const AdminPage: React.FC = () => { const AdminPage: React.FC = () => {
return ( return (

View File

@ -1,11 +1,21 @@
import { isLoginRequiredError } from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import { createSchedule, deleteSchedule, findScheduleById, findSchedules, updateSchedule } from '@_api/schedule/scheduleAPI'; import {
import { ScheduleStatus, type ScheduleDetailRetrieveResponse, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes'; createSchedule,
import { fetchAdminThemes } from '@_api/theme/themeAPI'; deleteSchedule,
import type { AdminThemeSummaryRetrieveResponse } from '@_api/theme/themeTypes'; 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 '@_css/admin-schedule-page.css';
import React, { Fragment, useEffect, useState } from 'react'; import React, {Fragment, useEffect, useState} from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
const getScheduleStatusText = (status: ScheduleStatus): string => { const getScheduleStatusText = (status: ScheduleStatus): string => {
switch (status) { switch (status) {

View File

@ -1,14 +1,14 @@
import { isLoginRequiredError } from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
import { import {
createThemeV2, type AdminThemeDetailResponse,
deleteTheme, Difficulty,
fetchAdminThemeDetail, type ThemeCreateRequest,
updateTheme type ThemeUpdateRequest
} from '@_api/theme/themeAPI'; } from '@_api/theme/themeTypes';
import { Difficulty, type ThemeCreateRequestV2, type ThemeUpdateRequest, type ThemeV2 } from '@_api/theme/themeTypes'; import React, {useEffect, useState} from 'react';
import React, { useEffect, useState } from 'react'; import {useLocation, useNavigate, useParams} from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import '@_css/admin-theme-edit-page.css';
import '../../css/admin-theme-edit-page.css';
const AdminThemeEditPage: React.FC = () => { const AdminThemeEditPage: React.FC = () => {
const { themeId } = useParams<{ themeId: string }>(); const { themeId } = useParams<{ themeId: string }>();
@ -17,8 +17,8 @@ const AdminThemeEditPage: React.FC = () => {
const isNew = themeId === 'new'; const isNew = themeId === 'new';
const [theme, setTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null); const [theme, setTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
const [originalTheme, setOriginalTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null); const [originalTheme, setOriginalTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(isNew); const [isEditing, setIsEditing] = useState(isNew);
@ -35,7 +35,7 @@ const AdminThemeEditPage: React.FC = () => {
useEffect(() => { useEffect(() => {
if (isNew) { if (isNew) {
const newTheme: ThemeCreateRequestV2 = { const newTheme: ThemeCreateRequest = {
name: '', name: '',
description: '', description: '',
thumbnailUrl: '', thumbnailUrl: '',
@ -55,7 +55,7 @@ const AdminThemeEditPage: React.FC = () => {
fetchAdminThemeDetail(themeId) fetchAdminThemeDetail(themeId)
.then(data => { .then(data => {
// Map AdminThemeDetailRetrieveResponse to ThemeV2 // Map AdminThemeDetailRetrieveResponse to ThemeV2
const fetchedTheme: ThemeV2 = { const fetchedTheme: AdminThemeDetailResponse = {
id: data.id, id: data.id,
name: data.name, name: data.name,
description: data.description, description: data.description,
@ -112,7 +112,7 @@ const AdminThemeEditPage: React.FC = () => {
try { try {
if (isNew) { if (isNew) {
await createThemeV2(theme as ThemeCreateRequestV2); await createTheme(theme as ThemeCreateRequest);
alert('테마가 성공적으로 생성되었습니다.'); alert('테마가 성공적으로 생성되었습니다.');
navigate(`/admin/theme`); navigate(`/admin/theme`);
} else { } else {

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, {useEffect, useState} from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import { fetchAdminThemes } from '@_api/theme/themeAPI'; import {fetchAdminThemes} from '@_api/theme/themeAPI';
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes'; import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
import { isLoginRequiredError } from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import '../../css/admin-theme-page.css'; import '@_css/admin-theme-page.css';
const AdminThemePage: React.FC = () => { const AdminThemePage: React.FC = () => {
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]); const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -29,6 +29,7 @@
"@_api/*": ["src/api/*"], "@_api/*": ["src/api/*"],
"@_assets/*": ["src/assets/*"], "@_assets/*": ["src/assets/*"],
"@_components/*": ["src/components/*"], "@_components/*": ["src/components/*"],
"@_context/*": ["src/context/*"],
"@_css/*": ["src/css/*"], "@_css/*": ["src/css/*"],
"@_hooks/*": ["src/hooks/*"], "@_hooks/*": ["src/hooks/*"],
"@_pages/*": ["src/pages/*"], "@_pages/*": ["src/pages/*"],

View File

@ -1,4 +1,4 @@
import { defineConfig } from 'vite' import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths' import tsconfigPaths from 'vite-tsconfig-paths'

View 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)
}
}
}

View 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", "관리자를 찾을 수 없어요."),
}

View File

@ -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,
}

View File

@ -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?
}

View File

@ -4,55 +4,111 @@ import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.admin.business.AdminService
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginContext
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginResponse import roomescape.auth.web.LoginSuccessResponse
import roomescape.member.implement.MemberFinder import roomescape.common.dto.CurrentUserContext
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.common.dto.LoginCredentials
import roomescape.common.dto.PrincipalType
import roomescape.user.business.UserService
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
const val CLAIM_PERMISSION_KEY = "permission"
const val CLAIM_TYPE_KEY = "principal_type"
@Service @Service
class AuthService( class AuthService(
private val memberFinder: MemberFinder, private val adminService: AdminService,
private val jwtHandler: JwtHandler, private val userService: UserService,
private val loginHistoryService: LoginHistoryService,
private val jwtUtils: JwtUtils,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun login(request: LoginRequest): LoginResponse { fun login(
val params = "email=${request.email}, password=${request.password}" request: LoginRequest,
log.debug { "[AuthService.login] 시작: $params" } context: LoginContext
): LoginSuccessResponse {
log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val member: MemberEntity = fetchOrThrow(AuthErrorCode.LOGIN_FAILED) { val (credentials, extraClaims) = getCredentials(request)
memberFinder.findByEmailAndPassword(request.email, request.password)
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) @Transactional(readOnly = true)
fun checkLogin(memberId: Long): LoginCheckResponse { fun findContextById(id: Long, type: PrincipalType): CurrentUserContext {
log.debug { "[AuthService.checkLogin] 시작: memberId=$memberId" } 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) PrincipalType.USER -> {
.also { log.info { "[AuthService.checkLogin] 완료: memberId=$memberId, role=${it.role}" } } userService.findContextById(id)
} }
}.also {
private fun fetchOrThrow(errorCode: AuthErrorCode, block: () -> MemberEntity): MemberEntity { log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" }
try {
return block()
} catch (e: Exception) {
throw AuthException(errorCode, e.message ?: errorCode.message)
} }
} }
fun logout(memberId: Long) { private fun verifyPasswordOrThrow(
log.info { "[AuthService.logout] 로그아웃: memberId=$memberId" } 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
} }
} }

View File

@ -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}" }
}
}
}

View File

@ -1,46 +1,52 @@
package roomescape.auth.docs package roomescape.auth.docs
import io.swagger.v3.oas.annotations.Operation 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.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.LoginCheckResponse
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginResponse import roomescape.auth.web.LoginSuccessResponse
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.CurrentUser
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.Public
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다") @Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다")
interface AuthAPI { interface AuthAPI {
@Public
@Operation(summary = "로그인") @Operation(summary = "로그인")
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."), ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."),
) )
fun login( fun login(
@Valid @RequestBody loginRequest: LoginRequest @Valid @RequestBody loginRequest: LoginRequest,
): ResponseEntity<CommonApiResponse<LoginResponse>> servletRequest: HttpServletRequest
): ResponseEntity<CommonApiResponse<LoginSuccessResponse>>
@Operation(summary = "로그인 상태 확인") @Operation(summary = "로그인 상태 확인")
@ApiResponses( @ApiResponses(
ApiResponse( ApiResponse(
responseCode = "200", responseCode = "200",
description = "로그인 상태이며, 로그인된 회원의 이름 / 권한을 반환합니다.", description = "입력된 ID / 결과(Boolean)을 반환합니다.",
useReturnTypeSchema = true useReturnTypeSchema = true
), ),
) )
fun checkLogin( fun checkLogin(
@MemberId @Parameter(hidden = true) memberId: Long @CurrentUser user: CurrentUserContext
): ResponseEntity<CommonApiResponse<LoginCheckResponse>> ): ResponseEntity<CommonApiResponse<CurrentUserContext>>
@LoginRequired
@Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"])
@ApiResponses( @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>>
} }

View File

@ -14,4 +14,6 @@ enum class AuthErrorCode(
ACCESS_DENIED(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없어요."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없어요."),
LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "A005", "이메일과 비밀번호를 확인해주세요."), LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "A005", "이메일과 비밀번호를 확인해주세요."),
MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A006", "회원 정보를 찾을 수 없어요."), MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A006", "회원 정보를 찾을 수 없어요."),
TEMPORARY_AUTH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "A999", "일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.");
} }

View File

@ -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"
}
}

View 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)
}
}
}

View File

@ -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)

View File

@ -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>
}

View File

@ -1,44 +1,46 @@
package roomescape.auth.web package roomescape.auth.web
import io.swagger.v3.oas.annotations.Parameter import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping 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 org.springframework.web.bind.annotation.RestController
import roomescape.auth.business.AuthService import roomescape.auth.business.AuthService
import roomescape.auth.docs.AuthAPI 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 import roomescape.common.dto.response.CommonApiResponse
@RestController @RestController
@RequestMapping("/auth")
class AuthController( class AuthController(
private val authService: AuthService private val authService: AuthService,
) : AuthAPI { ) : AuthAPI {
@PostMapping("/login") @PostMapping("/login")
override fun login( override fun login(
@Valid @RequestBody loginRequest: LoginRequest, loginRequest: LoginRequest,
): ResponseEntity<CommonApiResponse<LoginResponse>> { servletRequest: HttpServletRequest
val response: LoginResponse = authService.login(loginRequest) ): ResponseEntity<CommonApiResponse<LoginSuccessResponse>> {
val response = authService.login(request = loginRequest, context = servletRequest.toLoginContext())
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@GetMapping("/login/check") @GetMapping("/login/check")
override fun checkLogin( override fun checkLogin(
@MemberId @Parameter(hidden = true) memberId: Long @CurrentUser user: CurrentUserContext,
): ResponseEntity<CommonApiResponse<LoginCheckResponse>> { ): ResponseEntity<CommonApiResponse<CurrentUserContext>> {
val response: LoginCheckResponse = authService.checkLogin(memberId) return ResponseEntity.ok(CommonApiResponse(user))
return ResponseEntity.ok(CommonApiResponse(response))
} }
@PostMapping("/logout") @PostMapping("/logout")
override fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>> { override fun logout(
authService.logout(memberId) @CurrentUser user: CurrentUserContext,
servletResponse: HttpServletResponse
return ResponseEntity.noContent().build() ): ResponseEntity<CommonApiResponse<Unit>> {
return ResponseEntity.ok().build()
} }
} }

View File

@ -1,24 +1,24 @@
package roomescape.auth.web package roomescape.auth.web
import io.swagger.v3.oas.annotations.media.Schema import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.constraints.Email import roomescape.common.dto.PrincipalType
import jakarta.validation.constraints.NotBlank
data class LoginResponse( data class LoginContext(
val accessToken: String val ipAddress: String,
val userAgent: String,
) )
data class LoginCheckResponse( fun HttpServletRequest.toLoginContext() = LoginContext(
@Schema(description = "로그인된 회원의 이름") ipAddress = this.remoteAddr,
val name: String, userAgent = this.getHeader("User-Agent")
@Schema(description = "회원(MEMBER) / 관리자(ADMIN)")
val role: String,
) )
data class LoginRequest( data class LoginRequest(
@Email(message = "이메일 형식이 일치하지 않습니다. 예시: abc123@gmail.com") val account: String,
val email: String, val password: String,
val principalType: PrincipalType
@NotBlank(message = "비밀번호는 공백일 수 없습니다.") )
val password: String
data class LoginSuccessResponse(
val accessToken: String
) )

View File

@ -1,13 +1,25 @@
package roomescape.auth.web.support package roomescape.auth.web.support
@Target(AnnotationTarget.FUNCTION) import roomescape.admin.infrastructure.persistence.Privilege
@Retention(AnnotationRetention.RUNTIME)
annotation class Admin
@Target(AnnotationTarget.FUNCTION) @Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME) @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) @Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class MemberId annotation class CurrentUser

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.slf4j.MDC
import org.springframework.core.MethodParameter import org.springframework.core.MethodParameter
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer import org.springframework.web.method.support.ModelAndViewContainer
import roomescape.auth.business.AuthService
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException 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 {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class MemberIdResolver( class CurrentUserContextResolver(
private val jwtHandler: JwtHandler private val jwtUtils: JwtUtils,
private val authService: AuthService
) : HandlerMethodArgumentResolver { ) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean { override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.hasParameterAnnotation(MemberId::class.java) return parameter.hasParameterAnnotation(CurrentUser::class.java)
} }
override fun resolveArgument( override fun resolveArgument(
@ -30,17 +33,17 @@ class MemberIdResolver(
mavContainer: ModelAndViewContainer?, mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest, webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory? binderFactory: WebDataBinderFactory?
): Any { ): Any? {
val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest
val token: String? = request.accessToken() val token: String? = request.accessToken()
try { try {
return jwtHandler.getMemberIdFromToken(token) val (id, type) = jwtUtils.extractIdAndType(token)
.also { MDC.put("member_id", "$it") }
return authService.findContextById(id, type)
} catch (e: Exception) { } catch (e: Exception) {
log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" } log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" }
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
throw AuthException(errorCode, e.message ?: errorCode.message)
} }
} }
} }

View File

@ -5,7 +5,7 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.data.domain.AuditorAware import org.springframework.data.domain.AuditorAware
import org.springframework.data.jpa.repository.config.EnableJpaAuditing 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.* import java.util.*
@Configuration @Configuration
@ -18,7 +18,7 @@ class JpaConfig {
class MdcAuditorAware : AuditorAware<Long> { class MdcAuditorAware : AuditorAware<Long> {
override fun getCurrentAuditor(): Optional<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) { if (memberIdStr == null) {
return Optional.empty() return Optional.empty()

View File

@ -4,20 +4,26 @@ import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import roomescape.auth.web.support.AuthInterceptor import roomescape.auth.web.support.interceptors.AdminInterceptor
import roomescape.auth.web.support.MemberIdResolver import roomescape.auth.web.support.interceptors.AuthenticatedInterceptor
import roomescape.auth.web.support.interceptors.UserInterceptor
import roomescape.auth.web.support.resolver.CurrentUserContextResolver
@Configuration @Configuration
class WebMvcConfig( class WebMvcConfig(
private val memberIdResolver: MemberIdResolver, private val adminInterceptor: AdminInterceptor,
private val authInterceptor: AuthInterceptor private val userInterceptor: UserInterceptor,
private val authenticatedInterceptor: AuthenticatedInterceptor,
private val currentUserContextResolver: CurrentUserContextResolver
) : WebMvcConfigurer { ) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) { override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(memberIdResolver) resolvers.add(currentUserContextResolver)
} }
override fun addInterceptors(registry: InterceptorRegistry) { override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(authInterceptor) registry.addInterceptor(adminInterceptor)
registry.addInterceptor(userInterceptor)
registry.addInterceptor(authenticatedInterceptor)
} }
} }

View 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
)

View File

@ -1,7 +1,9 @@
package roomescape.common.entity package roomescape.common.entity
import jakarta.persistence.* import jakarta.persistence.*
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.domain.Persistable import org.springframework.data.domain.Persistable
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
@ -10,28 +12,24 @@ import kotlin.jvm.Transient
@MappedSuperclass @MappedSuperclass
@EntityListeners(AuditingEntityListener::class) @EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity( abstract class AuditingBaseEntity(
id: Long,
) : PersistableBaseEntity(id) {
@Column(updatable = false) @Column(updatable = false)
@CreatedDate @CreatedDate
var createdAt: LocalDateTime? = null, lateinit var createdAt: LocalDateTime
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
@Column
@LastModifiedDate @LastModifiedDate
var lastModifiedAt: LocalDateTime? = null, lateinit var updatedAt: LocalDateTime
) : Persistable<Long> {
@Transient
private var isNewEntity: Boolean = true
@PostLoad
@PostPersist
fun markNotNew() {
isNewEntity = false
}
override fun isNew(): Boolean = isNewEntity
abstract override fun getId(): Long?
@Column
@LastModifiedBy
var updatedBy: Long = 0L
} }
@MappedSuperclass @MappedSuperclass
@ -43,12 +41,13 @@ abstract class PersistableBaseEntity(
@Transient @Transient
private var isNewEntity: Boolean = true private var isNewEntity: Boolean = true
) : Persistable<Long> { ) : Persistable<Long> {
@PostLoad @PostLoad
@PostPersist @PrePersist
fun markNotNew() { fun markNotNew() {
isNewEntity = false isNewEntity = false
} }
override fun isNew(): Boolean = isNewEntity
override fun getId(): Long = _id override fun getId(): Long = _id
override fun isNew(): Boolean = isNewEntity
} }

View File

@ -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
}

View File

@ -3,7 +3,7 @@ package roomescape.common.log
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.slf4j.MDC import org.slf4j.MDC
import roomescape.auth.web.support.MDC_MEMBER_ID_KEY import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
enum class LogType { enum class LogType {
INCOMING_HTTP_REQUEST, INCOMING_HTTP_REQUEST,
@ -34,8 +34,8 @@ class ApiLogMessageConverter(
controllerPayload: Map<String, Any>, controllerPayload: Map<String, Any>,
): String { ): String {
val payload: MutableMap<String, Any> = commonRequestPayload(LogType.CONTROLLER_INVOKED, request) val payload: MutableMap<String, Any> = commonRequestPayload(LogType.CONTROLLER_INVOKED, request)
val memberId: Long? = MDC.get(MDC_MEMBER_ID_KEY)?.toLong() val memberId: Long? = MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong()
if (memberId != null) payload["member_id"] = memberId else payload["member_id"] = "NONE" if (memberId != null) payload["principal_id"] = memberId else payload["principal_id"] = "NONE"
payload.putAll(controllerPayload) payload.putAll(controllerPayload)
@ -48,9 +48,9 @@ class ApiLogMessageConverter(
payload["endpoint"] = request.endpoint payload["endpoint"] = request.endpoint
payload["status_code"] = request.httpStatus payload["status_code"] = request.httpStatus
MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull() MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLongOrNull()
?.let { payload["member_id"] = it } ?.let { payload["principal_id"] = it }
?: run { payload["member_id"] = "NONE" } ?: run { payload["principal_id"] = "NONE" }
request.startTime?.let { payload["duration_ms"] = System.currentTimeMillis() - it } request.startTime?.let { payload["duration_ms"] = System.currentTimeMillis() - it }
request.body?.let { payload["response_body"] = it } request.body?.let { payload["response_body"] = it }

View File

@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.node.TextNode
import roomescape.common.config.JacksonConfig import roomescape.common.config.JacksonConfig
private const val MASK: String = "****" 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() private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
class RoomescapeLogMaskingConverter : MessageConverter() { class RoomescapeLogMaskingConverter : MessageConverter() {

View 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))
}

View File

@ -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}" } }
}
}

View File

@ -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", "이미 가입된 이메일이에요.")
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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" }
}
}

View File

@ -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}" } }
}
}

View File

@ -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,
}

View File

@ -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?
}

View File

@ -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