[#22] 프론트엔드 React 전환 및 인증 API 수정 #23

Merged
pricelees merged 9 commits from refactor/#22 into main 2025-07-27 03:39:20 +00:00
99 changed files with 6358 additions and 2955 deletions

2
.gitignore vendored
View File

@ -35,3 +35,5 @@ out/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
logs
.kotlin

View File

@ -32,7 +32,6 @@ repositories {
dependencies { dependencies {
// Spring // Spring
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")

1
frontend/.env Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL = "http://localhost:8080"

28
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.DS_Store
.env

69
frontend/README.md Normal file
View File

@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>방탈출</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3836
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.2",
"bootstrap": "^5.3.3",
"flatpickr": "^4.6.13",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13",
"react-router-dom": "^6.23.1",
"vite-tsconfig-paths": "^5.1.4"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

56
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,56 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage';
import ReservationPage from './pages/ReservationPage';
import MyReservationPage from './pages/MyReservationPage';
import AdminLayout from './pages/admin/AdminLayout';
import AdminPage from './pages/admin/AdminPage';
import AdminReservationPage from './pages/admin/ReservationPage';
import AdminTimePage from './pages/admin/TimePage';
import AdminThemePage from './pages/admin/ThemePage';
import AdminWaitingPage from './pages/admin/WaitingPage';
import { AuthProvider } from './context/AuthContext';
import AdminRoute from './components/AdminRoute';
const AdminRoutes = () => (
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/reservation" element={<AdminReservationPage />} />
<Route path="/time" element={<AdminTimePage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/waiting" element={<AdminWaitingPage />} />
</Routes>
</AdminLayout>
);
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/admin/*" element={
<AdminRoute>
<AdminRoutes />
</AdminRoute>
} />
<Route path="/*" element={
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/reservation" element={<ReservationPage />} />
<Route path="/reservation-mine" element={<MyReservationPage />} />
</Routes>
</Layout>
} />
</Routes>
</Router>
</AuthProvider>
);
}
export default App;

View File

@ -0,0 +1,81 @@
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
timeout: 10000,
});
export const isLoginRequiredError = (error: any): boolean => {
if (!axios.isAxiosError(error) || !error.response) {
return false;
}
const LOGIN_REQUIRED_ERROR_CODE = ['A001', 'A002', 'A003', 'A006'];
const code = error.response?.data?.code;
return code && LOGIN_REQUIRED_ERROR_CODE.includes(code);
}
async function request<T>(
method: Method,
endpoint: string,
data: object = {},
isRequiredAuth: boolean = false
): Promise<T> {
const config: AxiosRequestConfig = {
method,
url: endpoint,
headers: {
'Content-Type': 'application/json'
},
};
if (isRequiredAuth) {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
}
if (method.toUpperCase() !== 'GET') {
config.data = data;
}
try {
const response = await apiClient.request(config);
return response.data.data;
} catch (error: unknown) {
const axiosError = error as AxiosError<{ code: string, message: string }>;
console.error('API 요청 실패:', axiosError);
throw axiosError;
}
}
async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('GET', endpoint, {}, isRequiredAuth);
}
async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('POST', endpoint, data, isRequiredAuth);
}
async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('PUT', endpoint, data, isRequiredAuth);
}
async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('PATCH', endpoint, data, isRequiredAuth);
}
async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('DELETE', endpoint, {}, isRequiredAuth);
}
export default {
get,
post,
put,
patch,
del
};

View File

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

View File

@ -0,0 +1,14 @@
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
}
export interface LoginCheckResponse {
name: string;
role: 'ADMIN' | 'MEMBER';
}

View File

@ -0,0 +1,10 @@
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

@ -0,0 +1,19 @@
export interface MemberRetrieveResponse {
id: number;
name: string;
}
export interface MemberRetrieveListResponse {
members: MemberRetrieveResponse[];
}
export interface SignupRequest {
email: string;
password: string;
name: string;
}
export interface SignupResponse {
id: number;
name: string;
}

View File

@ -0,0 +1,70 @@
import apiClient from "@_api/apiClient";
import type {
AdminReservationCreateRequest,
MyReservationRetrieveListResponse,
ReservationCreateWithPaymentRequest,
ReservationRetrieveListResponse,
ReservationRetrieveResponse,
ReservationSearchQuery,
WaitingCreateRequest
} from "./reservationTypes";
// GET /reservations
export const fetchReservations = async (): Promise<ReservationRetrieveListResponse> => {
return await apiClient.get<ReservationRetrieveListResponse>('/reservations', true);
};
// GET /reservations-mine
export const fetchMyReservations = async (): Promise<MyReservationRetrieveListResponse> => {
return await apiClient.get<MyReservationRetrieveListResponse>('/reservations-mine', true);
};
// GET /reservations/search
export const searchReservations = async (params: ReservationSearchQuery): Promise<ReservationRetrieveListResponse> => {
const query = new URLSearchParams();
if (params.themeId) query.append('themeId', params.themeId.toString());
if (params.memberId) query.append('memberId', params.memberId.toString());
if (params.dateFrom) query.append('dateFrom', params.dateFrom);
if (params.dateTo) query.append('dateTo', params.dateTo);
return await apiClient.get<ReservationRetrieveListResponse>(`/reservations/search?${query.toString()}`, true);
};
// DELETE /reservations/{id}
export const cancelReservationByAdmin = async (id: number): 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: number): Promise<void> => {
return await apiClient.del(`/reservations/waiting/${id}`, true);
};
// POST /reservations/waiting/{id}/confirm
export const confirmWaiting = async (id: number): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true);
};
// POST /reservations/waiting/{id}/reject
export const rejectWaiting = async (id: number): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true);
};

View File

@ -0,0 +1,72 @@
import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
export const ReservationStatus = {
CONFIRMED: 'CONFIRMED',
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
WAITING: 'WAITING',
} as const;
export type ReservationStatus =
| typeof ReservationStatus.CONFIRMED
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
| typeof ReservationStatus.WAITING;
export interface MyReservationRetrieveResponse {
id: number;
themeName: string;
date: string;
time: string;
status: ReservationStatus;
rank: number;
paymentKey: string | null;
amount: number | null;
}
export interface MyReservationRetrieveListResponse {
reservations: MyReservationRetrieveResponse[];
}
export interface ReservationRetrieveResponse {
id: number;
date: string;
member: MemberRetrieveResponse;
time: TimeRetrieveResponse;
theme: ThemeRetrieveResponse;
status: ReservationStatus;
}
export interface ReservationRetrieveListResponse {
reservations: ReservationRetrieveResponse[];
}
export interface AdminReservationCreateRequest {
date: string;
timeId: number;
themeId: number;
memberId: number;
}
export interface ReservationCreateWithPaymentRequest {
date: string;
timeId: number;
themeId: number;
paymentKey: string;
orderId: string;
amount: number;
paymentType: string;
}
export interface WaitingCreateRequest {
date: string;
timeId: number;
themeId: number;
}
export interface ReservationSearchQuery {
themeId?: number;
memberId?: number;
dateFrom?: string;
dateTo?: string;
}

View File

@ -0,0 +1,18 @@
import apiClient from "@_api/apiClient";
import type { ThemeCreateRequest, ThemeCreateResponse, ThemeRetrieveListResponse } 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: number): Promise<void> => {
return await apiClient.del(`/themes/${id}`, true);
};

View File

@ -0,0 +1,23 @@
export interface ThemeCreateRequest {
name: string;
description: string;
thumbnail: string;
}
export interface ThemeCreateResponse {
id: number;
name: string;
description: string;
thumbnail: string;
}
export interface ThemeRetrieveResponse {
id: number;
name: string;
description: string;
thumbnail: string;
}
export interface ThemeRetrieveListResponse {
themes: ThemeRetrieveResponse[];
}

View File

@ -0,0 +1,18 @@
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: number): Promise<void> => {
return await apiClient.del(`/times/${id}`, true);
};
export const fetchTimesWithAvailability = async (date: string, themeId: number): Promise<TimeWithAvailabilityListResponse> => {
return await apiClient.get<TimeWithAvailabilityListResponse>(`/times/search?date=${date}&themeId=${themeId}`, true);
};

View File

@ -0,0 +1,27 @@
export interface TimeCreateRequest {
startAt: string;
}
export interface TimeCreateResponse {
id: number;
startAt: string;
}
export interface TimeRetrieveResponse {
id: number;
startAt: string;
}
export interface TimeRetrieveListResponse {
times: TimeCreateResponse[];
}
export interface TimeWithAvailabilityResponse {
id: number;
startAt: string;
isAvailable: boolean;
}
export interface TimeWithAvailabilityListResponse {
times: TimeWithAvailabilityResponse[];
}

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,28 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { loggedIn, role, loading } = useAuth();
const location = useLocation();
if (loading) {
return <div>Loading...</div>; // Or a proper spinner component
}
if (!loggedIn) {
// Not logged in, redirect to login page. No alert needed here
// as the user is simply redirected.
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (role !== 'ADMIN') {
// Logged in but not an admin, show alert and redirect.
alert('접근 권한이 없어요. 관리자에게 문의해주세요.');
return <Navigate to="/" replace />;
}
return children;
};
export default AdminRoute;

View File

@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import Navbar from './Navbar';
interface LayoutProps {
children: ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<>
<Navbar />
<main>{children}</main>
</>
);
};
export default Layout;

View File

@ -0,0 +1,56 @@
import { checkLogin } from '@_api/auth/authAPI';
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from 'src/context/AuthContext';
const Navbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async (e: React.MouseEvent) => {
e.preventDefault();
try {
await logout();
navigate('/');
} catch (error) {
console.error('Logout failed:', error);
}
}
return (
<nav className="navbar navbar-expand-lg navbar-light bg-light">
<Link className="navbar-brand" to="/">
<img src="/image/service-logo.png" alt="LOGO" style={{ height: '40px' }} />
</Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/reservation">Reservation</Link>
</li>
{!loggedIn ? (
<li className="nav-item">
<Link className="nav-link" to="/login">Login</Link>
</li>
) : (
<li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span id="profile-name">{userName}</span>
</a>
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
<li><Link className="dropdown-item" to="/reservation-mine">My Reservation</Link></li>
<li><hr className="dropdown-divider" /></li>
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
</ul>
</li>
)}
</ul>
</div>
</nav>
);
};
export default Navbar;

View File

@ -0,0 +1,73 @@
import React, { createContext, useState, useEffect, ReactNode, useContext } from 'react';
import { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI';
import type { LoginRequest, LoginCheckResponse } from '@_api/auth/authTypes';
interface AuthContextType {
loggedIn: boolean;
userName: string | null;
role: 'ADMIN' | 'MEMBER' | null;
loading: boolean; // Add loading state to type
login: (data: LoginRequest) => Promise<LoginCheckResponse>;
logout: () => Promise<void>;
checkLogin: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [loggedIn, setLoggedIn] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
const [role, setRole] = useState<'ADMIN' | 'MEMBER' | null>(null);
const [loading, setLoading] = useState(true); // Add loading state
const checkLogin = async () => {
try {
const response = await apiCheckLogin();
setLoggedIn(true);
setUserName(response.name);
setRole(response.role);
} catch (error) {
setLoggedIn(false);
setUserName(null);
setRole(null);
localStorage.removeItem('accessToken');
} finally {
setLoading(false); // Set loading to false after check is complete
}
};
useEffect(() => {
checkLogin();
}, []);
const login = async (data: LoginRequest) => {
const response = await apiLogin(data);
await checkLogin();
return response;
};
const logout = async () => {
try {
await apiLogout();
} finally {
setLoggedIn(false);
setUserName(null);
setRole(null);
localStorage.removeItem('accessToken');
}
};
return (
<AuthContext.Provider value={{ loggedIn, userName, role, loading, login, logout, checkLogin }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

67
frontend/src/index.css Normal file
View File

@ -0,0 +1,67 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0 auto;
max-width: 60rem;
min-height: 100vh;
height: 100%;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

14
frontend/src/main.tsx Normal file
View File

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

View File

@ -0,0 +1,33 @@
import React, { useEffect, useState } from 'react';
import { mostReservedThemes } from '@_api/theme/themeAPI';
const HomePage: React.FC = () => {
const [ranking, setRanking] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
await mostReservedThemes(10).then(response => setRanking(response.themes))
};
fetchData().catch(err => console.error('Error fetching ranking:', err));
}, []);
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<ul className="list-unstyled" id="theme-ranking">
{ranking.map(theme => (
<li key={theme.id} className="d-flex my-4">
<img className="me-3 img-thumbnail" src={theme.thumbnail} alt={theme.name} style={{ width: '150px' }} />
<div className="media-body">
<h5 className="mt-0 mb-1">{theme.name}</h5>
{theme.description}
</div>
</li>
))}
</ul>
</div>
);
};
export default HomePage;

View File

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import type { LoginRequest } from '@_api/auth/authTypes';
import { useAuth } from '../context/AuthContext';
const LoginPage: 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 () => {
try {
const request: LoginRequest = { email, password };
await login(request);
alert('로그인에 성공했어요!');
navigate(from, { replace: true });
} catch (error: any) {
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
alert(message);
console.error('로그인 실패:', error);
setEmail('');
setPassword('');
}
}
return (
<div className="content-container" style={{ width: '300px' }}>
<h2 className="content-container-title">Login</h2>
<div className="form-group">
<input type="email" className="form-control" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group">
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
</div>
<div className="d-flex justify-content-between">
<button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
<button className="btn btn-custom" onClick={handleLogin}>Login</button>
</div>
</div>
);
};
export default LoginPage;

View File

@ -0,0 +1,90 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { cancelWaiting, fetchMyReservations } from '@_api/reservation/reservationAPI';
import type { MyReservationRetrieveResponse } from '@_api/reservation/reservationTypes';
import { ReservationStatus } from '@_api/reservation/reservationTypes';
import { isLoginRequiredError } from '@_api/apiClient';
const MyReservationPage: React.FC = () => {
const [reservations, setReservations] = useState<MyReservationRetrieveResponse[]>([]);
const navigate = useNavigate();
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(() => {
fetchMyReservations()
.then(res => setReservations(res.reservations))
.catch(handleError);
}, []);
const _cancelWaiting = (id: number) => {
cancelWaiting(id)
.then(() => {
alert('예약 대기가 취소되었습니다.');
setReservations(reservations.filter(r => r.id !== id));
})
.catch(handleError);
};
const getStatusText = (status: ReservationStatus, rank: number) => {
if (status === ReservationStatus.CONFIRMED) {
return '예약';
}
if (status === ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) {
return '예약 - 결제 필요';
}
if (status === ReservationStatus.WAITING) {
return `${rank}번째 예약 대기`;
}
return '';
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="table-container"></div>
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th> </th>
<th>paymentKey</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{reservations.map(r => (
<tr key={r.id}>
<td>{r.themeName}</td>
<td>{r.date}</td>
<td>{r.time}</td>
<td>{getStatusText(r.status, r.rank)}</td>
<td>
{r.status === ReservationStatus.WAITING &&
<button className="btn btn-danger" onClick={() => _cancelWaiting(r.id)}></button>}
</td>
<td>{r.paymentKey}</td>
<td>{r.amount}</td>
<td></td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default MyReservationPage;

View File

@ -0,0 +1,199 @@
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<number | null>(null);
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
const [selectedTime, setSelectedTime] = useState<{ id: number, 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 = () =>
window.btoa(Math.random().toString()).slice(0, 20);
const orderIdPrefix = "WTEST";
paymentWidgetRef.current.requestPayment({
orderId: orderIdPrefix + 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

@ -0,0 +1,44 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { signup } from '@_api/member/memberAPI';
import type { SignupRequest } from '@_api/member/memberTypes';
const SignupPage: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const navigate = useNavigate();
const handleSignup = async () => {
const request: SignupRequest = { email, password, name };
await signup(request)
.then((response) => {
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
navigate('/login')
})
.catch(error => {
console.error(error);
});
};
return (
<div className="content-container" style={{ width: '400px' }}>
<h2 className="content-container-title">Signup</h2>
<div className="form-group">
<label>Email address</label>
<input type="email" className="form-control" placeholder="Enter email" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" className="form-control" placeholder="Enter password" value={password} onChange={e => setPassword(e.target.value)} />
</div>
<div className="form-group">
<label>Name</label>
<input type="text" className="form-control" placeholder="Enter name" value={name} onChange={e => setName(e.target.value)} />
</div>
<button className="btn btn-custom" onClick={handleSignup}>Register</button>
</div>
);
};
export default SignupPage;

View File

@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps {
children: ReactNode;
}
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
return (
<>
<AdminNavbar />
<main>{children}</main>
</>
);
};
export default AdminLayout;

View File

@ -0,0 +1,63 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
const AdminNavbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async (e: React.MouseEvent) => {
e.preventDefault();
try {
await logout();
navigate('/');
} catch (error) {
console.error("Logout failed", error);
// Handle logout error if needed
}
};
return (
<nav className="navbar navbar-expand-lg navbar-light bg-light">
<Link className="navbar-brand" to="/admin">
<img src="/image/admin-logo.png" alt="LOGO" style={{ height: '40px' }} />
</Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/admin/reservation">Reservation</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/admin/waiting">Waiting</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/admin/theme">Theme</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/admin/time">Time</Link>
</li>
{!loggedIn ? (
<li className="nav-item">
<Link className="nav-link" to="/login">Login</Link>
</li>
) : (
<li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span id="profile-name">{userName || 'Profile'}</span>
</a>
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
</ul>
</li>
)}
</ul>
</div>
</nav>
);
};
export default AdminNavbar;

View File

@ -0,0 +1,11 @@
import React from 'react';
const AdminPage: React.FC = () => {
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
</div>
);
};
export default AdminPage;

View File

@ -0,0 +1,200 @@
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';
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 ? Number(filter.memberId) : undefined,
themeId: filter.themeId ? Number(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: Number(newReservation.memberId),
themeId: Number(newReservation.themeId),
date: newReservation.date,
timeId: Number(newReservation.timeId),
};
await createReservationByAdmin(request)
.then(() => {
alert('예약을 추가했어요. 결제는 별도로 진행해주세요.');
_fetchReservations();
handleCancelClick();
})
.catch(handleError);
};
const deleteReservation = async(id: number) => {
if (!window.confirm('정말 삭제하시겠어요?')) {
return;
}
await cancelReservationByAdmin(id)
.then(() => {
setReservations(reservations.filter(r => r.id !== id))
alert('예약을 삭제했어요.');
}).catch(handleError);
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="d-flex">
<div className="table-container flex-grow-1 mr-3">
<div className="table-header d-flex justify-content-end">
<button id="add-button" className="btn btn-custom mb-2" onClick={handleAddClick}> </button>
</div>
<table className="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>
<td></td>
<td>
<select className="form-control" 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-control" 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-control" onChange={e => setNewReservation({ ...newReservation, date: e.target.value })} /></td>
<td>
<select className="form-control" 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-custom" onClick={handleSaveClick}></button>
<button className="btn btn-secondary" onClick={handleCancelClick}></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="filter-section ml-3">
<form id="filter-form" onSubmit={applyFilter}>
<div className="form-group">
<label htmlFor="member"></label>
<select id="member" name="memberId" className="form-control" 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 htmlFor="theme"></label>
<select id="theme" name="themeId" className="form-control" 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 htmlFor="date-from">From</label>
<input type="date" id="date-from" name="dateFrom" className="form-control" onChange={handleFilterChange} />
</div>
<div className="form-group">
<label htmlFor="date-to">To</label>
<input type="date" id="date-to" name="dateTo" className="form-control" onChange={handleFilterChange} />
</div>
<button type="submit" className="btn btn-primary float-end"></button>
</form>
</div>
</div>
</div>
);
};
export default AdminReservationPage;

View File

@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { createTheme, fetchThemes, delTheme } from '@_api/theme/themeAPI';
import { isLoginRequiredError } from '@_api/apiClient';
const AdminThemePage: React.FC = () => {
const [themes, setThemes] = useState<any[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [newTheme, setNewTheme] = useState({ name: '', description: '', thumbnail: '' });
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 fetchThemes()
.then(response => setThemes(response.themes))
.catch(handleError);
};
fetchData();
}, []);
const handleAddClick = () => {
setIsEditing(true);
};
const handleCancelClick = () => {
setIsEditing(false);
setNewTheme({ name: '', description: '', thumbnail: '' });
};
const handleSaveClick = async () => {
await createTheme(newTheme)
.then((response) => {
setThemes([...themes, response]);
alert('테마를 추가했어요.');
handleCancelClick();
})
.catch(handleError);
}
const deleteTheme = async (id: number) => {
if (!window.confirm('정말 삭제하시겠어요?')) {
return;
}
await delTheme(id)
.then(() => {
setThemes(themes.filter(theme => theme.id !== id));
alert('테마를 삭제했어요.');
})
.catch(handleError);
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="table-header">
<button id="add-button" className="btn btn-custom mb-2 float-end" onClick={handleAddClick}> </button>
</div>
<div className="table-container" />
<table className="table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"> URL</th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="table-body">
{themes.map(theme => (
<tr key={theme.id}>
<td>{theme.id}</td>
<td>{theme.name}</td>
<td>{theme.description}</td>
<td>{theme.thumbnail}</td>
<td>
<button className="btn btn-danger" onClick={() => deleteTheme(theme.id)}></button>
</td>
</tr>
))}
{isEditing && (
<tr>
<td></td>
<td><input type="text" className="form-control" placeholder="이름" value={newTheme.name} onChange={e => setNewTheme({ ...newTheme, name: e.target.value })} /></td>
<td><input type="text" className="form-control" placeholder="설명" value={newTheme.description} onChange={e => setNewTheme({ ...newTheme, description: e.target.value })} /></td>
<td><input type="text" className="form-control" placeholder="썸네일 URL" value={newTheme.thumbnail} onChange={e => setNewTheme({ ...newTheme, thumbnail: e.target.value })} /></td>
<td>
<button className="btn btn-custom" onClick={handleSaveClick}></button>
<button className="btn btn-secondary" onClick={handleCancelClick}></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export default AdminThemePage;

View File

@ -0,0 +1,119 @@
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';
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: number) => {
if (!window.confirm('정말 삭제하시겠어요?')) {
return;
}
await delTime(id)
.then(() => {
setTimes(times.filter(time => time.id !== id));
alert('시간을 삭제했어요.');
})
.catch(handleError);
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="table-header">
<button id="add-button" className="btn btn-custom mb-2 float-end" onClick={handleAddClick}> </button>
</div>
<div className="table-container" />
<table className="table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="table-body">
{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>
<td></td>
<td><input type="time" className="form-control" value={newTime} onChange={e => setNewTime(e.target.value)} /></td>
<td>
<button className="btn btn-custom" onClick={handleSaveClick}></button>
<button className="btn btn-secondary" onClick={handleCancelClick}></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export default AdminTimePage;

View File

@ -0,0 +1,85 @@
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';
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: number) => {
await confirmWaiting(id)
.then(() => {
alert('대기 중인 예약을 승인했어요. 결제는 별도로 진행해주세요.');
setWaitings(waitings.filter(w => w.id !== id));
})
.catch(handleError);
};
const denyWaiting = async (id: number) => {
await rejectWaiting(id)
.then(() => {
alert('대기 중인 예약을 거절했어요.');
setWaitings(waitings.filter(w => w.id !== id));
})
.catch(handleError);
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="table-container" />
<table className="table">
<thead>
<tr>
<th scope="col"> </th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="table-body">
{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 mr-2" onClick={() => approveWaiting(w.id)}></button>
<button className="btn btn-danger" onClick={() => denyWaiting(w.id)}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default AdminWaitingPage;

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@_api/*": ["src/api/*"],
"@_assets/*": ["src/assets/*"],
"@_components/*": ["src/components/*"],
"@_css/*": ["src/css/*"],
"@_hooks/*": ["src/hooks/*"],
"@_pages/*": ["src/pages/*"],
"@_types/*": ["/src/types/*"],
}
},
"include": ["src"],
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

11
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
],
})

View File

@ -10,7 +10,7 @@ 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.LoginCheckResponse
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.LoginResponse
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@ -18,17 +18,17 @@ import roomescape.common.dto.response.CommonApiResponse
interface AuthAPI { interface AuthAPI {
@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<Unit>> ): ResponseEntity<CommonApiResponse<LoginResponse>>
@Operation(summary = "로그인 상태 확인") @Operation(summary = "로그인 상태 확인")
@ApiResponses( @ApiResponses(
ApiResponse( ApiResponse(
responseCode = "200", responseCode = "200",
description = "로그인 상태이며, 로그인된 회원의 이름을 반환합니다.", description = "로그인 상태이며, 로그인된 회원의 이름 / 권한을 반환합니다.",
useReturnTypeSchema = true useReturnTypeSchema = true
), ),
) )
@ -36,10 +36,9 @@ interface AuthAPI {
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<LoginCheckResponse>> ): ResponseEntity<CommonApiResponse<LoginCheckResponse>>
@LoginRequired
@Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."), ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."),
) )
fun logout(): ResponseEntity<CommonApiResponse<Unit>> fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>>
} }

View File

@ -1,5 +1,7 @@
package roomescape.auth.service package roomescape.auth.service
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
@ -10,6 +12,8 @@ import roomescape.auth.web.LoginResponse
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class AuthService( class AuthService(
private val memberService: MemberService, private val memberService: MemberService,
@ -30,7 +34,7 @@ class AuthService(
memberService.findById(memberId) memberService.findById(memberId)
} }
return LoginCheckResponse(member.name) return LoginCheckResponse(member.name, member.role.name)
} }
private fun fetchMemberOrThrow( private fun fetchMemberOrThrow(
@ -43,4 +47,10 @@ class AuthService(
throw AuthException(errorCode) throw AuthException(errorCode)
} }
} }
fun logout(memberId: Long?) {
if (memberId != null) {
log.info { "requested logout for $memberId" }
}
}
} }

View File

@ -2,7 +2,6 @@ package roomescape.auth.web
import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
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
@ -11,8 +10,6 @@ import org.springframework.web.bind.annotation.RestController
import roomescape.auth.docs.AuthAPI import roomescape.auth.docs.AuthAPI
import roomescape.auth.service.AuthService import roomescape.auth.service.AuthService
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.auth.web.support.expiredAccessTokenCookie
import roomescape.auth.web.support.toResponseCookie
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@RestController @RestController
@ -23,12 +20,10 @@ class AuthController(
@PostMapping("/login") @PostMapping("/login")
override fun login( override fun login(
@Valid @RequestBody loginRequest: LoginRequest, @Valid @RequestBody loginRequest: LoginRequest,
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<LoginResponse>> {
val response: LoginResponse = authService.login(loginRequest) val response: LoginResponse = authService.login(loginRequest)
return ResponseEntity.ok() return ResponseEntity.ok(CommonApiResponse(response))
.header(HttpHeaders.SET_COOKIE, response.toResponseCookie())
.body(CommonApiResponse())
} }
@GetMapping("/login/check") @GetMapping("/login/check")
@ -41,7 +36,9 @@ class AuthController(
} }
@PostMapping("/logout") @PostMapping("/logout")
override fun logout(): ResponseEntity<CommonApiResponse<Unit>> = ResponseEntity.ok() override fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>> {
.header(HttpHeaders.SET_COOKIE, expiredAccessTokenCookie()) authService.logout(memberId)
.body(CommonApiResponse())
return ResponseEntity.noContent().build()
}
} }

View File

@ -10,7 +10,9 @@ data class LoginResponse(
data class LoginCheckResponse( data class LoginCheckResponse(
@Schema(description = "로그인된 회원의 이름") @Schema(description = "로그인된 회원의 이름")
val name: String val name: String,
@Schema(description = "회원(MEMBER) / 관리자(ADMIN)")
val role: String,
) )
data class LoginRequest( data class LoginRequest(

View File

@ -28,24 +28,22 @@ class AuthInterceptor(
return true return true
} }
val member: MemberEntity = findMember(request, response) val member: MemberEntity = findMember(request)
if (admin != null && !member.isAdmin()) { if (admin != null && !member.isAdmin()) {
response.sendRedirect("/login")
throw AuthException(AuthErrorCode.ACCESS_DENIED) throw AuthException(AuthErrorCode.ACCESS_DENIED)
} }
return true return true
} }
private fun findMember(request: HttpServletRequest, response: HttpServletResponse): MemberEntity { private fun findMember(request: HttpServletRequest): MemberEntity {
try { try {
val token: String? = request.accessTokenCookie().value val token: String? = request.accessToken()
val memberId: Long = jwtHandler.getMemberIdFromToken(token) val memberId: Long = jwtHandler.getMemberIdFromToken(token)
return memberService.findById(memberId) return memberService.findById(memberId)
} catch (e: Exception) { } catch (e: Exception) {
response.sendRedirect("/login")
throw e throw e
} }
} }

View File

@ -1,26 +1,9 @@
package roomescape.auth.web.support package roomescape.auth.web.support
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.ResponseCookie
import roomescape.auth.web.LoginResponse
const val ACCESS_TOKEN_COOKIE_NAME = "accessToken" const val AUTHORIZATION_HEADER_NAME = "Authorization"
const val AUTHORIZATION_HEADER_PREFIX = "Bearer "
fun HttpServletRequest.accessTokenCookie(): Cookie = this.cookies fun HttpServletRequest.accessToken(): String? = this.getHeader(AUTHORIZATION_HEADER_NAME)
?.firstOrNull { it.name == ACCESS_TOKEN_COOKIE_NAME } ?.removePrefix(AUTHORIZATION_HEADER_PREFIX)
?: Cookie(ACCESS_TOKEN_COOKIE_NAME, "")
fun LoginResponse.toResponseCookie(): String = accessTokenCookie(this.accessToken, 1800)
.toString()
fun expiredAccessTokenCookie(): String = accessTokenCookie("", 0)
.toString()
private fun accessTokenCookie(token: String, maxAgeSecond: Long): ResponseCookie =
ResponseCookie.from(ACCESS_TOKEN_COOKIE_NAME, token)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(maxAgeSecond)
.build()

View File

@ -25,7 +25,7 @@ class MemberIdResolver(
binderFactory: WebDataBinderFactory? binderFactory: WebDataBinderFactory?
): Any { ): Any {
val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest
val token: String = request.accessTokenCookie().value val token: String? = request.accessToken()
return jwtHandler.getMemberIdFromToken(token) return jwtHandler.getMemberIdFromToken(token)
} }

View File

@ -0,0 +1,16 @@
package roomescape.common.config
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class CorsConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("Authorization", "Content-Type")
.maxAge(3600) // 1 hour
}
}

View File

@ -7,8 +7,8 @@ import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.member.web.MemberRetrieveListResponse import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.toRetrieveResponse import roomescape.member.web.*
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -27,6 +27,21 @@ class MemberService(
memberRepository.findByEmailAndPassword(email, password) memberRepository.findByEmailAndPassword(email, password)
} }
@Transactional
fun create(request: SignupRequest): SignupResponse {
memberRepository.findByEmail(request.email)?.let {
throw MemberException(MemberErrorCode.DUPLICATE_EMAIL)
}
val member = MemberEntity(
name = request.name,
email = request.email,
password = request.password,
role = Role.MEMBER
)
return memberRepository.save(member).toSignupResponse()
}
private fun fetchOrThrow(block: () -> MemberEntity?): MemberEntity { private fun fetchOrThrow(block: () -> MemberEntity?): MemberEntity {
return block() ?: throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) return block() ?: throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
} }

View File

@ -5,9 +5,12 @@ 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 org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.support.Admin import roomescape.auth.web.support.Admin
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.member.web.MemberRetrieveListResponse import roomescape.member.web.MemberRetrieveListResponse
import roomescape.member.web.SignupRequest
import roomescape.member.web.SignupResponse
@Tag(name = "2. 회원 API", description = "회원 정보를 관리할 때 사용합니다.") @Tag(name = "2. 회원 API", description = "회원 정보를 관리할 때 사용합니다.")
interface MemberAPI { interface MemberAPI {
@ -21,4 +24,14 @@ interface MemberAPI {
) )
) )
fun findMembers(): ResponseEntity<CommonApiResponse<MemberRetrieveListResponse>> fun findMembers(): ResponseEntity<CommonApiResponse<MemberRetrieveListResponse>>
@Operation(summary = "회원 가입")
@ApiResponses(
ApiResponse(
responseCode = "201",
description = "성공",
useReturnTypeSchema = true
)
)
fun signup(@RequestBody request: SignupRequest): ResponseEntity<CommonApiResponse<SignupResponse>>
} }

View File

@ -8,5 +8,6 @@ enum class MemberErrorCode(
override val errorCode: String, override val errorCode: String,
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없어요.") MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없어요."),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "M002", "이미 가입된 이메일이에요.")
} }

View File

@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository : JpaRepository<MemberEntity, Long> { interface MemberRepository : JpaRepository<MemberEntity, Long> {
fun findByEmailAndPassword(email: String, password: String): MemberEntity? fun findByEmailAndPassword(email: String, password: String): MemberEntity?
fun findByEmail(email: String): MemberEntity?
} }

View File

@ -2,16 +2,26 @@ package roomescape.member.web
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.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.docs.MemberAPI import roomescape.member.docs.MemberAPI
import java.net.URI
@RestController @RestController
class MemberController( class MemberController(
private val memberService: MemberService private val memberService: MemberService
) : MemberAPI { ) : MemberAPI {
@PostMapping("/members")
override fun signup(@RequestBody request: SignupRequest): ResponseEntity<CommonApiResponse<SignupResponse>> {
val response: SignupResponse = memberService.create(request)
return ResponseEntity.created(URI.create("/members/${response.id}"))
.body(CommonApiResponse(response))
}
@GetMapping("/members") @GetMapping("/members")
override fun findMembers(): ResponseEntity<CommonApiResponse<MemberRetrieveListResponse>> { override fun findMembers(): ResponseEntity<CommonApiResponse<MemberRetrieveListResponse>> {
val response: MemberRetrieveListResponse = memberService.findMembers() val response: MemberRetrieveListResponse = memberService.findMembers()

View File

@ -19,3 +19,19 @@ data class MemberRetrieveResponse(
data class MemberRetrieveListResponse( data class MemberRetrieveListResponse(
val members: List<MemberRetrieveResponse> val members: List<MemberRetrieveResponse>
) )
data class SignupRequest(
val email: String,
val password: String,
val name: String
)
data class SignupResponse(
val id: Long,
val name: String,
)
fun MemberEntity.toSignupResponse(): SignupResponse = SignupResponse(
id = this.id!!,
name = this.name
)

View File

@ -1,48 +0,0 @@
package roomescape.view
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
@Controller
class AuthPageController {
@GetMapping("/login")
fun showLoginPage(): String = "login"
}
@Controller
@RequestMapping("/admin")
class AdminPageController {
@Admin
@GetMapping
fun showIndexPage() = "admin/index"
@Admin
@GetMapping("/{page}")
fun showAdminSubPage(@PathVariable page: String) = when (page) {
"reservation" -> "admin/reservation-new"
"time" -> "admin/time"
"theme" -> "admin/theme"
"waiting" -> "admin/waiting"
else -> "admin/index"
}
}
@Controller
class ClientPageController {
@GetMapping("/")
fun showPopularThemePage(): String = "index"
@LoginRequired
@GetMapping("/reservation")
fun showReservationPage(): String = "reservation"
@LoginRequired
@GetMapping("/reservation-mine")
fun showReservationMinePage(): String = "reservation-mine"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,45 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
requestRead(`/themes/most-reserved-last-week?count=10`) // 인기 테마 목록 조회 API endpoint
.then(render)
.catch(error => console.error('Error fetching times:', error));
});
function formatDate(dateString) {
let date = new Date(dateString);
let year = date.getFullYear();
let month = (date.getMonth() + 1).toString().padStart(2, '0'); // '04'
let day = date.getDate().toString().padStart(2, '0'); // '28'
return `${year}-${month}-${day}`; // '2024-04-28'
}
function render(data) {
const container = document.getElementById('theme-ranking');
data.data.themes.forEach(theme => {
const name = theme.name;
const thumbnail = theme.thumbnail;
const description = theme.description;
const htmlContent = `
<img class="mr-3 img-thumbnail" src="${thumbnail}" alt="${name}">
<div class="media-body">
<h5 class="mt-0 mb-1">${name}</h5>
${description}
</div>
`;
const div = document.createElement('li');
div.className = 'media my-4';
div.innerHTML = htmlContent;
container.appendChild(div);
})
}
function requestRead(endpoint) {
return fetch(endpoint)
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
});
}

View File

@ -1,57 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
fetch('/reservations-mine') // 내 예약 목록 조회 API 호출
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
})
.then(render)
.catch(error => console.error('Error fetching reservations:', error));
});
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.reservations.forEach(item => {
const row = tableBody.insertRow();
const theme = item.themeName;
const date = item.date;
const time = item.time;
const status = item.status.includes('CONFIRMED') ? (item.status === 'CONFIRMED' ? '예약' : '예약 - 결제 필요') : item.rank + '번째 예약 대기';
row.insertCell(0).textContent = theme;
row.insertCell(1).textContent = date;
row.insertCell(2).textContent = time;
row.insertCell(3).textContent = status;
if (status.includes('대기')) { // 예약 대기 상태일 때 예약 대기 취소 버튼 추가하는 코드, 상태 값은 변경 가능
const cancelCell = row.insertCell(4);
const cancelButton = document.createElement('button');
cancelButton.textContent = '취소';
cancelButton.className = 'btn btn-danger';
cancelButton.onclick = function () {
requestDeleteWaiting(item.id).then(() => window.location.reload());
};
cancelCell.appendChild(cancelButton);
} else { // 예약 완료 상태일 때
/*
TODO: [미션4 - 2단계] 예약 목록 조회 ,
예약 완료 상태일 결제 정보를 함께 보여주기
결제 정보 필드명은 자신의 response 맞게 변경하기
*/
row.insertCell(4).textContent = '';
row.insertCell(5).textContent = item.paymentKey;
row.insertCell(6).textContent = item.amount;
}
});
}
function requestDeleteWaiting(id) {
const endpoint = '/reservations/waiting/' + id;
return fetch(endpoint, {
method: 'DELETE'
}).then(response => {
if (response.status === 204) return;
throw new Error('Delete failed');
});
}

View File

@ -1,194 +0,0 @@
let isEditing = false;
const RESERVATION_API_ENDPOINT = '/reservations';
const TIME_API_ENDPOINT = '/times';
const THEME_API_ENDPOINT = '/themes';
const timesOptions = [];
const themesOptions = [];
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('add-button').addEventListener('click', addInputRow);
requestRead(RESERVATION_API_ENDPOINT)
.then(render)
.catch(error => console.error('Error fetching reservations:', error));
fetchTimes();
fetchThemes();
});
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.reservations.forEach(item => {
const row = tableBody.insertRow();
row.insertCell(0).textContent = item.id; // 예약 id
row.insertCell(1).textContent = item.name; // 예약자명
row.insertCell(2).textContent = item.theme.name; // 테마명
row.insertCell(3).textContent = item.date; // 예약 날짜
row.insertCell(4).textContent = item.time.startAt; // 시작 시간
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
});
}
function fetchTimes() {
requestRead(TIME_API_ENDPOINT)
.then(data => {
timesOptions.push(...data.data.times);
})
.catch(error => console.error('Error fetching time:', error));
}
function fetchThemes() {
requestRead(THEME_API_ENDPOINT)
.then(data => {
themesOptions.push(...data.data.themes);
})
.catch(error => console.error('Error fetching theme:', error));
}
function createSelect(options, defaultText, selectId, textProperty) {
const select = document.createElement('select');
select.className = 'form-control';
select.id = selectId;
// 기본 옵션 추가
const defaultOption = document.createElement('option');
defaultOption.textContent = defaultText;
select.appendChild(defaultOption);
// 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성
options.forEach(optionData => {
const option = document.createElement('option');
option.value = optionData.id;
option.textContent = optionData[textProperty]; // 동적 속성 접근
select.appendChild(option);
});
return select;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function addInputRow() {
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
const tableBody = document.getElementById('table-body');
const row = tableBody.insertRow();
isEditing = true;
const nameInput = createInput('text');
const dateInput = createInput('date');
const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt');
const themeDropdown = createSelect(themesOptions, "테마 선택", 'theme-select', 'name');
const cellFieldsToCreate = ['', nameInput, themeDropdown, dateInput, timeDropdown];
cellFieldsToCreate.forEach((field, index) => {
const cell = row.insertCell(index);
if (typeof field === 'string') {
cell.textContent = field;
} else {
cell.appendChild(field);
}
});
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
row.remove();
isEditing = false;
}));
}
function createInput(type) {
const input = document.createElement('input');
input.type = type;
input.className = 'form-control';
return input;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function saveRow(event) {
// 이벤트 전파를 막는다
event.stopPropagation();
const row = event.target.parentNode.parentNode;
const nameInput = row.querySelector('input[type="text"]');
const themeSelect = row.querySelector('#theme-select');
const dateInput = row.querySelector('input[type="date"]');
const timeSelect = row.querySelector('#time-select');
const reservation = {
name: nameInput.value,
themeId: themeSelect.value,
date: dateInput.value,
timeId: timeSelect.value
};
requestCreate(reservation)
.then(() => {
location.reload();
})
.catch(error => console.error('Error:', error));
isEditing = false; // isEditing 값을 false로 설정
}
function deleteRow(event) {
const row = event.target.closest('tr');
const reservationId = row.cells[0].textContent;
requestDelete(reservationId)
.then(() => row.remove())
.catch(error => console.error('Error:', error));
}
function requestCreate(reservation) {
const requestOptions = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(reservation)
};
return fetch(RESERVATION_API_ENDPOINT, requestOptions)
.then(response => {
if (response.status === 201) return response.json();
throw new Error('Create failed');
});
}
function requestDelete(id) {
const requestOptions = {
method: 'DELETE',
};
return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions)
.then(response => {
if (response.status !== 204) throw new Error('Delete failed');
});
}
function requestRead(endpoint) {
return fetch(endpoint)
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
});
}

View File

@ -1,250 +0,0 @@
let isEditing = false;
const RESERVATION_API_ENDPOINT = '/reservations';
const TIME_API_ENDPOINT = '/times';
const THEME_API_ENDPOINT = '/themes';
const MEMBER_API_ENDPOINT = '/members';
const timesOptions = [];
const themesOptions = [];
const membersOptions = [];
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('add-button').addEventListener('click', addInputRow);
document.getElementById('filter-form').addEventListener('submit', applyFilter);
requestRead(RESERVATION_API_ENDPOINT)
.then(render)
.catch(error => console.error('Error fetching reservations:', error));
fetchTimes();
fetchThemes();
fetchMembers();
});
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.reservations.forEach(item => {
const row = tableBody.insertRow();
const isPaid = item.status === 'CONFIRMED' ? '결제 완료' : '결제 대기';
row.insertCell(0).textContent = item.id; // 예약 id
row.insertCell(1).textContent = item.member.name; // 사용자 name
row.insertCell(2).textContent = item.theme.name; // 테마 name
row.insertCell(3).textContent = item.date; // date
row.insertCell(4).textContent = item.time.startAt; // 예약 시간 startAt
row.insertCell(5).textContent = isPaid; // 결제
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
});
}
function fetchTimes() {
requestRead(TIME_API_ENDPOINT)
.then(data => {
timesOptions.push(...data.data.times);
})
.catch(error => console.error('Error fetching time:', error));
}
function fetchThemes() {
requestRead(THEME_API_ENDPOINT)
.then(data => {
themesOptions.push(...data.data.themes);
populateSelect('theme', themesOptions, 'name');
})
.catch(error => console.error('Error fetching theme:', error));
}
function fetchMembers() {
requestRead(MEMBER_API_ENDPOINT)
.then(data => {
membersOptions.push(...data.data.members);
populateSelect('member', membersOptions, 'name');
})
.catch(error => console.error('Error fetching member:', error));
}
function populateSelect(selectId, options, textProperty) {
const select = document.getElementById(selectId);
options.forEach(optionData => {
const option = document.createElement('option');
option.value = optionData.id;
option.textContent = optionData[textProperty];
select.appendChild(option);
});
}
function createSelect(options, defaultText, selectId, textProperty) {
const select = document.createElement('select');
select.className = 'form-control';
select.id = selectId;
// 기본 옵션 추가
const defaultOption = document.createElement('option');
defaultOption.textContent = defaultText;
select.appendChild(defaultOption);
// 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성
options.forEach(optionData => {
const option = document.createElement('option');
option.value = optionData.id;
option.textContent = optionData[textProperty]; // 동적 속성 접근
select.appendChild(option);
});
return select;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function addInputRow() {
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
const tableBody = document.getElementById('table-body');
const row = tableBody.insertRow();
isEditing = true;
const dateInput = createInput('date');
const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt');
const themeDropdown = createSelect(themesOptions, "테마 선택", 'theme-select', 'name');
const memberDropdown = createSelect(membersOptions, "멤버 선택", 'member-select', 'name');
const cellFieldsToCreate = ['', memberDropdown, themeDropdown, dateInput, timeDropdown];
cellFieldsToCreate.forEach((field, index) => {
const cell = row.insertCell(index);
if (typeof field === 'string') {
cell.textContent = field;
} else {
cell.appendChild(field);
}
});
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
row.remove();
isEditing = false;
}));
}
function createInput(type) {
const input = document.createElement('input');
input.type = type;
input.className = 'form-control';
return input;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function saveRow(event) {
// 이벤트 전파를 막는다
event.stopPropagation();
const row = event.target.parentNode.parentNode;
const dateInput = row.querySelector('input[type="date"]');
const memberSelect = row.querySelector('#member-select');
const themeSelect = row.querySelector('#theme-select');
const timeSelect = row.querySelector('#time-select');
const reservation = {
date: dateInput.value,
themeId: themeSelect.value,
timeId: timeSelect.value,
memberId: memberSelect.value,
};
requestCreate(reservation)
.then(() => {
location.reload();
})
.catch(error => console.error('Error:', error));
isEditing = false; // isEditing 값을 false로 설정
}
function deleteRow(event) {
const row = event.target.closest('tr');
const reservationId = row.cells[0].textContent;
requestDelete(reservationId)
.then(() => row.remove())
.catch(error => console.error('Error:', error));
}
function applyFilter(event) {
event.preventDefault();
const themeId = document.getElementById('theme').value;
const memberId = document.getElementById('member').value;
const dateFrom = document.getElementById('date-from').value;
const dateTo = document.getElementById('date-to').value;
const queryParams = {
themeId: themeId,
memberId: memberId,
dateFrom: dateFrom,
dateTo: dateTo
}
const searchParams = new URLSearchParams(queryParams);
const endpoint = '/reservations/search';
const url = `${endpoint}?${searchParams.toString()}`;
fetch(url, { // 예약 검색 API 호출
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
}).then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
}).then(render)
.catch(error => console.error("Error fetching available times:", error));
}
function requestCreate(reservation) {
const requestOptions = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(reservation)
};
return fetch('/reservations/admin', requestOptions)
.then(response => {
if (response.status === 201) return response.json();
throw new Error('Create failed');
});
}
function requestDelete(id) {
const requestOptions = {
method: 'DELETE',
};
return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions)
.then(response => {
if (response.status !== 204) throw new Error('Delete failed');
});
}
function requestRead(endpoint) {
return fetch(endpoint)
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
});
}

View File

@ -1,179 +0,0 @@
let isEditing = false;
const RESERVATION_API_ENDPOINT = '/reservations';
const TIME_API_ENDPOINT = '/times';
const timesOptions = [];
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('add-button').addEventListener('click', addInputRow);
requestRead(RESERVATION_API_ENDPOINT)
.then(render)
.catch(error => console.error('Error fetching reservations:', error));
fetchTimes();
});
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.reservations.forEach(item => {
const row = tableBody.insertRow();
row.insertCell(0).textContent = item.id;
row.insertCell(1).textContent = item.name;
row.insertCell(2).textContent = item.date;
row.insertCell(3).textContent = item.time.startAt;
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
});
}
function fetchTimes() {
requestRead(TIME_API_ENDPOINT)
.then(data => {
timesOptions.push(...data.data.times);
})
.catch(error => console.error('Error fetching time:', error));
}
function createSelect(options, defaultText, selectId, textProperty) {
const select = document.createElement('select');
select.className = 'form-control';
select.id = selectId;
// 기본 옵션 추가
const defaultOption = document.createElement('option');
defaultOption.textContent = defaultText;
select.appendChild(defaultOption);
// 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성
options.forEach(optionData => {
const option = document.createElement('option');
option.value = optionData.id;
option.textContent = optionData[textProperty]; // 동적 속성 접근
select.appendChild(option);
});
return select;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function addInputRow() {
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
const tableBody = document.getElementById('table-body');
const row = tableBody.insertRow();
isEditing = true;
const nameInput = createInput('text');
const dateInput = createInput('date');
const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt');
const cellFieldsToCreate = ['', nameInput, dateInput, timeDropdown];
cellFieldsToCreate.forEach((field, index) => {
const cell = row.insertCell(index);
if (typeof field === 'string') {
cell.textContent = field;
} else {
cell.appendChild(field);
}
});
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
row.remove();
isEditing = false;
}));
}
function createInput(type) {
const input = document.createElement('input');
input.type = type;
input.className = 'form-control';
return input;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function saveRow(event) {
// 이벤트 전파를 막는다
event.stopPropagation();
const row = event.target.parentNode.parentNode;
const nameInput = row.querySelector('input[type="text"]');
const dateInput = row.querySelector('input[type="date"]');
const timeSelect = row.querySelector('select');
const reservation = {
name: nameInput.value,
date: dateInput.value,
timeId: timeSelect.value
};
requestCreate(reservation)
.then(() => {
location.reload();
})
.catch(error => console.error('Error:', error));
isEditing = false; // isEditing 값을 false로 설정
}
function deleteRow(event) {
const row = event.target.closest('tr');
const reservationId = row.cells[0].textContent;
requestDelete(reservationId)
.then(() => row.remove())
.catch(error => console.error('Error:', error));
}
function requestCreate(reservation) {
const requestOptions = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(reservation)
};
return fetch(RESERVATION_API_ENDPOINT, requestOptions)
.then(response => {
if (response.status === 201) return response.json();
throw new Error('Create failed');
});
}
function requestDelete(id) {
const requestOptions = {
method: 'DELETE',
};
return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions)
.then(response => {
if (response.status !== 204) throw new Error('Delete failed');
});
}
function requestRead(endpoint) {
return fetch(endpoint)
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
});
}

View File

@ -1,136 +0,0 @@
let isEditing = false;
const API_ENDPOINT = '/themes';
const cellFields = ['id', 'name', 'description', 'thumbnail'];
const createCellFields = ['', createInput(), createInput(), createInput()];
function createBody(inputs) {
return {
name: inputs[0].value,
description: inputs[1].value,
thumbnail: inputs[2].value,
};
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('add-button').addEventListener('click', addRow);
requestRead()
.then(render)
.catch(error => console.error('Error fetching times:', error));
});
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.themes.forEach(item => {
const row = tableBody.insertRow();
cellFields.forEach((field, index) => {
row.insertCell(index).textContent = item[field];
});
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
});
}
function addRow() {
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
const tableBody = document.getElementById('table-body');
const row = tableBody.insertRow();
isEditing = true;
createAddField(row);
}
function createAddField(row) {
createCellFields.forEach((field, index) => {
const cell = row.insertCell(index);
if (typeof field === 'string') {
cell.textContent = field;
} else {
cell.appendChild(field);
}
});
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
row.remove();
isEditing = false;
}));
}
function createInput() {
const input = document.createElement('input');
input.className = 'form-control';
return input;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function saveRow(event) {
const row = event.target.parentNode.parentNode;
const inputs = row.querySelectorAll('input');
const body = createBody(inputs);
requestCreate(body)
.then(() => {
location.reload();
})
.catch(error => console.error('Error:', error));
isEditing = false; // isEditing 값을 false로 설정
}
function deleteRow(event) {
const row = event.target.closest('tr');
const id = row.cells[0].textContent;
requestDelete(id)
.then(() => row.remove())
.catch(error => console.error('Error:', error));
}
// request
function requestCreate(data) {
const requestOptions = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
};
return fetch(API_ENDPOINT, requestOptions)
.then(response => {
if (response.status === 201) return response.json();
throw new Error('Create failed');
});
}
function requestRead() {
return fetch(API_ENDPOINT)
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
});
}
function requestDelete(id) {
const requestOptions = {
method: 'DELETE',
};
return fetch(`${API_ENDPOINT}/${id}`, requestOptions)
.then(response => {
if (response.status !== 204) throw new Error('Delete failed');
});
}

View File

@ -1,135 +0,0 @@
let isEditing = false;
const API_ENDPOINT = '/times';
const cellFields = ['id', 'startAt'];
const createCellFields = ['', createInput()];
function createBody(inputs) {
return {
startAt: inputs[0].value,
};
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('add-button').addEventListener('click', addRow);
requestRead()
.then(render)
.catch(error => console.error('Error fetching times:', error));
});
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.times.forEach(item => {
const row = tableBody.insertRow();
cellFields.forEach((field, index) => {
row.insertCell(index).textContent = item[field];
});
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
});
}
function addRow() {
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
const tableBody = document.getElementById('table-body');
const row = tableBody.insertRow();
isEditing = true;
createAddField(row);
}
function createAddField(row) {
createCellFields.forEach((field, index) => {
const cell = row.insertCell(index);
if (typeof field === 'string') {
cell.textContent = field;
} else {
cell.appendChild(field);
}
});
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
row.remove();
isEditing = false;
}));
}
function createInput() {
const input = document.createElement('input');
input.type = 'time'
input.className = 'form-control';
return input;
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}
function saveRow(event) {
const row = event.target.parentNode.parentNode;
const inputs = row.querySelectorAll('input');
const body = createBody(inputs);
requestCreate(body)
.then(() => {
location.reload();
})
.catch(error => console.error('Error:', error));
isEditing = false; // isEditing 값을 false로 설정
}
function deleteRow(event) {
const row = event.target.closest('tr');
const id = row.cells[0].textContent;
requestDelete(id)
.then(() => row.remove())
.catch(error => console.error('Error:', error));
}
// request
function requestCreate(data) {
const requestOptions = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
};
return fetch(API_ENDPOINT, requestOptions)
.then(response => {
if (response.status === 201) return response.json();
throw new Error('Create failed');
});
}
function requestRead() {
return fetch(API_ENDPOINT)
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
});
}
function requestDelete(id) {
const requestOptions = {
method: 'DELETE',
};
return fetch(`${API_ENDPOINT}/${id}`, requestOptions)
.then(response => {
if (response.status !== 204) throw new Error('Delete failed');
});
}

View File

@ -1,273 +0,0 @@
const THEME_API_ENDPOINT = '/themes';
document.addEventListener('DOMContentLoaded', () => {
requestRead(THEME_API_ENDPOINT)
.then(renderTheme)
.catch(error => console.error('Error fetching times:', error));
flatpickr("#datepicker", {
inline: true,
onChange: function (selectedDates, dateStr, instance) {
if (dateStr === '') return;
checkDate();
}
});
// ------ 결제위젯 초기화 ------
// @docs https://docs.tosspayments.com/reference/widget-sdk#sdk-설치-및-초기화
// @docs https://docs.tosspayments.com/reference/widget-sdk#renderpaymentmethods선택자-결제-금액-옵션
const paymentAmount = 1000;
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
const paymentWidget = PaymentWidget(widgetClientKey, PaymentWidget.ANONYMOUS);
paymentWidget.renderPaymentMethods(
"#payment-method",
{value: paymentAmount},
{variantKey: "DEFAULT"}
);
document.getElementById('theme-slots').addEventListener('click', event => {
if (event.target.classList.contains('theme-slot')) {
document.querySelectorAll('.theme-slot').forEach(slot => slot.classList.remove('active'));
event.target.classList.add('active');
checkDateAndTheme();
}
});
document.getElementById('time-slots').addEventListener('click', event => {
if (event.target.classList.contains('time-slot') && !event.target.classList.contains('disabled')) {
document.querySelectorAll('.time-slot').forEach(slot => slot.classList.remove('active'));
event.target.classList.add('active');
checkDateAndThemeAndTime();
}
});
document.getElementById('reserve-button').addEventListener('click', onReservationButtonClickWithPaymentWidget);
document.getElementById('wait-button').addEventListener('click', onWaitButtonClick);
function onReservationButtonClickWithPaymentWidget(event) {
onReservationButtonClick(event, paymentWidget);
}
});
function renderTheme(themes) {
const themeSlots = document.getElementById('theme-slots');
themeSlots.innerHTML = '';
themes.data.themes.forEach(theme => {
const name = theme.name;
const themeId = theme.id;
themeSlots.appendChild(createSlot('theme', name, themeId));
});
}
function createSlot(type, text, id, booked) {
const div = document.createElement('div');
div.className = type + '-slot cursor-pointer bg-light border rounded p-3 mb-2';
div.textContent = text;
div.setAttribute('data-' + type + '-id', id);
if (type === 'time') {
div.setAttribute('data-time-booked', booked);
}
return div;
}
function checkDate() {
const selectedDate = document.getElementById("datepicker").value;
if (selectedDate) {
const themeSection = document.getElementById("theme-section");
if (themeSection.classList.contains("disabled")) {
themeSection.classList.remove("disabled");
}
const timeSlots = document.getElementById('time-slots');
timeSlots.innerHTML = '';
requestRead(THEME_API_ENDPOINT)
.then(renderTheme)
.catch(error => console.error('Error fetching times:', error));
}
}
function checkDateAndTheme() {
const selectedDate = document.getElementById("datepicker").value;
const selectedThemeElement = document.querySelector('.theme-slot.active');
if (selectedDate && selectedThemeElement) {
const selectedThemeId = selectedThemeElement.getAttribute('data-theme-id');
fetchAvailableTimes(selectedDate, selectedThemeId);
}
}
function fetchAvailableTimes(date, themeId) {
fetch(`/times/search?date=${date}&themeId=${themeId}`, { // 예약 가능 시간 조회 API endpoint
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
}).then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
}).then(renderAvailableTimes)
.catch(error => console.error("Error fetching available times:", error));
}
function renderAvailableTimes(times) {
const timeSection = document.getElementById("time-section");
if (timeSection.classList.contains("disabled")) {
timeSection.classList.remove("disabled");
}
const timeSlots = document.getElementById('time-slots');
timeSlots.innerHTML = '';
if (times.length === 0) {
timeSlots.innerHTML = '<div class="no-times">선택할 수 있는 시간이 없습니다.</div>';
return;
}
times.data.times.forEach(time => {
const startAt = time.startAt;
const timeId = time.id;
const isAvailable = time.isAvailable;
const div = createSlot('time', startAt, timeId, isAvailable); // createSlot('time', 시작 시간, time id, 예약 여부)
timeSlots.appendChild(div);
});
}
function checkDateAndThemeAndTime() {
const selectedDate = document.getElementById("datepicker").value;
const selectedThemeElement = document.querySelector('.theme-slot.active');
const selectedTimeElement = document.querySelector('.time-slot.active');
const reserveButton = document.getElementById("reserve-button");
const waitButton = document.getElementById("wait-button");
if (selectedDate && selectedThemeElement && selectedTimeElement) {
if (selectedTimeElement.getAttribute('data-time-booked') === 'false') {
// 선택된 시간이 이미 예약된 경우
reserveButton.classList.add("disabled");
waitButton.classList.remove("disabled"); // 예약 대기 버튼 활성화
} else {
// 선택된 시간이 예약 가능한 경우
reserveButton.classList.remove("disabled");
waitButton.classList.add("disabled"); // 예약 대기 버튼 활성화
}
} else {
// 날짜, 테마, 시간 중 하나라도 선택되지 않은 경우
reserveButton.classList.add("disabled");
waitButton.classList.add("disabled");
}
}
function onReservationButtonClick(event, paymentWidget) {
const selectedDate = document.getElementById("datepicker").value;
const selectedThemeId = document.querySelector('.theme-slot.active')?.getAttribute('data-theme-id');
const selectedTimeId = document.querySelector('.time-slot.active')?.getAttribute('data-time-id');
if (selectedDate && selectedThemeId && selectedTimeId) {
const reservationData = {
date: selectedDate,
themeId: selectedThemeId,
timeId: selectedTimeId,
};
const generateRandomString = () =>
window.btoa(Math.random()).slice(0, 20);
// TOSS 결제 위젯 Javascript SDK 연동 방식 중 'Promise로 처리하기'를 적용함
// https://docs.tosspayments.com/reference/widget-sdk#promise%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0
const orderIdPrefix = "WTEST";
paymentWidget.requestPayment({
orderId: orderIdPrefix + generateRandomString(),
orderName: "테스트 방탈출 예약 결제 1건",
amount: 1000,
}).then(function (data) {
console.debug(data);
fetchReservationPayment(data, reservationData);
}).catch(function (error) {
// TOSS 에러 처리: 에러 목록을 확인하세요
// https://docs.tosspayments.com/reference/error-codes#failurl 로-전달되는-에러
alert(error.code + " :" + error.message);
});
} else {
alert("Please select a date, theme, and time before making a reservation.");
}
}
async function fetchReservationPayment(paymentData, reservationData) {
const reservationPaymentRequest = {
date: reservationData.date,
themeId: reservationData.themeId,
timeId: reservationData.timeId,
paymentKey: paymentData.paymentKey,
orderId: paymentData.orderId,
amount: paymentData.amount,
paymentType: paymentData.paymentType,
}
const reservationURL = "/reservations";
fetch(reservationURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reservationPaymentRequest),
}).then(response => {
if (!response.ok) {
return response.json().then(errorBody => {
console.error("예약 결제 실패 : " + JSON.stringify(errorBody));
window.alert("예약 결제 실패 메시지: " + errorBody.message);
});
} else {
response.json().then(successBody => {
alert("예약이 완료되었습니다.");
console.log("예약 결제 성공 : " + JSON.stringify(successBody));
window.location.href = "/";
});
}
}).catch(error => {
console.error(error.message);
});
}
function onWaitButtonClick() {
const selectedDate = document.getElementById("datepicker").value;
const selectedThemeId = document.querySelector('.theme-slot.active')?.getAttribute('data-theme-id');
const selectedTimeId = document.querySelector('.time-slot.active')?.getAttribute('data-time-id');
if (selectedDate && selectedThemeId && selectedTimeId) {
const reservationData = {
date: selectedDate,
timeId: selectedTimeId,
themeId: selectedThemeId,
};
fetch('/reservations/waiting', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(reservationData)
})
.then(response => {
if (!response.ok) throw new Error('Reservation waiting failed');
return response.json();
})
.then(data => {
alert('Reservation waiting successful!');
window.location.href = "/";
})
.catch(error => {
alert("An error occurred while making the reservation waiting.");
console.error(error);
});
} else {
alert("Please select a date, theme, and time before making a reservation waiting.");
}
}
function requestRead(endpoint) {
return fetch(endpoint)
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
});
}

View File

@ -1,152 +0,0 @@
document.addEventListener('DOMContentLoaded', function () {
updateUIBasedOnLogin();
});
document.getElementById('logout-btn').addEventListener('click', function (event) {
event.preventDefault();
fetch('/logout', {
method: 'POST', // 또는 서버 설정에 따라 GET 일 수도 있음
credentials: 'include' // 쿠키를 포함시키기 위해 필요
})
.then(response => {
if (response.ok) {
// 로그아웃 성공, 페이지 새로고침 또는 리다이렉트
window.location.reload();
} else {
// 로그아웃 실패 처리
console.error('Logout failed');
}
})
.catch(error => {
console.error('Error:', error);
});
});
function updateUIBasedOnLogin() {
fetch('/login/check') // 로그인 상태 확인 API 호출
.then(response => {
if (!response.ok) { // 요청이 실패하거나 로그인 상태가 아닌 경우
throw new Error('Not logged in or other error');
}
return response.json(); // 응답 본문을 JSON으로 파싱
})
.then(data => {
// 응답에서 사용자 이름을 추출하여 UI 업데이트
document.getElementById('profile-name').textContent = data.data.name; // 프로필 이름 설정
document.querySelector('.nav-item.dropdown').style.display = 'block'; // 드롭다운 메뉴 표시
document.querySelector('.nav-item a[href="/login"]').parentElement.style.display = 'none'; // 로그인 버튼 숨김
})
.catch(error => {
// 에러 처리 또는 로그아웃 상태일 때 UI 업데이트
console.error('Error:', error);
document.getElementById('profile-name').textContent = 'Profile'; // 기본 텍스트로 재설정
document.querySelector('.nav-item.dropdown').style.display = 'none'; // 드롭다운 메뉴 숨김
document.querySelector('.nav-item a[href="/login"]').parentElement.style.display = 'block'; // 로그인 버튼 표시
});
}
// 드롭다운 메뉴 토글
document.getElementById("navbarDropdown").addEventListener('click', function (e) {
e.preventDefault();
const dropdownMenu = e.target.closest('.nav-item.dropdown').querySelector('.dropdown-menu');
dropdownMenu.classList.toggle('show'); // Bootstrap 4에서는 data-toggle 사용, Bootstrap 5에서는 JS로 처리
});
function login() {
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
// 입력 필드 검증
if (!email || !password) {
alert('Please fill in all fields.');
return; // 필수 입력 필드가 비어있으면 여기서 함수 실행을 중단
}
fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
password: password
})
})
.then(response => {
if (200 !== response.status) {
alert('Login failed'); // 로그인 실패 시 경고창 표시
throw new Error('Login failed');
}
})
.then(() => {
updateUIBasedOnLogin(); // UI 업데이트
window.location.href = '/';
})
.catch(error => {
console.error('Error during login:', error);
});
}
function signup() {
// Redirect to signup page
window.location.href = '/signup';
}
function register(event) {
// 폼 데이터 수집
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const name = document.getElementById('name').value;
// 입력 필드 검증
if (!email || !password || !name) {
alert('Please fill in all fields.');
return; // 필수 입력 필드가 비어있으면 여기서 함수 실행을 중단
}
// 요청 데이터 포맷팅
const formData = {
email: email,
password: password,
name: name
};
// AJAX 요청 생성 및 전송
fetch('/members', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
})
.then(response => {
if (!response.ok) {
alert('Signup request failed');
throw new Error('Signup request failed');
}
return response.json(); // 여기서 응답을 JSON 형태로 변환
})
.then(data => {
// 성공적인 응답 처리
console.log('Signup successful:', data);
window.location.href = '/login';
})
.catch(error => {
// 에러 처리
console.error('Error during signup:', error);
});
// 폼 제출에 의한 페이지 리로드 방지
event.preventDefault();
}
function base64DecodeUnicode(str) {
// Base64 디코딩
const decodedBytes = atob(str);
// UTF-8 바이트를 문자열로 변환
const encodedUriComponent = decodedBytes.split('').map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join('');
return decodeURIComponent(encodedUriComponent);
}

View File

@ -1,69 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
fetch('/reservations/waiting') // 내 예약 목록 조회 API 호출
.then(response => {
if (response.status === 200) return response.json();
throw new Error('Read failed');
})
.then(render)
.catch(error => console.error('Error fetching reservations:', error));
});
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.reservations.forEach(item => {
const row = tableBody.insertRow();
const id = item.id;
const name = item.member.name;
const theme = item.theme.name;
const date = item.date;
const startAt = item.time.startAt;
row.insertCell(0).textContent = id; // 예약 대기 id
row.insertCell(1).textContent = name; // 예약자명
row.insertCell(2).textContent = theme; // 테마명
row.insertCell(3).textContent = date; // 예약 날짜
row.insertCell(4).textContent = startAt; // 시작 시간
const actionCell = row.insertCell(row.cells.length);
actionCell.appendChild(createActionButton('승인', 'btn-primary', approve));
actionCell.appendChild(createActionButton('거절', 'btn-danger', deny));
});
}
function approve(event) {
const row = event.target.closest('tr');
const id = row.cells[0].textContent;
const endpoint = `/reservations/waiting/${id}/confirm`
return fetch(endpoint, {
method: 'POST'
}).then(response => {
if (response.status === 200) return;
throw new Error('Delete failed');
}).then(() => location.reload());
}
function deny(event) {
const row = event.target.closest('tr');
const id = row.cells[0].textContent;
const endpoint = `/reservations/waiting/${id}/reject`
return fetch(endpoint, {
method: 'POST'
}).then(response => {
if (response.status === 204) return;
throw new Error('Delete failed');
}).then(() => location.reload());
}
function createActionButton(label, className, eventListener) {
const button = document.createElement('button');
button.textContent = label;
button.classList.add('btn', className, 'mr-2');
button.addEventListener('click', eventListener);
return button;
}

View File

@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 어드민</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/admin">
<img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/admin/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/waiting">Waiting</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/theme">Theme</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/time">Time</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">방탈출 어드민</h2>
</div>
<script src="/js/user-scripts.js"></script>
</body>
</html>

View File

@ -1,111 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 어드민</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/admin">
<img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/admin/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/waiting">Waiting</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/theme">Theme</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/time">Time</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">방탈출 예약 페이지</h2>
<div class="d-flex">
<div class="table-container flex-grow-1 mr-3">
<div class="table-header d-flex justify-content-end">
<button id="add-button" class="btn btn-custom mb-2">예약 추가</button>
</div>
<table class="table">
<thead>
<tr>
<th>예약번호</th>
<th>예약자</th>
<th>테마</th>
<th>날짜</th>
<th>시간</th>
<th>결제 완료 여부</th>
<th></th>
</tr>
</thead>
<tbody id="table-body">
</tbody>
</table>
</div>
<div class="filter-section ml-3">
<form id="filter-form">
<div class="form-group">
<label for="member">예약자</label>
<select type="text" id="member" name="member" class="form-control">
<option value="">전체</option>
<option style="display:none"></option>
</select>
</div>
<div class="form-group">
<label for="theme">테마</label>
<select type="text" id="theme" name="theme" class="form-control">
<option value="">전체</option>
<option style="display:none"></option>
</select>
</div>
<div class="form-group">
<label for="date-from">From</label>
<input type="date" id="date-from" name="date-from" class="form-control">
</div>
<div class="form-group">
<label for="date-to">To</label>
<input type="date" id="date-to" name="date-to" class="form-control">
</div>
<button type="submit" class="btn btn-primary float-right">적용</button>
</form>
</div>
</div>
</div>
<script src="/js/user-scripts.js"></script>
<script src="/js/reservation-with-member.js"></script>
</body>
</html>

View File

@ -1,63 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 어드민</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/admin">
<img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/admin/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/waiting">Waiting</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/theme">Theme</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/time">Time</a>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">방탈출 예약 페이지</h2>
<div class="table-header">
<button id="add-button" class="btn btn-custom mb-2 float-right">예약 추가</button>
</div>
<div class="table-container"></div>
<table class="table">
<thead>
<tr>
<th>예약번호</th>
<th>예약자</th>
<th>날짜</th>
<th>시간</th>
<th></th>
</tr>
</thead>
<tbody id="table-body">
</tbody>
</table>
</div>
<script src="/js/reservation.js"></script>
</body>
</html>

View File

@ -1,80 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 어드민</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/admin">
<img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/admin/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/waiting">Waiting</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/theme">Theme</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/time">Time</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">테마 관리 페이지</h2>
<div class="table-header">
<button id="add-button" class="btn btn-custom mb-2 float-right">테마 추가</button>
</div>
<div class="table-container"/>
<table class="table">
<thead>
<tr>
<th scope="col">순서</th>
<th scope="col">제목</th>
<th scope="col">설명</th>
<th scope="col">썸네일 URL</th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="table-body">
</tbody>
</table>
</div>
<script src="/js/user-scripts.js"></script>
<script src="/js/theme.js"></script>
</body>
</html>

View File

@ -1,78 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 어드민</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/admin">
<img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/admin/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/waiting">Waiting</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/theme">Theme</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/time">Time</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">시간 관리 페이지</h2>
<div class="table-header">
<button id="add-button" class="btn btn-custom mb-2 float-right">예약시간 추가</button>
</div>
<div class="table-container"/>
<table class="table">
<thead>
<tr>
<th scope="col">순서</th>
<th scope="col">시간</th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="table-body">
</tbody>
</table>
</div>
<script src="/js/user-scripts.js"></script>
<script src="/js/time.js"></script>
</body>
</html>

View File

@ -1,77 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 어드민</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/admin">
<img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/admin/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/waiting">Waiting</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/theme">Theme</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/time">Time</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">예약 대기 관리 페이지</h2>
<div class="table-container"/>
<table class="table">
<thead>
<tr>
<th scope="col">예약대기 번호</th>
<th scope="col">예약자</th>
<th scope="col">테마</th>
<th scope="col">날짜</th>
<th scope="col">시간</th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="table-body">
</tbody>
</table>
</div>
<script src="/js/user-scripts.js"></script>
<script src="/js/waiting.js"></script>
</body>
</html>

View File

@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 예약 페이지</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">
<img src="https://avatars.githubusercontent.com/u/141792611?s=200&v=4" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">인기 테마</h2>
<ul class="list-unstyled" id="theme-ranking">
</ul>
</div>
<script src="/js/user-scripts.js"></script>
<script src="/js/ranking.js"></script>
</body>
</html>

View File

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">
<img src="https://avatars.githubusercontent.com/u/141792611?s=200&v=4" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container" style="width: 300px;">
<h2 class="content-container-title">Login</h2>
<form id="login-form">
<div class="form-group">
<input type="email" class="form-control" id="email" placeholder="Email" required>
</div>
<div class="form-group">
<input type="password" class="form-control" id="password" placeholder="Password" required>
</div>
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-custom" onclick="signup()">Sign Up</button>
<button type="button" class="btn btn-custom" onclick="login()">Login</button>
</div>
</form>
</div>
<script src="/js/user-scripts.js"></script>
</body>
</html>

View File

@ -1,70 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 어드민</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">
<img src="https://avatars.githubusercontent.com/u/141792611?s=200&v=4" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container">
<h2 class="content-container-title">내 예약</h2>
<div class="table-container"></div>
<table class="table">
<thead>
<tr>
<th>테마</th>
<th>날짜</th>
<th>시간</th>
<th>상태</th>
<th>대기 취소</th>
<th>paymentKey</th>
<th>결제금액</th>
<th></th>
</tr>
</thead>
<tbody id="table-body">
</tbody>
</table>
</div>
<script src="/js/user-scripts.js"></script>
<script src="/js/reservation-mine.js"></script>
</body>
</html>

View File

@ -1,103 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>방탈출 예약 페이지</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<link rel="stylesheet" href="/css/reservation.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/toss-style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">
<img src="https://avatars.githubusercontent.com/u/141792611?s=200&v=4" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<!-- Content -->
<div id="content-container" class="col-md-10 offset-md-1 p-5">
<h2 class="content-container-title">예약 페이지</h2>
<div class="d-flex" id="reservation-container">
<!-- Date Section -->
<div class="section border rounded col-md-4 p-3" id="date-section">
<h3 class="fs-5 text-center mb-3">날짜 선택</h3>
<div class="d-flex justify-content-center p-3">
<div id="datepicker"></div>
</div>
</div>
<!-- Theme Section -->
<div class="section border rounded col-md-4 p-3 disabled" id="theme-section">
<h3 class="fs-5 text-center mb-3">테마 선택</h3>
<div class="p-3" id="theme-slots">
<!-- Dynamically generated theme slots will go here -->
</div>
</div>
<!-- Time Section -->
<div class="section border rounded col-md-4 p-3 disabled" id="time-section">
<h3 class="fs-5 text-center mb-3">시간 선택</h3>
<div class="p-3" id="time-slots">
<!-- Dynamically generated time slots will go here -->
</div>
</div>
</div>
<!-- Reservation Button -->
<div class="button-group float-right">
<button id="wait-button" class="btn btn-secondary mt-3 disabled">예약대기</button>
</div>
</div>
</div>
<!-- 결제창 ui -->
<div class="wrapper w-100">
<div class="max-w-540 w-100">
<div id="payment-method" class="w-100"></div>
<div id="agreement" class="w-100"></div>
<div class="btn-wrapper w-100">
<button id="reserve-button" class="btn primary w-100">예약하기</button>
</div>
</div>
</div>
<script src="/js/user-scripts.js"></script>
<script src="https://js.tosspayments.com/v1/payment-widget"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="/js/user-reservation.js"></script>
</body>
</html>

View File

@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Signup</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">
<img src="https://avatars.githubusercontent.com/u/141792611?s=200&v=4" alt="LOGO" style="height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/reservation">Reservation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<img class="profile-image" src="/image/default-profile.png" alt="Profile">
<span id="profile-name">Profile</span> <!-- 프로필 이름을 넣을 span 추가 -->
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/reservation-mine">My Reservation</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" id="logout-btn">Logout</a>
</div>
</li>
</ul>
</div>
</nav>
<div class="content-container" style="width: 400px;">
<h2 class="content-container-title">Signup</h2>
<form id="signup-form">
<div class="form-group">
<label for="email">Email address</label>
<input type="email" class="form-control" id="email" placeholder="Enter email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" id="password" placeholder="Enter password">
</div>
<div class="form-group">
<label for="name">Name</label>
<input type="name" class="form-control" id="name" placeholder="Enter name">
</div>
<button type="submit" class="btn btn-custom" onclick="register()">Register</button>
</form>
</div>
<script src="/js/user-scripts.js"></script>
</body>
</html>

View File

@ -2,7 +2,6 @@ package roomescape.auth.web
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.SpykBean
import io.mockk.every import io.mockk.every
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@ -39,19 +38,14 @@ class AuthControllerTest(
jwtHandler.createToken(user.id!!) jwtHandler.createToken(user.id!!)
} returns expectedToken } returns expectedToken
Then("토큰을 쿠키에 담아 응답한다") { Then("토큰을 반환한다.") {
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
body = userRequest, body = userRequest,
) { ) {
status { isOk() } status { isOk() }
header { jsonPath("$.data.accessToken", equalTo(expectedToken))
string("Set-Cookie", containsString("accessToken=$expectedToken"))
string("Set-Cookie", containsString("Max-Age=1800"))
string("Set-Cookie", containsString("HttpOnly"))
string("Set-Cookie", containsString("Secure"))
}
} }
} }
} }
@ -61,7 +55,7 @@ class AuthControllerTest(
memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password) memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password)
} returns null } returns null
Then("에러 응답") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.LOGIN_FAILED val expectedError = AuthErrorCode.LOGIN_FAILED
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -101,13 +95,14 @@ class AuthControllerTest(
When("로그인된 회원의 ID로 요청하면") { When("로그인된 회원의 ID로 요청하면") {
loginAsUser() loginAsUser()
Then("회원의 이름을 응답한다") { Then("회원의 이름과 권한을 응답한다") {
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { isOk() } status { isOk() }
jsonPath("$.data.name", equalTo(user.name)) jsonPath("$.data.name", equalTo(user.name))
jsonPath("$.data.role", equalTo(user.role.name))
} }
} }
} }
@ -118,7 +113,7 @@ class AuthControllerTest(
every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId
every { memberRepository.findByIdOrNull(invalidMemberId) } returns null every { memberRepository.findByIdOrNull(invalidMemberId) } returns null
Then("에러 응답.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.UNIDENTIFIABLE_MEMBER val expectedError = AuthErrorCode.UNIDENTIFIABLE_MEMBER
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -130,42 +125,20 @@ class AuthControllerTest(
} }
} }
} }
Given("로그아웃 요청을 보낼 때") { Given("로그아웃 요청을 보낼 때") {
val endpoint = "/logout" val endpoint = "/logout"
When("로그인 상태가 아니라면") { When("토큰으로 memberId 조회가 가능하면") {
doNotLogin() every {
jwtHandler.getMemberIdFromToken(any())
} returns 1L
Then("로그인 페이지로 이동한다.") { Then("정상 응답한다.") {
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isNoContent() }
header {
string("Location", "/login")
}
}
}
}
When("로그인 상태라면") {
loginAsUser()
Then("토큰의 존재 여부와 무관하게 토큰을 만료시킨다.") {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
) {
status { isOk() }
header {
string("Set-Cookie", containsString("Max-Age=0"))
string("Set-Cookie", containsString("accessToken="))
string("Set-Cookie", containsString("Path=/"))
string("Set-Cookie", containsString("HttpOnly"))
string("Set-Cookie", containsString("Secure"))
}
} }
} }
} }

View File

@ -1,72 +1,26 @@
package roomescape.auth.web.support package roomescape.auth.web.support
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import roomescape.auth.web.LoginResponse
class CookieUtilsTest : FunSpec({ class CookieUtilsTest : FunSpec({
context("HttpServletRequest에서 accessToken 쿠키를 가져온다.") { context("accessToken 쿠키를 가져온다.") {
val httpServletRequest: HttpServletRequest = mockk() val httpServletRequest: HttpServletRequest = mockk()
test("accessToken이 있으면 해당 쿠키를 반환한다.") { test("accessToken이 있으면 해당 쿠키를 반환한다.") {
val token = "test-token" val token = "test-token"
val cookie = Cookie(ACCESS_TOKEN_COOKIE_NAME, token) every { httpServletRequest.getHeader("Authorization") } returns "Bearer $token"
every { httpServletRequest.cookies } returns arrayOf(cookie)
assertSoftly(httpServletRequest.accessTokenCookie()) { httpServletRequest.accessToken() shouldBe token
this.name shouldBe ACCESS_TOKEN_COOKIE_NAME
this.value shouldBe token
}
} }
test("accessToken이 없으면 accessToken에 빈 값을 담은 쿠키를 반환한다.") { test("accessToken이 없으면 null을 반환한다.") {
every { httpServletRequest.cookies } returns arrayOf() every { httpServletRequest.getHeader("Authorization") } returns null
assertSoftly(httpServletRequest.accessTokenCookie()) { httpServletRequest.accessToken() shouldBe null
this.name shouldBe ACCESS_TOKEN_COOKIE_NAME
this.value shouldBe ""
} }
} }
test("httpServletRequest.cookies가 null이면 accessToken에 빈 값을 담은 쿠키를 반환한다.") {
every { httpServletRequest.cookies } returns null
assertSoftly(httpServletRequest.accessTokenCookie()) {
this.name shouldBe ACCESS_TOKEN_COOKIE_NAME
this.value shouldBe ""
}
}
}
context("TokenResponse를 쿠키로 반환한다.") {
val loginResponse = LoginResponse("test-token")
val result: String = loginResponse.toResponseCookie()
result.split("; ") shouldContainAll listOf(
"accessToken=test-token",
"HttpOnly",
"Secure",
"Path=/",
"Max-Age=1800"
)
}
context("만료된 accessToken 쿠키를 반환한다.") {
val result: String = expiredAccessTokenCookie()
result.split("; ") shouldContainAll listOf(
"accessToken=",
"HttpOnly",
"Secure",
"Path=/",
"Max-Age=0"
)
}
}) })

View File

@ -4,11 +4,16 @@ import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode
import roomescape.member.exception.MemberErrorCode
import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.MemberController import roomescape.member.web.MemberController
import roomescape.member.web.MemberRetrieveListResponse import roomescape.member.web.MemberRetrieveListResponse
import roomescape.member.web.SignupRequest
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
import kotlin.random.Random import kotlin.random.Random
@ -51,35 +56,91 @@ class MemberControllerTest(
} }
} }
`when`("관리자가 아니면 로그인 페이지로 이동한다.") { `when`("관리자가 아니면 에러 응답을 받는다.") {
then("비회원") { then("비회원") {
doNotLogin() doNotLogin()
val expectedError = AuthErrorCode.INVALID_TOKEN
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { }.andExpect {
string("Location", "/login") jsonPath("$.code") { value(expectedError.errorCode) }
}
} }
} }
then("일반 회원") { then("일반 회원") {
loginAsUser() loginAsUser()
val expectedError = AuthErrorCode.ACCESS_DENIED
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { }.andExpect {
string("Location", "/login") jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
} }
} }
given("POST /members") {
val endpoint = "/members"
val request = SignupRequest(
name = "name",
email = "email@email.com",
password = "password"
)
`when`("같은 이메일이 없으면") {
every {
memberRepository.findByEmail(request.email)
} returns null
every {
memberRepository.save(any())
} returns MemberFixture.create(
id = 1,
name = request.name,
account = request.email,
password = request.password,
role = Role.MEMBER
)
then("id과 이름을 담아 성공 응답") {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request
) {
status { isCreated() }
jsonPath("$.data.name") { value(request.name) }
jsonPath("$.data.id") { value(1) }
}
}
}
`when`("같은 이메일이 있으면") {
every {
memberRepository.findByEmail(request.email)
} returns mockk()
then("에러 응답") {
val expectedError = MemberErrorCode.DUPLICATE_EMAIL
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request
) {
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code") { value(expectedError.errorCode) }
}
}
}
} }
} }
} }

View File

@ -9,14 +9,13 @@ import io.restassured.module.kotlin.extensions.Given
import io.restassured.module.kotlin.extensions.Then import io.restassured.module.kotlin.extensions.Then
import io.restassured.module.kotlin.extensions.When import io.restassured.module.kotlin.extensions.When
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.support.MemberIdResolver import roomescape.auth.web.support.MemberIdResolver
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
@ -108,7 +107,7 @@ class ReservationControllerTest(
} }
} }
test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답") { test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답을 받는다.") {
val reservationRequest = createRequest() val reservationRequest = createRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse().copy( val paymentApproveResponse = PaymentFixture.createApproveResponse().copy(
paymentKey = reservationRequest.paymentKey, paymentKey = reservationRequest.paymentKey,
@ -203,6 +202,7 @@ class ReservationControllerTest(
test("관리자만 검색할 수 있다.") { test("관리자만 검색할 수 있다.") {
login(reservations.keys.first()) login(reservations.keys.first())
val expectedError = AuthErrorCode.ACCESS_DENIED
Given { Given {
port(port) port(port)
@ -210,7 +210,8 @@ class ReservationControllerTest(
}.When { }.When {
get("/reservations/search") get("/reservations/search")
}.Then { }.Then {
header(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE)) statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
} }
} }
@ -309,14 +310,15 @@ class ReservationControllerTest(
test("관리자만 예약을 삭제할 수 있다.") { test("관리자만 예약을 삭제할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER)) login(MemberFixture.create(role = Role.MEMBER))
val reservation: ReservationEntity = reservations.values.flatten().first() val reservation: ReservationEntity = reservations.values.flatten().first()
val expectedError = AuthErrorCode.ACCESS_DENIED
Given { Given {
port(port) port(port)
}.When { }.When {
delete("/reservations/${reservation.id}") delete("/reservations/${reservation.id}")
}.Then { }.Then {
statusCode(302) statusCode(expectedError.httpStatus.value())
header(HttpHeaders.LOCATION, containsString("/login")) body("code", equalTo(expectedError.errorCode))
} }
} }
@ -425,6 +427,7 @@ class ReservationControllerTest(
test("관리자가 아니면 조회할 수 없다.") { test("관리자가 아니면 조회할 수 없다.") {
login(MemberFixture.create(role = Role.MEMBER)) login(MemberFixture.create(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given { Given {
port(port) port(port)
@ -432,7 +435,8 @@ class ReservationControllerTest(
}.When { }.When {
get("/reservations/waiting") get("/reservations/waiting")
}.Then { }.Then {
header(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE)) statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
} }
} }
@ -566,14 +570,14 @@ class ReservationControllerTest(
context("POST /reservations/waiting/{id}/confirm") { context("POST /reservations/waiting/{id}/confirm") {
test("관리자만 승인할 수 있다.") { test("관리자만 승인할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER)) login(MemberFixture.create(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given { Given {
port(port) port(port)
}.When { }.When {
post("/reservations/waiting/1/confirm") post("/reservations/waiting/1/confirm")
}.Then { }.Then {
statusCode(302) statusCode(expectedError.httpStatus.value())
header(HttpHeaders.LOCATION, containsString("/login")) body("code", equalTo(expectedError.errorCode))
} }
} }
@ -642,14 +646,15 @@ class ReservationControllerTest(
context("POST /reservations/waiting/{id}/reject") { context("POST /reservations/waiting/{id}/reject") {
test("관리자만 거절할 수 있다.") { test("관리자만 거절할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER)) login(MemberFixture.create(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given { Given {
port(port) port(port)
}.When { }.When {
post("/reservations/waiting/1/reject") post("/reservations/waiting/1/reject")
}.Then { }.Then {
statusCode(302) statusCode(expectedError.httpStatus.value())
header(HttpHeaders.LOCATION, containsString("/login")) body("code", equalTo(expectedError.errorCode))
} }
} }

View File

@ -8,6 +8,7 @@ import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.runs import io.mockk.runs
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
@ -34,15 +35,15 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
When("로그인 상태가 아니라면") { When("로그인 상태가 아니라면") {
doNotLogin() doNotLogin()
Then("로그인 페이지로 이동한다.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.INVALID_TOKEN
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { }.andExpect {
string("Location", "/login") jsonPath("$.code") { value(expectedError.errorCode) }
}
} }
} }
} }
@ -87,30 +88,30 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
When("로그인 상태가 아니라면") { When("로그인 상태가 아니라면") {
doNotLogin() doNotLogin()
Then("로그인 페이지로 이동한다.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.INVALID_TOKEN
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
body = request, body = request,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { jsonPath("$.code", equalTo(expectedError.errorCode))
string("Location", "/login")
}
} }
} }
} }
When("관리자가 아닌 회원은") { When("관리자가 아닌 회원은") {
loginAsUser() loginAsUser()
Then("로그인 페이지로 이동한다.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
body = request, body = request,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code") { value(AuthErrorCode.ACCESS_DENIED.errorCode) } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
} }
@ -120,7 +121,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED
Then("에러 응답.") { Then("에러 응답을 받는다.") {
every { every {
themeRepository.existsByName(request.name) themeRepository.existsByName(request.name)
} returns true } returns true
@ -232,28 +233,28 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
When("로그인 상태가 아니라면") { When("로그인 상태가 아니라면") {
doNotLogin() doNotLogin()
Then("로그인 페이지로 이동한다.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.INVALID_TOKEN
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { jsonPath("$.code", equalTo(expectedError.errorCode))
string("Location", "/login")
}
} }
} }
} }
When("관리자가 아닌 회원은") { When("관리자가 아닌 회원은") {
loginAsUser() loginAsUser()
Then("로그인 페이지로 이동한다.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code") { value(AuthErrorCode.ACCESS_DENIED.errorCode) } jsonPath("$.code", equalTo(expectedError.errorCode))
} }
} }
} }
@ -262,7 +263,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
loginAsAdmin() loginAsAdmin()
val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED
Then("에러 응답") { Then("에러 응답을 받는다.") {
every { every {
themeRepository.isReservedTheme(themeId) themeRepository.isReservedTheme(themeId)
} returns true } returns true

View File

@ -6,11 +6,13 @@ import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode
import roomescape.common.config.JacksonConfig import roomescape.common.config.JacksonConfig
import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.time.business.TimeService import roomescape.time.business.TimeService
@ -72,14 +74,19 @@ class TimeControllerTest(
When("관리자가 아닌 경우") { When("관리자가 아닌 경우") {
loginAsUser() loginAsUser()
val expectedError = AuthErrorCode.ACCESS_DENIED
Then("로그인 페이지로 이동") { Then("에러 응답을 받는다.") {
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { string("Location", "/login") } }.andExpect {
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) }
}
} }
} }
} }
@ -152,14 +159,15 @@ class TimeControllerTest(
When("관리자가 아닌 경우") { When("관리자가 아닌 경우") {
loginAsUser() loginAsUser()
Then("로그인 페이지로 이동") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
body = TimeFixture.create(), body = TimeFixture.create(),
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { string("Location", "/login") } jsonPath("$.code", equalTo(expectedError.errorCode))
} }
} }
} }
@ -232,13 +240,14 @@ class TimeControllerTest(
When("관리자가 아닌 경우") { When("관리자가 아닌 경우") {
loginAsUser() loginAsUser()
Then("로그인 페이지로 이동") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { isEqualTo(expectedError.httpStatus.value()) }
header { string("Location", "/login") } jsonPath("$.code", equalTo(expectedError.errorCode))
} }
} }
} }

View File

@ -47,7 +47,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
log: Boolean = false, log: Boolean = false,
assert: MockMvcResultMatchersDsl.() -> Unit assert: MockMvcResultMatchersDsl.() -> Unit
): ResultActionsDsl = mockMvc.get(endpoint) { ): ResultActionsDsl = mockMvc.get(endpoint) {
header(HttpHeaders.COOKIE, "accessToken=token") header(HttpHeaders.AUTHORIZATION, "Bearer token")
}.apply { }.apply {
log.takeIf { it }?.let { this.andDo { print() } } log.takeIf { it }?.let { this.andDo { print() } }
}.andExpect(assert) }.andExpect(assert)
@ -59,7 +59,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
log: Boolean = false, log: Boolean = false,
assert: MockMvcResultMatchersDsl.() -> Unit assert: MockMvcResultMatchersDsl.() -> Unit
): ResultActionsDsl = mockMvc.post(endpoint) { ): ResultActionsDsl = mockMvc.post(endpoint) {
this.header(HttpHeaders.COOKIE, "accessToken=token") this.header(HttpHeaders.AUTHORIZATION, "Bearer token")
body?.let { body?.let {
this.contentType = MediaType.APPLICATION_JSON this.contentType = MediaType.APPLICATION_JSON
this.content = objectMapper.writeValueAsString(it) this.content = objectMapper.writeValueAsString(it)
@ -74,7 +74,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
log: Boolean = false, log: Boolean = false,
assert: MockMvcResultMatchersDsl.() -> Unit assert: MockMvcResultMatchersDsl.() -> Unit
): ResultActionsDsl = mockMvc.delete(endpoint) { ): ResultActionsDsl = mockMvc.delete(endpoint) {
header(HttpHeaders.COOKIE, "accessToken=token") header(HttpHeaders.AUTHORIZATION, "Bearer token")
}.apply { }.apply {
log.takeIf { it }?.let { this.andDo { print() } } log.takeIf { it }?.let { this.andDo { print() } }
}.andExpect(assert) }.andExpect(assert)
@ -109,14 +109,18 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
fun <T> MvcResult.readValue(valueType: Class<T>): T = this.response.contentAsString fun <T> MvcResult.readValue(valueType: Class<T>): T = this.response.contentAsString
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
?.let { readValue(it, valueType) } ?.let { readValue(it, valueType) }
?: throw RuntimeException(""" ?: throw RuntimeException(
"""
[Test] Exception occurred while reading response json: ${this.response.contentAsString} with value type: $valueType [Test] Exception occurred while reading response json: ${this.response.contentAsString} with value type: $valueType
""".trimIndent()) """.trimIndent()
)
fun <T> readValue(responseJson: String, valueType: Class<T>): T = objectMapper fun <T> readValue(responseJson: String, valueType: Class<T>): T = objectMapper
.readTree(responseJson)["data"] .readTree(responseJson)["data"]
?.let { objectMapper.convertValue(it, valueType) } ?.let { objectMapper.convertValue(it, valueType) }
?: throw RuntimeException(""" ?: throw RuntimeException(
"""
[Test] Exception occurred while reading response json: $responseJson with value type: $valueType [Test] Exception occurred while reading response json: $responseJson with value type: $valueType
""".trimIndent()) """.trimIndent()
)
} }

View File

@ -1,133 +0,0 @@
package roomescape.view
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import roomescape.util.RoomescapeApiTest
@WebMvcTest(controllers = [
AuthPageController::class,
AdminPageController::class,
ClientPageController::class
])
class PageControllerTest(
@Autowired private val mockMvc: MockMvc
) : RoomescapeApiTest() {
init {
listOf("/", "/login").forEach {
given("GET $it 요청은") {
`when`("로그인 및 권한 여부와 관계없이 성공한다.") {
then("비회원") {
doNotLogin()
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { isOk() }
}
}
then("회원") {
loginAsUser()
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { isOk() }
}
}
then("관리자") {
loginAsAdmin()
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { isOk() }
}
}
}
}
}
listOf("/admin", "/admin/reservation", "/admin/time", "/admin/theme", "/admin/waiting").forEach {
given("GET $it 요청을") {
`when`("관리자가 보내면") {
loginAsAdmin()
then("성공한다.") {
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { isOk() }
}
}
}
`when`("회원이 보내면") {
loginAsUser()
then("로그인 페이지로 이동한다.") {
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { is3xxRedirection() }
header {
string("Location", "/login")
}
}
}
}
}
}
listOf("/reservation", "/reservation-mine").forEach {
given("GET $it 요청을") {
`when`("로그인 된 회원이 보내면 성공한다.") {
then("회원") {
loginAsUser()
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { isOk() }
}
}
then("관리자") {
loginAsAdmin()
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { isOk() }
}
}
}
`when`("로그인 없이 보내면") {
then("로그인 페이지로 이동한다.") {
doNotLogin()
runGetTest(
mockMvc = mockMvc,
endpoint = it,
) {
status { is3xxRedirection() }
header {
string("Location", "/login")
}
}
}
}
}
}
}
}