feat: 프론트엔드 전체 디자인 수정 및 새로운 테마 API 반영

This commit is contained in:
이상진 2025-09-03 10:25:16 +09:00
parent 5ed632b1b3
commit 6e99917a34
29 changed files with 1993 additions and 393 deletions

View File

@ -20,6 +20,10 @@ import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
import ReservationStep1PageV21 from './pages/v2/ReservationStep1PageV21';
import ReservationStep2PageV21 from './pages/v2/ReservationStep2PageV21';
import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21';
import HomePageV2 from './pages/v2/HomePageV2';
import LoginPageV2 from './pages/v2/LoginPageV2';
import SignupPageV2 from './pages/v2/SignupPageV2';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
const AdminRoutes = () => (
<AdminLayout>
@ -28,6 +32,7 @@ const AdminRoutes = () => (
<Route path="/reservation" element={<AdminReservationPage />} />
<Route path="/time" element={<AdminTimePage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/waiting" element={<AdminWaitingPage />} />
</Routes>
</AdminLayout>
@ -53,6 +58,11 @@ function App() {
<Route path="/my-reservation" element={<MyReservationPage />} />
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
{/* V2 Pages */}
<Route path="/v2/home" element={<HomePageV2 />} />
<Route path="/v2/login" element={<LoginPageV2 />} />
<Route path="/v2/signup" element={<SignupPageV2 />} />
{/* V2 Reservation Flow */}
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />

View File

@ -28,7 +28,7 @@ async function request<T>(
},
};
if (isRequiredAuth) {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
if (!config.headers) {
@ -36,7 +36,7 @@ async function request<T>(
}
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
}
if (method.toUpperCase() !== 'GET') {
config.data = data;

View File

@ -1,5 +1,13 @@
import apiClient from "@_api/apiClient";
import type { ThemeCreateRequest, ThemeCreateResponse, ThemeRetrieveListResponse } from "./themeTypes";
import apiClient from '@_api/apiClient';
import type {
AdminThemeDetailRetrieveResponse,
AdminThemeSummaryRetrieveListResponse,
ThemeCreateRequest,
ThemeCreateRequestV2, ThemeCreateResponse,
ThemeCreateResponseV2, ThemeRetrieveListResponse,
ThemeUpdateRequest,
UserThemeRetrieveListResponse
} from './themeTypes';
export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
@ -16,3 +24,27 @@ export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetri
export const delTheme = async (id: string): Promise<void> => {
return await apiClient.del(`/themes/${id}`, true);
};
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes');
};
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailRetrieveResponse> => {
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
};
export const createThemeV2 = async (themeData: ThemeCreateRequestV2): Promise<ThemeCreateResponseV2> => {
return await apiClient.post<ThemeCreateResponseV2>('/admin/themes', themeData);
};
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
await apiClient.patch<any>(`/admin/themes/${id}`, themeData);
};
export const deleteTheme = async (id: string): Promise<void> => {
await apiClient.del<any>(`/admin/themes/${id}`);
};
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
};

View File

@ -21,3 +21,113 @@ export interface ThemeRetrieveResponse {
export interface ThemeRetrieveListResponse {
themes: ThemeRetrieveResponse[];
}
export interface ThemeV2 {
id: string;
name: string;
description: string;
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isOpen: boolean;
createDate: string; // Assuming ISO string format
updatedDate: string; // Assuming ISO string format
createdBy: string;
updatedBy: string;
}
export interface ThemeCreateRequestV2 {
name: string;
description: string;
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isOpen: boolean;
}
export interface ThemeCreateResponseV2 {
id: string;
}
export interface ThemeUpdateRequest {
name?: string;
description?: string;
thumbnailUrl?: string;
difficulty?: Difficulty;
price?: number;
minParticipants?: number;
maxParticipants?: number;
availableMinutes?: number;
expectedMinutesFrom?: number;
expectedMinutesTo?: number;
isOpen?: boolean;
}
export interface AdminThemeSummaryRetrieveResponse {
id: string;
name: string;
difficulty: Difficulty;
price: number;
isOpen: boolean;
}
export interface AdminThemeSummaryRetrieveListResponse {
themes: AdminThemeSummaryRetrieveResponse[];
}
export interface AdminThemeDetailRetrieveResponse {
id: string;
name: string;
description: string;
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isOpen: boolean;
createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
createdBy: string;
updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
updatedBy: string;
}
export interface UserThemeRetrieveResponse {
id: string;
name: string;
thumbnailUrl: string;
description: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
}
export interface UserThemeRetrieveListResponse {
themes: UserThemeRetrieveResponse[];
}
// @ts-ignore
export enum Difficulty {
VERY_EASY = 'VERY_EASY',
EASY = 'EASY',
NORMAL = 'NORMAL',
HARD = 'HARD',
VERY_HARD = 'VERY_HARD',
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from 'src/context/AuthContext';
import 'src/css/navbar.css';
const Navbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth();
@ -14,39 +15,31 @@ const Navbar: React.FC = () => {
} 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="/v2/reservation">Reservation</Link>
</li>
<nav className="navbar-container">
<div className="nav-links">
<Link className="nav-link" to="/"></Link>
<Link className="nav-link" to="/v2/reservation"></Link>
</div>
<div className="nav-actions">
{!loggedIn ? (
<li className="nav-item">
<Link className="nav-link" to="/login">Login</Link>
</li>
<>
<button className="btn btn-secondary" onClick={() => navigate('/v2/login')}></button>
<button className="btn btn-primary" onClick={() => navigate('/v2/signup')}></button>
</>
) : (
<li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<div className="profile-info">
<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="/my-reservation/v2">My Reservation</Link></li>
<li><hr className="dropdown-divider" /></li>
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
</ul>
</li>
<span>{userName}</span>
<div className="dropdown-menu">
<Link className="dropdown-item" to="/my-reservation/v2"> </Link>
<div className="dropdown-divider" />
<a className="dropdown-item" href="#" onClick={handleLogout}></a>
</div>
</div>
)}
</ul>
</div>
</nav>
);

View File

@ -0,0 +1,17 @@
/* /src/css/admin-page.css */
.admin-container {
max-width: 1200px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.admin-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}

View File

@ -0,0 +1,160 @@
/* /src/css/admin-reservation-page.css */
.admin-reservation-container {
max-width: 1400px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.admin-reservation-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.admin-reservation-content {
display: flex;
gap: 40px;
}
.reservations-main {
flex-grow: 1;
}
.filter-section {
width: 300px;
flex-shrink: 0;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.section-card .card-title {
font-size: 20px;
font-weight: 600;
color: #333d4b;
margin-bottom: 20px;
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.table-container table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
.table-container th,
.table-container td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e8eb;
vertical-align: middle;
}
.table-container th {
background-color: #f9fafb;
color: #505a67;
font-weight: 600;
}
.table-container tr:last-child td {
border-bottom: none;
}
.table-container tr:hover {
background-color: #f4f6f8;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #4E5968;
}
.form-input,
.form-select {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.btn-secondary:hover {
background-color: #E5E8EB;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}
.filter-section .btn-primary {
width: 100%;
margin-top: 10px;
}
.editing-row td {
padding-top: 20px;
padding-bottom: 20px;
}
.editing-row .btn {
margin-right: 8px;
}

View File

@ -0,0 +1,236 @@
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--danger-color: #dc3545;
--light-gray-color: #f8f9fa;
--dark-gray-color: #343a40;
--border-color: #dee2e6;
--input-bg-color: #fff;
--text-color: #212529;
--label-color: #495057;
--white-color: #ffffff;
--box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
--border-radius: 8px;
}
*, *::before, *::after {
box-sizing: border-box;
}
.admin-theme-edit-container {
padding: 2rem 0;
background-color: var(--light-gray-color);
min-height: 100vh;
}
.centered-layout {
max-width: 1024px;
margin: 0 auto;
padding: 0 2rem;
}
@media (max-width: 768px) {
.centered-layout {
padding: 0 1rem;
}
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--dark-gray-color);
}
.form-card {
background-color: var(--white-color);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
margin-bottom: 2rem;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.form-section:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.form-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
}
.form-group.full-width {
flex-basis: 100%;
}
.form-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--label-color);
margin-bottom: 0.5rem;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
line-height: 1.5;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--input-bg-color);
color: var(--text-color);
transition: border-color 0.2s, box-shadow 0.2s;
height: 3rem; /* 48px */
}
.form-input:disabled,
.form-textarea:disabled,
.form-select:disabled {
background-color: #e9ecef;
opacity: 1;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-textarea {
height: auto;
min-height: 120px;
resize: vertical;
}
.button-group {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 2rem;
}
.main-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
text-align: center;
}
.main-actions .btn {
padding: 0.85rem 2.8rem;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background-color: var(--primary-color);
color: var(--white-color);
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: var(--secondary-color);
color: var(--white-color);
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: var(--danger-color);
color: var(--white-color);
}
.btn-danger:hover {
background-color: #c82333;
}
.audit-info {
background-color: var(--white-color);
padding: 1.5rem;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
margin-top: 2rem;
}
.audit-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--dark-gray-color);
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.audit-body p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: var(--label-color);
}
.audit-body p strong {
color: var(--dark-gray-color);
margin-right: 0.5rem;
}
.delete-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
text-align: center;
}
.btn-delete-text {
background: none;
border: none;
color: var(--danger-color);
text-decoration: underline;
cursor: pointer;
font-size: 0.9rem;
padding: 0.5rem;
}
.btn-delete-text:hover {
color: #a71d2a;
}

View File

@ -0,0 +1,121 @@
/* /src/css/admin-theme-page.css */
.admin-theme-container {
max-width: 1200px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.admin-theme-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.table-container table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
.table-container th,
.table-container td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e8eb;
vertical-align: middle;
word-break: break-all;
}
.table-container th {
background-color: #f9fafb;
color: #505a67;
font-weight: 600;
}
.table-container tr:last-child td {
border-bottom: none;
}
.table-container tr:hover {
background-color: #f4f6f8;
}
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.btn-secondary:hover {
background-color: #E5E8EB;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}
.editing-row td {
padding-top: 20px;
padding-bottom: 20px;
}
.editing-row .btn {
margin-right: 8px;
}

View File

@ -0,0 +1,120 @@
/* /src/css/admin-time-page.css */
.admin-time-container {
max-width: 800px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.admin-time-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.table-container table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
.table-container th,
.table-container td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e8eb;
vertical-align: middle;
}
.table-container th {
background-color: #f9fafb;
color: #505a67;
font-weight: 600;
}
.table-container tr:last-child td {
border-bottom: none;
}
.table-container tr:hover {
background-color: #f4f6f8;
}
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.btn-secondary:hover {
background-color: #E5E8EB;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}
.editing-row td {
padding-top: 20px;
padding-bottom: 20px;
}
.editing-row .btn {
margin-right: 8px;
}

View File

@ -0,0 +1,81 @@
/* /src/css/admin-waiting-page.css */
.admin-waiting-container {
max-width: 1200px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.admin-waiting-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-container table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
.table-container th,
.table-container td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e8eb;
vertical-align: middle;
}
.table-container th {
background-color: #f9fafb;
color: #505a67;
font-weight: 600;
}
.table-container tr:last-child td {
border-bottom: none;
}
.table-container tr:hover {
background-color: #f4f6f8;
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
margin-right: 8px;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}

View File

@ -0,0 +1,66 @@
/* /src/css/home-page-v2.css */
.home-container-v2 {
max-width: 800px;
margin: 40px auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.home-container-v2 .page-title {
font-size: 28px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.theme-ranking-list-v2 {
display: flex;
flex-direction: column;
gap: 20px;
}
.theme-ranking-item-v2 {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.theme-ranking-item-v2:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.theme-ranking-item-v2 .thumbnail {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e5e8eb;
}
.theme-ranking-item-v2 .theme-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.theme-ranking-item-v2 .theme-name {
font-size: 20px;
font-weight: 700;
color: #333d4b;
margin: 0;
}
.theme-ranking-item-v2 .theme-description {
font-size: 16px;
color: #505a67;
margin: 0;
}

View File

@ -0,0 +1,74 @@
/* /src/css/login-page-v2.css */
.login-container-v2 {
width: 100%;
max-width: 400px;
margin: 80px auto;
padding: 40px;
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.login-container-v2 .page-title {
font-size: 28px;
font-weight: 700;
color: #191F28;
margin-bottom: 30px;
text-align: center;
}
.login-form-v2 .form-group {
margin-bottom: 20px;
}
.login-form-v2 .form-input {
width: 100%;
padding: 14px;
font-size: 16px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.login-form-v2 .form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.login-form-v2 .button-group {
margin-top: 30px;
display: flex;
gap: 10px;
}
.login-form-v2 .btn {
flex-grow: 1;
padding: 14px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.login-form-v2 .btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.login-form-v2 .btn-primary:hover {
background-color: #1B64DA;
}
.login-form-v2 .btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.login-form-v2 .btn-secondary:hover {
background-color: #E5E8EB;
}

117
frontend/src/css/navbar.css Normal file
View File

@ -0,0 +1,117 @@
/* /src/css/navbar.css */
.navbar-container {
background-color: #ffffff;
height: 60px;
padding: 0 40px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e8eb;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.navbar-container .nav-links {
display: flex;
align-items: center;
gap: 30px;
}
.navbar-container .nav-link {
text-decoration: none;
color: #4E5968;
font-size: 16px;
font-weight: 600;
transition: color 0.2s;
}
.navbar-container .nav-link:hover {
color: #191F28;
}
.navbar-container .nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-container .btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.navbar-container .btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.navbar-container .btn-primary:hover {
background-color: #1B64DA;
}
.navbar-container .btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.navbar-container .btn-secondary:hover {
background-color: #E5E8EB;
}
.navbar-container .profile-info {
display: flex;
align-items: center;
gap: 8px;
color: #333d4b;
font-weight: 600;
cursor: pointer;
position: relative;
height: 100%;
padding: 15px 0;
}
.navbar-container .profile-image {
width: 32px;
height: 32px;
border-radius: 50%;
}
.navbar-container .dropdown-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
min-width: 160px;
z-index: 100;
padding: 8px 0;
}
.navbar-container .profile-info:hover .dropdown-menu {
display: block;
}
.navbar-container .dropdown-item {
display: block;
padding: 10px 16px;
color: #333d4b;
text-decoration: none;
font-size: 15px;
}
.navbar-container .dropdown-item:hover {
background-color: #f4f6f8;
}
.navbar-container .dropdown-divider {
height: 1px;
margin: 8px 0;
overflow: hidden;
background-color: #e5e8eb;
}

View File

@ -0,0 +1,65 @@
/* /src/css/signup-page-v2.css */
.signup-container-v2 {
width: 100%;
max-width: 400px;
margin: 80px auto;
padding: 40px;
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.signup-container-v2 .page-title {
font-size: 28px;
font-weight: 700;
color: #191F28;
margin-bottom: 30px;
text-align: center;
}
.signup-form-v2 .form-group {
margin-bottom: 20px;
}
.signup-form-v2 .form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #4E5968;
}
.signup-form-v2 .form-input {
width: 100%;
padding: 14px;
font-size: 16px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.signup-form-v2 .form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.signup-form-v2 .btn-primary {
width: 100%;
margin-top: 10px;
padding: 14px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
background-color: #3182F6;
color: #ffffff;
transition: background-color 0.2s;
}
.signup-form-v2 .btn-primary:hover {
background-color: #1B64DA;
}

View File

@ -1,6 +1,5 @@
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 = () => {
@ -14,8 +13,7 @@ const LoginPage: React.FC = () => {
const handleLogin = async () => {
try {
const request: LoginRequest = { email, password };
await login(request);
await login({email, password});
alert('로그인에 성공했어요!');
navigate(from, { replace: true });

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import '../../css/navbar.css';
const AdminNavbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth();
@ -13,48 +14,30 @@ const AdminNavbar: React.FC = () => {
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>
<nav className="navbar-container">
<div className="nav-links">
<Link className="nav-link" to="/admin"></Link>
<Link className="nav-link" to="/admin/reservation"></Link>
<Link className="nav-link" to="/admin/waiting"></Link>
<Link className="nav-link" to="/admin/theme"></Link>
<Link className="nav-link" to="/admin/time"></Link>
</div>
<div className="nav-actions">
{!loggedIn ? (
<li className="nav-item">
<Link className="nav-link" to="/login">Login</Link>
</li>
<button className="btn btn-primary" onClick={() => navigate('/v2/login')}>Login</button>
) : (
<li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<div className="profile-info">
<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>
<span>{userName || 'Profile'}</span>
<div className="dropdown-menu">
<a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a>
</div>
</div>
)}
</ul>
</div>
</nav>
);

View File

@ -1,9 +1,10 @@
import React from 'react';
import '../../css/admin-page.css';
const AdminPage: React.FC = () => {
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="admin-container">
<h2 className="page-title"> </h2>
</div>
);
};

View File

@ -0,0 +1,266 @@
import { isLoginRequiredError } from '@_api/apiClient';
import {
createThemeV2,
deleteTheme,
fetchAdminThemeDetail,
updateTheme
} from '@_api/theme/themeAPI';
import { Difficulty, type ThemeCreateRequestV2, type ThemeUpdateRequest, type ThemeV2 } from '@_api/theme/themeTypes';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import '../../css/admin-theme-edit-page.css';
const AdminThemeEditPage: React.FC = () => {
const { themeId } = useParams<{ themeId: string }>();
const navigate = useNavigate();
const location = useLocation();
const isNew = themeId === 'new';
const [theme, setTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null);
const [originalTheme, setOriginalTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(isNew);
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
if (isNew) {
const newTheme: ThemeCreateRequestV2 = {
name: '',
description: '',
thumbnailUrl: '',
difficulty: Difficulty.NORMAL,
price: 0,
minParticipants: 2,
maxParticipants: 4,
availableMinutes: 60,
expectedMinutesFrom: 50,
expectedMinutesTo: 70,
isOpen: true,
};
setTheme(newTheme);
setOriginalTheme(newTheme);
setIsLoading(false);
} else if (themeId) {
fetchAdminThemeDetail(themeId)
.then(data => {
// Map AdminThemeDetailRetrieveResponse to ThemeV2
const fetchedTheme: ThemeV2 = {
id: data.id,
name: data.name,
description: data.description,
thumbnailUrl: data.thumbnailUrl,
difficulty: data.difficulty,
price: data.price,
minParticipants: data.minParticipants,
maxParticipants: data.maxParticipants,
availableMinutes: data.availableMinutes,
expectedMinutesFrom: data.expectedMinutesFrom,
expectedMinutesTo: data.expectedMinutesTo,
isOpen: data.isOpen,
createDate: data.createdAt, // Map createdAt to createDate
updatedDate: data.updatedAt, // Map updatedAt to updatedDate
createdBy: data.createdBy,
updatedBy: data.updatedBy,
};
setTheme(fetchedTheme);
setOriginalTheme(fetchedTheme);
})
.catch(handleError)
.finally(() => setIsLoading(false));
}
}, [themeId, isNew, navigate]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
let processedValue: string | number | boolean = value;
if (name === 'isOpen') {
processedValue = value === 'true';
} else if (type === 'checkbox') {
processedValue = (e.target as HTMLInputElement).checked;
} else if (type === 'number') {
processedValue = value === '' ? '' : Number(value);
}
setTheme(prev => prev ? { ...prev, [name]: processedValue } : null);
};
const handleCancelEdit = () => {
if (!isNew) {
setTheme(originalTheme);
setIsEditing(false);
} else {
navigate('/admin/theme');
}
};
const handleSubmit = async (e: React.FormEvent) => {
console.log('handleSubmit called');
e.preventDefault();
if (!theme) return;
try {
if (isNew) {
await createThemeV2(theme as ThemeCreateRequestV2);
alert('테마가 성공적으로 생성되었습니다.');
navigate(`/admin/theme`);
} else {
if (!themeId) {
throw new Error('themeId is undefined');
}
await updateTheme(themeId, theme as ThemeUpdateRequest);
alert('테마가 성공적으로 업데이트되었습니다.');
setOriginalTheme(theme);
setIsEditing(false);
navigate(`/admin/theme`);
}
} catch (error) {
handleError(error);
}
};
const handleDelete = async () => {
if (isNew || !themeId) return;
if (window.confirm('정말로 이 테마를 삭제하시겠습니까?')) {
try {
await deleteTheme(themeId);
alert('테마가 삭제되었습니다.');
navigate('/admin/theme');
} catch (error) {
handleError(error);
}
}
};
if (isLoading) {
return <div className="admin-theme-edit-container"><p> ...</p></div>;
}
if (!theme) {
return <div className="admin-theme-edit-container"><p> .</p></div>;
}
return (
<div className="admin-theme-edit-container">
<div className="centered-layout">
<header className="page-header">
<h2 className="page-title">{isNew ? '새 테마 추가' : '테마 정보 수정'}</h2>
</header>
<form onSubmit={handleSubmit} className="form-card">
<div className="form-section">
<div className="form-group full-width">
<label className="form-label" htmlFor="name"> </label>
<input id="name" name="name" type="text" className="form-input" value={theme.name} onChange={handleChange} required disabled={!isEditing} />
</div>
<div className="form-group full-width">
<label className="form-label" htmlFor="description"></label>
<textarea id="description" name="description" className="form-textarea" value={theme.description} onChange={handleChange} required disabled={!isEditing} />
</div>
<div className="form-group full-width">
<label className="form-label" htmlFor="thumbnailUrl"> URL</label>
<input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={theme.thumbnailUrl} onChange={handleChange} required disabled={!isEditing} />
</div>
</div>
<div className="form-section">
<div className="form-row">
<div className="form-group">
<label className="form-label" htmlFor="difficulty"></label>
<select id="difficulty" name="difficulty" className="form-select" value={theme.difficulty} onChange={handleChange} disabled={!isEditing}>
{Object.values(Difficulty).map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label" htmlFor="isOpen"> </label>
<select id="isOpen" name="isOpen" className="form-select" value={String(theme.isOpen)} onChange={handleChange} disabled={!isEditing}>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label" htmlFor="price"> ()</label>
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
</div>
<div className="form-group">
<label className="form-label" htmlFor="availableMinutes"> ()</label>
<input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={theme.availableMinutes} onChange={handleChange} required disabled={!isEditing} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label" htmlFor="expectedMinutesFrom"> ()</label>
<input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={theme.expectedMinutesFrom} onChange={handleChange} required disabled={!isEditing} />
</div>
<div className="form-group">
<label className="form-label" htmlFor="expectedMinutesTo"> ()</label>
<input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={theme.expectedMinutesTo} onChange={handleChange} required disabled={!isEditing} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label" htmlFor="minParticipants"> ()</label>
<input id="minParticipants" name="minParticipants" type="number" className="form-input" value={theme.minParticipants} onChange={handleChange} required disabled={!isEditing} />
</div>
<div className="form-group">
<label className="form-label" htmlFor="maxParticipants"> ()</label>
<input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={theme.maxParticipants} onChange={handleChange} required disabled={!isEditing} />
</div>
</div>
</div>
<div className="button-group">
{isEditing ? (
<div className="main-actions">
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}></button>
<button type="submit" className="btn btn-primary"></button>
</div>
) : (
<div className="main-actions">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}></button>
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); console.log('setIsEditing(true) called'); setIsEditing(true); }}></button>
</div>
)}
</div>
</form>
{!isNew && 'id' in theme && (
<div className="audit-info">
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p><strong>:</strong> {new Date(theme.createDate).toLocaleString()}</p>
<p><strong>:</strong> {new Date(theme.updatedDate).toLocaleString()}</p>
<p><strong>:</strong> {theme.createdBy}</p>
<p><strong>:</strong> {theme.updatedBy}</p>
</div>
</div>
)}
{!isNew && !isEditing && (
<div className="delete-section">
<button className="btn-delete-text" onClick={handleDelete}> .</button>
</div>
)}
</div>
</div>
);
};
export default AdminThemeEditPage;

View File

@ -14,6 +14,7 @@ import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
import { isLoginRequiredError } from '@_api/apiClient';
import '../../css/admin-reservation-page.css';
const AdminReservationPage: React.FC = () => {
const [reservations, setReservations] = useState<ReservationRetrieveResponse[]>([]);
@ -102,14 +103,15 @@ const AdminReservationPage: React.FC = () => {
};
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 className="admin-reservation-container">
<h2 className="page-title"> </h2>
<div className="admin-reservation-content">
<div className="reservations-main section-card">
<div className="table-header">
<button className="btn btn-primary" onClick={handleAddClick}> </button>
</div>
<table className="table">
<div className="table-container">
<table>
<thead>
<tr>
<th></th>
@ -117,7 +119,7 @@ const AdminReservationPage: React.FC = () => {
<th></th>
<th></th>
<th></th>
<th> </th>
<th></th>
<th></th>
</tr>
</thead>
@ -134,30 +136,30 @@ const AdminReservationPage: React.FC = () => {
</tr>
))}
{isEditing && (
<tr>
<tr className="editing-row">
<td></td>
<td>
<select className="form-control" onChange={e => setNewReservation({ ...newReservation, memberId: e.target.value })}>
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, memberId: e.target.value })}>
<option value=""> </option>
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</td>
<td>
<select className="form-control" onChange={e => setNewReservation({ ...newReservation, themeId: e.target.value })}>
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, themeId: e.target.value })}>
<option value=""> </option>
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</td>
<td><input type="date" className="form-control" onChange={e => setNewReservation({ ...newReservation, date: e.target.value })} /></td>
<td><input type="date" className="form-input" onChange={e => setNewReservation({ ...newReservation, date: e.target.value })} /></td>
<td>
<select className="form-control" onChange={e => setNewReservation({ ...newReservation, timeId: e.target.value })}>
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, timeId: e.target.value })}>
<option value=""> </option>
{times.map(t => <option key={t.id} value={t.id}>{t.startAt}</option>)}
</select>
</td>
<td></td>
<td>
<button className="btn btn-custom" onClick={handleSaveClick}></button>
<button className="btn btn-primary" onClick={handleSaveClick}></button>
<button className="btn btn-secondary" onClick={handleCancelClick}></button>
</td>
</tr>
@ -165,31 +167,33 @@ const AdminReservationPage: React.FC = () => {
</tbody>
</table>
</div>
<div className="filter-section ml-3">
</div>
<div className="filter-section section-card">
<h3 className="card-title"> </h3>
<form id="filter-form" onSubmit={applyFilter}>
<div className="form-group">
<label htmlFor="member"></label>
<select id="member" name="memberId" className="form-control" onChange={handleFilterChange}>
<label className="form-label" htmlFor="member"></label>
<select id="member" name="memberId" className="form-select" onChange={handleFilterChange}>
<option value=""></option>
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
<div className="form-group">
<label htmlFor="theme"></label>
<select id="theme" name="themeId" className="form-control" onChange={handleFilterChange}>
<label className="form-label" htmlFor="theme"></label>
<select id="theme" name="themeId" className="form-select" onChange={handleFilterChange}>
<option value=""></option>
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="form-group">
<label htmlFor="date-from">From</label>
<input type="date" id="date-from" name="dateFrom" className="form-control" onChange={handleFilterChange} />
<label className="form-label" htmlFor="date-from">From</label>
<input type="date" id="date-from" name="dateFrom" className="form-input" onChange={handleFilterChange} />
</div>
<div className="form-group">
<label htmlFor="date-to">To</label>
<input type="date" id="date-to" name="dateTo" className="form-control" onChange={handleFilterChange} />
<label className="form-label" htmlFor="date-to">To</label>
<input type="date" id="date-to" name="dateTo" className="form-input" onChange={handleFilterChange} />
</div>
<button type="submit" className="btn btn-primary float-end"></button>
<button type="submit" className="btn btn-primary"></button>
</form>
</div>
</div>

View File

@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { createTheme, fetchThemes, delTheme } from '@_api/theme/themeAPI';
import { fetchAdminThemes } from '@_api/theme/themeAPI';
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
import { isLoginRequiredError } from '@_api/apiClient';
import '../../css/admin-theme-page.css';
const AdminThemePage: React.FC = () => {
const [themes, setThemes] = useState<any[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [newTheme, setNewTheme] = useState({ name: '', description: '', thumbnail: '' });
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
const navigate = useNavigate();
const location = useLocation();
@ -23,88 +23,59 @@ const AdminThemePage: React.FC = () => {
useEffect(() => {
const fetchData = async () => {
await fetchThemes()
.then(response => setThemes(response.themes))
.catch(handleError);
try {
const response = await fetchAdminThemes();
setThemes(response.themes);
} catch (error) {
handleError(error);
}
};
fetchData();
}, []);
const handleAddClick = () => {
setIsEditing(true);
navigate('/admin/theme/edit/new');
};
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: string) => {
if (!window.confirm('정말 삭제하시겠어요?')) {
return;
}
await delTheme(id)
.then(() => {
setThemes(themes.filter(theme => theme.id !== id));
alert('테마를 삭제했어요.');
})
.catch(handleError);
const handleManageClick = (themeId: number) => {
navigate(`/admin/theme/edit/${themeId}`);
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="admin-theme-container">
<h2 className="page-title"> </h2>
<div className="section-card">
<div className="table-header">
<button id="add-button" className="btn btn-custom mb-2 float-end" onClick={handleAddClick}> </button>
<button className="btn btn-primary" onClick={handleAddClick}> </button>
</div>
<div className="table-container" />
<table className="table">
<div className="table-container">
<table>
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"> URL</th>
<th scope="col"></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="table-body">
<tbody>
{themes.map(theme => (
<tr key={theme.id}>
<td>{theme.id}</td>
<td>{theme.name}</td>
<td>{theme.description}</td>
<td>{theme.thumbnail}</td>
<td>{theme.difficulty}</td>
<td>{theme.price.toLocaleString()}</td>
<td>{theme.isOpen ? '공개' : '비공개'}</td>
<td>
<button className="btn btn-danger" onClick={() => deleteTheme(theme.id)}></button>
<button className="btn btn-secondary" onClick={() => handleManageClick(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>
</div>
</div>
);
};

View File

@ -3,6 +3,7 @@ import type { TimeCreateRequest } from '@_api/time/timeTypes';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { isLoginRequiredError } from '@_api/apiClient';
import '../../css/admin-time-page.css';
const AdminTimePage: React.FC = () => {
const [times, setTimes] = useState<any[]>([]);
@ -76,21 +77,22 @@ const AdminTimePage: React.FC = () => {
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="admin-time-container">
<h2 className="page-title"> </h2>
<div className="section-card">
<div className="table-header">
<button id="add-button" className="btn btn-custom mb-2 float-end" onClick={handleAddClick}> </button>
<button className="btn btn-primary" onClick={handleAddClick}> </button>
</div>
<div className="table-container" />
<table className="table">
<div className="table-container">
<table>
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th>ID</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="table-body">
<tbody>
{times.map(time => (
<tr key={time.id}>
<td>{time.id}</td>
@ -101,11 +103,11 @@ const AdminTimePage: React.FC = () => {
</tr>
))}
{isEditing && (
<tr>
<tr className="editing-row">
<td></td>
<td><input type="time" className="form-control" value={newTime} onChange={e => setNewTime(e.target.value)} /></td>
<td><input type="time" className="form-input" value={newTime} onChange={e => setNewTime(e.target.value)} /></td>
<td>
<button className="btn btn-custom" onClick={handleSaveClick}></button>
<button className="btn btn-primary" onClick={handleSaveClick}></button>
<button className="btn btn-secondary" onClick={handleCancelClick}></button>
</td>
</tr>
@ -113,6 +115,8 @@ const AdminTimePage: React.FC = () => {
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@ -3,6 +3,7 @@ import type { ReservationRetrieveResponse } from '@_api/reservation/reservationT
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { isLoginRequiredError } from '@_api/apiClient';
import '../../css/admin-waiting-page.css';
const AdminWaitingPage: React.FC = () => {
const [waitings, setWaitings] = useState<ReservationRetrieveResponse[]>([]);
@ -48,21 +49,22 @@ const AdminWaitingPage: React.FC = () => {
};
return (
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="table-container" />
<table className="table">
<div className="admin-waiting-container">
<h2 className="page-title"> </h2>
<div className="section-card">
<div className="table-container">
<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>
<th> </th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="table-body">
<tbody>
{waitings.map(w => (
<tr key={w.id}>
<td>{w.id}</td>
@ -71,7 +73,7 @@ const AdminWaitingPage: React.FC = () => {
<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-primary" onClick={() => approveWaiting(w.id)}></button>
<button className="btn btn-danger" onClick={() => denyWaiting(w.id)}></button>
</td>
</tr>
@ -79,6 +81,8 @@ const AdminWaitingPage: React.FC = () => {
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react';
import { mostReservedThemes } from '../../api/theme/themeAPI';
import '../../css/home-page-v2.css';
const HomePageV2: React.FC = () => {
const [ranking, setRanking] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const response = await mostReservedThemes(10);
setRanking(response.themes);
} catch (err) {
console.error('Error fetching ranking:', err);
}
};
fetchData();
}, []);
return (
<div className="home-container-v2">
<h2 className="page-title"> </h2>
<div className="theme-ranking-list-v2">
{ranking.map(theme => (
<div key={theme.id} className="theme-ranking-item-v2">
<img className="thumbnail" src={theme.thumbnail} alt={theme.name} />
<div className="theme-info">
<h5 className="theme-name">{theme.name}</h5>
<p className="theme-description">{theme.description}</p>
</div>
</div>
))}
</div>
</div>
);
};
export default HomePageV2;

View File

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import '../../css/login-page-v2.css';
const LoginPageV2: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({email, password});
alert('로그인에 성공했어요!');
navigate(from, { replace: true });
} catch (error: any) {
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
alert(message);
console.error('로그인 실패:', error);
setPassword('');
}
}
return (
<div className="login-container-v2">
<h2 className="page-title"></h2>
<form className="login-form-v2" onSubmit={handleLogin}>
<div className="form-group">
<input
type="email"
className="form-input"
placeholder="이메일"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<input
type="password"
className="form-input"
placeholder="비밀번호"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="button-group">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/signup')}></button>
<button type="submit" className="btn btn-primary"></button>
</div>
</form>
</div>
);
};
export default LoginPageV2;

View File

@ -1,4 +1,7 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPI';
import { fetchUserThemes } from '@_api/theme/themeAPI';
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react';
@ -7,7 +10,7 @@ import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
// New theme type based on the provided schema
interface ThemeV21 {
id: string;
id: string; // Changed to number to match API
name: string;
difficulty: string;
description: string;
@ -44,83 +47,35 @@ const ReservationStep1PageV21: React.FC = () => {
};
useEffect(() => {
const mockThemes: ThemeV21[] = [
{
id: '1',
name: '우주 감옥 탈출',
difficulty: '어려움',
description: '당신은 우주에서 가장 악명 높은 감옥에 갇혔습니다. 동료들과 협력하여 감시 시스템을 뚫고 탈출하세요!',
thumbnailUrl: 'https://example.com/space-prison.jpg',
price: 28000,
minParticipants: 2,
maxParticipants: 5,
expectedMinutesFrom: 60,
expectedMinutesTo: 75,
availableMinutes: 90,
},
{
id: '2',
name: '마법사의 서재',
difficulty: '보통',
description: '전설적인 마법사의 비밀 서재에 들어왔습니다. 숨겨진 마법 주문을 찾아 세상을 구원하세요.',
thumbnailUrl: 'https://example.com/wizard-library.jpg',
price: 25000,
minParticipants: 2,
maxParticipants: 4,
expectedMinutesFrom: 50,
expectedMinutesTo: 60,
availableMinutes: 70,
},
{
id: '3',
name: '해적선 대탐험',
difficulty: '쉬움',
description: '전설의 해적선에 숨겨진 보물을 찾아 떠나는 모험! 가족, 친구와 함께 즐거운 시간을 보내세요.',
thumbnailUrl: 'https://example.com/pirate-ship.jpg',
price: 22000,
minParticipants: 3,
maxParticipants: 6,
expectedMinutesFrom: 45,
expectedMinutesTo: 55,
availableMinutes: 80,
},
];
const fetchMockThemes = () => {
return new Promise<{ themes: ThemeV21[] }>((resolve) => {
setTimeout(() => {
resolve({ themes: mockThemes });
}, 500); // 0.5초 딜레이
});
const fetchData = async () => {
try {
const response = await fetchUserThemes();
// Map UserThemeRetrieveResponse to ThemeV21
const mappedThemes: ThemeV21[] = response.themes.map(theme => ({
id: theme.id,
name: theme.name,
difficulty: theme.difficulty,
description: theme.description,
thumbnailUrl: theme.thumbnailUrl,
price: theme.price,
minParticipants: theme.minParticipants,
maxParticipants: theme.maxParticipants,
expectedMinutesFrom: theme.expectedMinutesFrom,
expectedMinutesTo: theme.expectedMinutesTo,
availableMinutes: theme.availableMinutes,
}));
setThemes(mappedThemes);
} catch (error) {
handleError(error);
}
};
fetchMockThemes().then(res => setThemes(res.themes)).catch(handleError);
fetchData();
}, []);
useEffect(() => {
if (selectedDate && selectedTheme) {
const mockTimes: TimeWithAvailabilityResponse[] = [
{ id: 't1', startAt: '10:00', isAvailable: Math.random() > 0.3 },
{ id: 't2', startAt: '11:15', isAvailable: Math.random() > 0.3 },
{ id: 't3', startAt: '12:30', isAvailable: Math.random() > 0.3 },
{ id: 't4', startAt: '13:45', isAvailable: Math.random() > 0.3 },
{ id: 't5', startAt: '15:00', isAvailable: Math.random() > 0.3 },
{ id: 't6', startAt: '16:15', isAvailable: Math.random() > 0.3 },
{ id: 't7', startAt: '17:30', isAvailable: Math.random() > 0.3 },
{ id: 't8', startAt: '18:45', isAvailable: Math.random() > 0.3 },
{ id: 't9', startAt: '20:00', isAvailable: Math.random() > 0.3 },
];
const fetchMockTimes = (date: Date, themeId: string) => {
console.log(`Fetching mock times for ${date.toLocaleDateString()} and theme ${themeId}`);
return new Promise<{ times: TimeWithAvailabilityResponse[] }>((resolve) => {
setTimeout(() => {
resolve({ times: mockTimes });
}, 300); // 0.3초 딜레이
});
};
fetchMockTimes(selectedDate, selectedTheme.id)
const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchTimesWithAvailability(dateStr, selectedTheme.id)
.then(res => {
setTimes(res.times);
setSelectedTime(null);
@ -150,28 +105,13 @@ const ReservationStep1PageV21: React.FC = () => {
timeId: selectedTime.id,
};
// Mock createPendingReservation to include price
const mockCreatePendingReservation = (data: typeof reservationData) => {
console.log("Creating pending reservation with:", data);
return new Promise<any>((resolve) => {
setTimeout(() => {
resolve({
reservationId: `res-${crypto.randomUUID()}`,
themeName: selectedTheme?.name,
date: data.date,
startAt: selectedTime?.startAt,
price: selectedTheme?.price, // Include the price in the response
});
}, 200);
});
};
mockCreatePendingReservation(reservationData)
createPendingReservation(reservationData)
.then((res) => {
navigate('/v2-1/reservation/payment', { state: { reservation: res } });
navigate('/v2/reservation/payment', { state: { reservation: res } });
})
.catch(handleError)
.finally(() => setIsConfirmModalOpen(false));
.finally(() => setIsModalOpen(false));
};
const renderDateOptions = () => {

View File

@ -1,9 +1,9 @@
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
import { isLoginRequiredError } from '@_api/apiClient';
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
import '@_css/reservation-v2-1.css'; // Reuse the new CSS for consistency
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
declare global {
@ -21,7 +21,7 @@ const ReservationStep2PageV21: React.FC = () => {
// The reservation object now contains the price
const reservation: ReservationCreateResponse & { price: number } | undefined = location.state?.reservation;
console.log(reservation)
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
@ -71,7 +71,7 @@ const ReservationStep2PageV21: React.FC = () => {
paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(),
orderName: `${reservation.themeName} 예약 결제`,
amount: reservation.price, // Use the price here as well
amount: reservation.price,
}).then((data: any) => {
const paymentData: ReservationPaymentRequest = {
paymentKey: data.paymentKey,

View File

@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { signup } from '../../api/member/memberAPI';
import type { SignupRequest } from '../../api/member/memberTypes';
import '../../css/signup-page-v2.css';
const SignupPageV2: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const navigate = useNavigate();
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
const request: SignupRequest = { email, password, name };
try {
const response = await signup(request);
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
navigate('/v2/login');
} catch (error: any) {
const message = error.response?.data?.message || '회원가입에 실패했어요. 입력 정보를 확인해주세요.';
alert(message);
console.error(error);
}
};
return (
<div className="signup-container-v2">
<h2 className="page-title"></h2>
<form className="signup-form-v2" onSubmit={handleSignup}>
<div className="form-group">
<label className="form-label"></label>
<input
type="email"
className="form-input"
placeholder="이메일을 입력하세요"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label"></label>
<input
type="password"
className="form-input"
placeholder="비밀번호를 입력하세요"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label"></label>
<input
type="text"
className="form-input"
placeholder="이름을 입력하세요"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<button type="submit" className="btn-primary"></button>
</form>
</div>
);
};
export default SignupPageV2;

View File

@ -0,0 +1,55 @@
POST http://localhost:8080/login?key=value&key1=value1
Content-Type: application/json
{
"email": "a@a.a",
"password": "a"
}
> {%
const accessToken = response.body.data.accessToken;
client.global.set("token", accessToken);
%}
###
GET http://localhost:8080/reservations
Accept: application/json
Authorization: Bearer {{token}}
###
DELETE http://localhost:8080/reservations/57
Accept: application/json
Authorization: Bearer {{token}}
###
POST http://localhost:8080/reservations/admin
Authorization: Bearer {{token}}
Content-Type: application/json
{
"date": "2026-10-01",
"timeId": 1,
"themeId": 1,
"memberId": 3
}
###
POST http://localhost:8080/reservations/admin
Authorization: Bearer {{token}}
Content-Type: application/json
{
"date": "2023-10-01",
"timeId": 1,
"themeId": 1,
"memberId": 3
}
###
GET http://localhost:8080/reservations-mine
Accept: application/json