Hotel Booking

This commit is contained in:
Iliyan Angelov
2025-11-16 14:19:13 +02:00
commit 824eec6190
203 changed files with 37696 additions and 0 deletions

5
client/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# API Configuration
VITE_API_URL=http://localhost:3000
# Environment
VITE_ENV=development

18
client/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

29
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# 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?
# Environment variables
.env
.env.local
.env.production

160
client/ROUTING_GUIDE.md Normal file
View File

@@ -0,0 +1,160 @@
# Routing Configuration - Hướng dẫn Test
## ✅ Đã hoàn thành Chức năng 2
### Components đã tạo:
1. **ProtectedRoute** - `src/components/auth/ProtectedRoute.tsx`
- Bảo vệ routes yêu cầu authentication
- Redirect về `/login` nếu chưa đăng nhập
- Lưu location để quay lại sau khi login
2. **AdminRoute** - `src/components/auth/AdminRoute.tsx`
- Bảo vệ routes chỉ dành cho Admin
- Redirect về `/` nếu không phải admin
- Kiểm tra `userInfo.role === 'admin'`
3. **Page Components**:
- `RoomListPage` - Danh sách phòng (public)
- `BookingListPage` - Lịch sử đặt phòng (protected)
- `DashboardPage` - Dashboard cá nhân (protected)
### Cấu trúc Routes:
#### Public Routes (Không cần đăng nhập):
```
/ → HomePage
/rooms → RoomListPage
/about → About Page
/login → Login Page (chưa có)
/register → Register Page (chưa có)
/forgot-password → Forgot Password Page (chưa có)
/reset-password/:token → Reset Password Page (chưa có)
```
#### Protected Routes (Cần đăng nhập):
```
/dashboard → DashboardPage (ProtectedRoute)
/bookings → BookingListPage (ProtectedRoute)
/profile → Profile Page (ProtectedRoute)
```
#### Admin Routes (Chỉ Admin):
```
/admin → AdminLayout (AdminRoute)
/admin/dashboard → Admin Dashboard
/admin/users → Quản lý người dùng
/admin/rooms → Quản lý phòng
/admin/bookings → Quản lý đặt phòng
/admin/payments → Quản lý thanh toán
/admin/services → Quản lý dịch vụ
/admin/promotions → Quản lý khuyến mãi
/admin/banners → Quản lý banner
/admin/reports → Báo cáo
/admin/settings → Cài đặt
```
## 🧪 Cách Test
### 1. Khởi động Dev Server:
```bash
cd /d/hotel-booking/client
npm run dev
```
Mở `http://localhost:5173`
### 2. Test Public Routes:
- Truy cập `/` → Hiển thị HomePage ✅
- Truy cập `/rooms` → Hiển thị RoomListPage ✅
- Truy cập `/about` → Hiển thị About Page ✅
### 3. Test Protected Routes (Chưa login):
- Truy cập `/dashboard` → Redirect về `/login`
- Truy cập `/bookings` → Redirect về `/login`
- Truy cập `/profile` → Redirect về `/login`
### 4. Test Protected Routes (Đã login):
- Click nút **"🔒 Demo Login"** ở góc dưới phải
- Truy cập `/dashboard` → Hiển thị Dashboard ✅
- Truy cập `/bookings` → Hiển thị Booking List ✅
- Truy cập `/profile` → Hiển thị Profile ✅
### 5. Test Admin Routes (Role = Customer):
- Đảm bảo đã login (role = customer)
- Truy cập `/admin` → Redirect về `/`
- Truy cập `/admin/dashboard` → Redirect về `/`
### 6. Test Admin Routes (Role = Admin):
- Click nút **"👑 Switch to Admin"**
- Truy cập `/admin` → Redirect về `/admin/dashboard`
- Truy cập `/admin/users` → Hiển thị User Management ✅
- Truy cập `/admin/rooms` → Hiển thị Room Management ✅
- Click các menu trong SidebarAdmin → Hoạt động bình thường ✅
### 7. Test Logout:
- Click nút **"🔓 Demo Logout"**
- Truy cập `/dashboard` → Redirect về `/login`
- Truy cập `/admin` → Redirect về `/`
## 🎯 Kết quả mong đợi
### ✅ ProtectedRoute:
1. User chưa login không thể truy cập protected routes
2. Redirect về `/login` và lưu `state.from` để quay lại sau
3. User đã login có thể truy cập protected routes bình thường
### ✅ AdminRoute:
1. User không phải admin không thể truy cập `/admin/*`
2. Redirect về `/` nếu không phải admin
3. Admin có thể truy cập tất cả admin routes
### ✅ Không có redirect loop:
1. Redirect chỉ xảy ra 1 lần
2. Không có vòng lặp redirect vô tận
3. Browser history hoạt động đúng (back/forward)
## 📝 Demo Buttons (Tạm thời)
### 🔒 Demo Login/Logout:
- Click để toggle authentication state
- Mô phỏng login/logout
- Sẽ được thay bằng Zustand store ở Chức năng 3
### 👑 Switch Role:
- Chỉ hiển thị khi đã login
- Toggle giữa `customer``admin`
- Test AdminRoute hoạt động đúng
## 🚀 Bước tiếp theo
Chức năng 3: useAuthStore (Zustand Store)
- Tạo store quản lý auth state toàn cục
- Thay thế demo state bằng Zustand
- Tích hợp với localStorage
- Xóa demo toggle buttons
## 🔧 File Structure
```
src/
├── components/
│ ├── auth/
│ │ ├── ProtectedRoute.tsx
│ │ ├── AdminRoute.tsx
│ │ └── index.ts
│ └── layout/
│ ├── Header.tsx
│ ├── Footer.tsx
│ ├── SidebarAdmin.tsx
│ ├── LayoutMain.tsx
│ └── index.ts
├── pages/
│ ├── HomePage.tsx
│ ├── AdminLayout.tsx
│ └── customer/
│ ├── RoomListPage.tsx
│ ├── BookingListPage.tsx
│ └── DashboardPage.tsx
└── App.tsx
```

View File

@@ -0,0 +1,305 @@
# useAuthStore - Zustand Authentication Store
## ✅ Hoàn thành Chức năng 3
### 📦 Files đã tạo:
1. **`src/store/useAuthStore.ts`** - Zustand store quản lý auth
2. **`src/services/api/apiClient.ts`** - Axios client với interceptors
3. **`src/services/api/authService.ts`** - Auth API service
4. **`.env.example`** - Template cho environment variables
### 🎯 Tính năng đã implement:
#### State Management:
```typescript
interface AuthState {
token: string | null;
refreshToken: string | null;
userInfo: UserInfo | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
```
#### Actions:
-`login(credentials)` - Đăng nhập
-`register(data)` - Đăng ký tài khoản mới
-`logout()` - Đăng xuất
-`setUser(user)` - Cập nhật thông tin user
-`refreshAuthToken()` - Làm mới token
-`forgotPassword(data)` - Quên mật khẩu
-`resetPassword(data)` - Đặt lại mật khẩu
-`initializeAuth()` - Khởi tạo auth từ localStorage
-`clearError()` - Xóa error message
### 📝 Cách sử dụng:
#### 1. Khởi tạo trong App.tsx:
```typescript
import useAuthStore from './store/useAuthStore';
function App() {
const {
isAuthenticated,
userInfo,
logout,
initializeAuth
} = useAuthStore();
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
// ...
}
```
#### 2. Sử dụng trong Login Form:
```typescript
import useAuthStore from '../store/useAuthStore';
const LoginPage = () => {
const { login, isLoading, error } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (data) => {
try {
await login(data);
navigate('/dashboard'); // Redirect sau khi login
} catch (error) {
// Error đã được xử lý bởi store
}
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
{error && <div>{error}</div>}
<button disabled={isLoading}>
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
</button>
</form>
);
};
```
#### 3. Sử dụng trong Register Form:
```typescript
const RegisterPage = () => {
const { register, isLoading } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (data) => {
try {
await register(data);
navigate('/login'); // Redirect về login
} catch (error) {
// Error được hiển thị qua toast
}
};
// ...
};
```
#### 4. Logout:
```typescript
const Header = () => {
const { logout } = useAuthStore();
const handleLogout = async () => {
await logout();
// Auto redirect về login nếu cần
};
return <button onClick={handleLogout}>Đăng xuất</button>;
};
```
#### 5. Hiển thị thông tin user:
```typescript
const Profile = () => {
const { userInfo } = useAuthStore();
return (
<div>
<h1>Xin chào, {userInfo?.name}</h1>
<p>Email: {userInfo?.email}</p>
<p>Role: {userInfo?.role}</p>
</div>
);
};
```
### 🔐 LocalStorage Persistence:
Store tự động lưu và đọc từ localStorage:
- `token` - JWT access token
- `refreshToken` - JWT refresh token
- `userInfo` - Thông tin user
Khi reload page, auth state được khôi phục tự động qua `initializeAuth()`.
### 🌐 API Integration:
#### Base URL Configuration:
Tạo file `.env` trong thư mục `client/`:
```env
VITE_API_URL=http://localhost:3000
VITE_ENV=development
```
#### API Endpoints được sử dụng:
- `POST /api/auth/login` - Đăng nhập
- `POST /api/auth/register` - Đăng ký
- `POST /api/auth/logout` - Đăng xuất
- `GET /api/auth/profile` - Lấy profile
- `POST /api/auth/refresh-token` - Refresh token
- `POST /api/auth/forgot-password` - Quên mật khẩu
- `POST /api/auth/reset-password` - Đặt lại mật khẩu
### 🛡️ Security Features:
1. **Auto Token Injection**:
- Axios interceptor tự động thêm token vào headers
```typescript
Authorization: Bearer <token>
```
2. **Auto Logout on 401**:
- Khi token hết hạn (401), tự động logout và redirect về login
3. **Token Refresh**:
- Có thể refresh token khi sắp hết hạn
4. **Password Hashing**:
- Backend xử lý bcrypt hashing
### 📱 Toast Notifications:
Store tự động hiển thị toast cho các events:
- ✅ Login thành công
- ✅ Đăng ký thành công
- ✅ Logout
- ❌ Login thất bại
- ❌ Đăng ký thất bại
- ❌ API errors
### 🔄 Component Updates:
#### ProtectedRoute:
```typescript
// TRƯỚC (với props)
<ProtectedRoute isAuthenticated={isAuthenticated}>
<Dashboard />
</ProtectedRoute>
// SAU (tự động lấy từ store)
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
```
#### AdminRoute:
```typescript
// TRƯỚC (với props)
<AdminRoute userInfo={userInfo}>
<AdminPanel />
</AdminRoute>
// SAU (tự động lấy từ store)
<AdminRoute>
<AdminPanel />
</AdminRoute>
```
#### LayoutMain:
Vẫn nhận props từ App.tsx để hiển thị Header/Navbar:
```typescript
<LayoutMain
isAuthenticated={isAuthenticated}
userInfo={userInfo}
onLogout={handleLogout}
/>
```
### 🧪 Testing:
Để test authentication flow:
1. **Tạo file `.env`**:
```bash
cp .env.example .env
```
2. **Ensure backend đang chạy**:
```bash
cd server
npm run dev
```
3. **Chạy frontend**:
```bash
cd client
npm run dev
```
4. **Test flow**:
- Truy cập `/register` → Đăng ký tài khoản
- Truy cập `/login` → Đăng nhập
- Truy cập `/dashboard` → Xem dashboard (protected)
- Click logout → Xóa session
- Reload page → Auth state được khôi phục
### 🚀 Next Steps:
**Chức năng 4: Form Login**
- Tạo LoginPage với React Hook Form + Yup
- Tích hợp với useAuthStore
- UX enhancements (loading, show/hide password, remember me)
**Chức năng 5: Form Register**
- Tạo RegisterPage với validation
- Tích hợp với useAuthStore
**Chức năng 6-7: Password Reset Flow**
- ForgotPasswordPage
- ResetPasswordPage
### 📚 TypeScript Types:
```typescript
interface LoginCredentials {
email: string;
password: string;
rememberMe?: boolean;
}
interface RegisterData {
name: string;
email: string;
password: string;
phone?: string;
}
interface UserInfo {
id: number;
name: string;
email: string;
phone?: string;
avatar?: string;
role: string;
createdAt?: string;
}
```
### ✅ Kết quả đạt được:
1. ✅ Toàn bộ thông tin user được quản lý tập trung
2. ✅ Duy trì đăng nhập sau khi reload trang
3. ✅ Dễ dàng truy cập userInfo trong mọi component
4. ✅ Auto token management
5. ✅ Type-safe với TypeScript
6. ✅ Clean code, dễ maintain

13
client/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>Hotel Booking - Management System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5098
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
client/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "hotel-booking-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@types/react-datepicker": "^6.2.0",
"axios": "^1.6.2",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"react": "^18.3.1",
"react-datepicker": "^8.9.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.20.0",
"react-toastify": "^9.1.3",
"yup": "^1.3.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/node": "^24.9.2",
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

338
client/src/App.tsx Normal file
View File

@@ -0,0 +1,338 @@
import React, { useEffect } from 'react';
import {
BrowserRouter,
Routes,
Route,
Navigate
} from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
// Store
import useAuthStore from './store/useAuthStore';
import useFavoritesStore from './store/useFavoritesStore';
// Layout Components
import { LayoutMain } from './components/layout';
import AdminLayout from './pages/AdminLayout';
// Auth Components
import {
ProtectedRoute,
AdminRoute
} from './components/auth';
// Pages
import HomePage from './pages/HomePage';
import DashboardPage from
'./pages/customer/DashboardPage';
import RoomListPage from
'./pages/customer/RoomListPage';
import RoomDetailPage from
'./pages/customer/RoomDetailPage';
import SearchResultsPage from
'./pages/customer/SearchResultsPage';
import FavoritesPage from
'./pages/customer/FavoritesPage';
import MyBookingsPage from
'./pages/customer/MyBookingsPage';
import BookingPage from
'./pages/customer/BookingPage';
import BookingSuccessPage from
'./pages/customer/BookingSuccessPage';
import BookingDetailPage from
'./pages/customer/BookingDetailPage';
import DepositPaymentPage from
'./pages/customer/DepositPaymentPage';
import PaymentConfirmationPage from
'./pages/customer/PaymentConfirmationPage';
import PaymentResultPage from
'./pages/customer/PaymentResultPage';
import {
LoginPage,
RegisterPage,
ForgotPasswordPage,
ResetPasswordPage
} from './pages/auth';
// Admin Pages
import {
DashboardPage as AdminDashboardPage,
RoomManagementPage,
UserManagementPage,
BookingManagementPage,
PaymentManagementPage,
ServiceManagementPage,
ReviewManagementPage,
PromotionManagementPage,
CheckInPage,
CheckOutPage,
} from './pages/admin';
// Demo component cho các page chưa có
const DemoPage: React.FC<{ title: string }> = ({ title }) => (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-800">
{title}
</h1>
<p className="text-gray-600 mt-4">
Page này đang đưc phát triển...
</p>
</div>
);
function App() {
// Sử dụng Zustand store
const {
isAuthenticated,
userInfo,
logout,
initializeAuth
} = useAuthStore();
const {
fetchFavorites,
syncGuestFavorites,
loadGuestFavorites,
} = useFavoritesStore();
// Khởi tạo auth state khi app load
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
// Load favorites when authenticated or load guest favorites
useEffect(() => {
if (isAuthenticated) {
// Sync guest favorites first, then fetch
syncGuestFavorites().then(() => {
fetchFavorites();
});
} else {
// Load guest favorites from localStorage
loadGuestFavorites();
}
}, [
isAuthenticated,
fetchFavorites,
syncGuestFavorites,
loadGuestFavorites,
]);
// Handle logout
const handleLogout = async () => {
await logout();
};
return (
<BrowserRouter>
<Routes>
{/* Public Routes with Main Layout */}
<Route
path="/"
element={
<LayoutMain
isAuthenticated={isAuthenticated}
userInfo={userInfo}
onLogout={handleLogout}
/>
}
>
<Route index element={<HomePage />} />
<Route
path="rooms"
element={<RoomListPage />}
/>
<Route
path="rooms/search"
element={<SearchResultsPage />}
/>
<Route
path="rooms/:id"
element={<RoomDetailPage />}
/>
<Route
path="favorites"
element={<FavoritesPage />}
/>
<Route
path="payment-result"
element={<PaymentResultPage />}
/>
<Route
path="about"
element={<DemoPage title="Giới thiệu" />}
/>
{/* Protected Routes - Yêu cầu đăng nhập */}
<Route
path="dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="booking/:id"
element={
<ProtectedRoute>
<BookingPage />
</ProtectedRoute>
}
/>
<Route
path="booking-success/:id"
element={
<ProtectedRoute>
<BookingSuccessPage />
</ProtectedRoute>
}
/>
<Route
path="deposit-payment/:bookingId"
element={
<ProtectedRoute>
<DepositPaymentPage />
</ProtectedRoute>
}
/>
<Route
path="bookings"
element={
<ProtectedRoute>
<MyBookingsPage />
</ProtectedRoute>
}
/>
<Route
path="bookings/:id"
element={
<ProtectedRoute>
<BookingDetailPage />
</ProtectedRoute>
}
/>
<Route
path="payment/:id"
element={
<ProtectedRoute>
<PaymentConfirmationPage />
</ProtectedRoute>
}
/>
<Route
path="profile"
element={
<ProtectedRoute>
<DemoPage title="Hồ sơ" />
</ProtectedRoute>
}
/>
</Route>
{/* Auth Routes (no layout) */}
<Route
path="/login"
element={<LoginPage />}
/>
<Route
path="/register"
element={<RegisterPage />}
/>
<Route
path="/forgot-password"
element={<ForgotPasswordPage />}
/>
<Route
path="/reset-password/:token"
element={<ResetPasswordPage />}
/>
{/* Admin Routes - Chỉ admin mới truy cập được */}
<Route
path="/admin"
element={
<AdminRoute>
<AdminLayout />
</AdminRoute>
}
>
<Route
index
element={<Navigate to="dashboard" replace />}
/>
<Route path="dashboard" element={<AdminDashboardPage />} />
<Route
path="users"
element={<UserManagementPage />}
/>
<Route
path="rooms"
element={<RoomManagementPage />}
/>
<Route
path="bookings"
element={<BookingManagementPage />}
/>
<Route
path="payments"
element={<PaymentManagementPage />}
/>
<Route
path="services"
element={<ServiceManagementPage />}
/>
<Route
path="reviews"
element={<ReviewManagementPage />}
/>
<Route
path="promotions"
element={<PromotionManagementPage />}
/>
<Route
path="check-in"
element={<CheckInPage />}
/>
<Route
path="check-out"
element={<CheckOutPage />}
/>
<Route
path="banners"
element={<DemoPage title="Quản lý banner" />}
/>
<Route
path="reports"
element={<DemoPage title="Báo cáo" />}
/>
<Route
path="settings"
element={<DemoPage title="Cài đặt" />}
/>
</Route>
{/* 404 Route */}
<Route
path="*"
element={<DemoPage title="404 - Không tìm thấy trang" />}
/>
</Routes>
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
interface AdminRouteProps {
children: React.ReactNode;
}
/**
* AdminRoute - Bảo vệ các route chỉ dành cho Admin
*
* Kiểm tra:
* 1. User đã đăng nhập chưa → nếu chưa, redirect /login
* 2. User có role admin không → nếu không, redirect /
*/
const AdminRoute: React.FC<AdminRouteProps> = ({
children
}) => {
const location = useLocation();
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
// Đang loading auth state → hiển thị loading
if (isLoading) {
return (
<div
className="min-h-screen flex items-center
justify-center bg-gray-50"
>
<div className="text-center">
<div
className="animate-spin rounded-full h-12 w-12
border-b-2 border-indigo-600 mx-auto"
/>
<p className="mt-4 text-gray-600">
Đang xác thực...
</p>
</div>
</div>
);
}
// Chưa đăng nhập → redirect về /login
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location }}
replace
/>
);
}
// Đã đăng nhập nhưng không phải admin → redirect về /
const isAdmin = userInfo?.role === 'admin';
if (!isAdmin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
export default AdminRoute;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
interface ProtectedRouteProps {
children: React.ReactNode;
}
/**
* ProtectedRoute - Bảo vệ các route yêu cầu authentication
*
* Nếu user chưa đăng nhập, redirect về /login
* và lưu location hiện tại để redirect về sau khi login
*/
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children
}) => {
const location = useLocation();
const { isAuthenticated, isLoading } = useAuthStore();
// Đang loading auth state → hiển thị loading
if (isLoading) {
return (
<div
className="min-h-screen flex items-center
justify-center bg-gray-50"
>
<div className="text-center">
<div
className="animate-spin rounded-full h-12 w-12
border-b-2 border-indigo-600 mx-auto"
/>
<p className="mt-4 text-gray-600">
Đang tải...
</p>
</div>
</div>
);
}
// Chưa đăng nhập → redirect về /login
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location }}
replace
/>
);
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,2 @@
export { default as ProtectedRoute } from './ProtectedRoute';
export { default as AdminRoute } from './AdminRoute';

View File

@@ -0,0 +1,100 @@
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
secondaryAction?: {
label: string;
onClick: () => void;
};
children?: ReactNode;
className?: string;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon: Icon,
title,
description,
action,
secondaryAction,
children,
className = '',
}) => {
return (
<div
className={`bg-white rounded-lg shadow-sm
p-12 text-center ${className}`}
>
{Icon && (
<div
className="w-24 h-24 bg-gray-100
rounded-full flex items-center
justify-center mx-auto mb-6"
>
<Icon className="w-12 h-12 text-gray-400" />
</div>
)}
<h3
className="text-2xl font-bold
text-gray-900 mb-3"
>
{title}
</h3>
{description && (
<p
className="text-gray-600 mb-6
max-w-md mx-auto"
>
{description}
</p>
)}
{children}
{(action || secondaryAction) && (
<div
className="flex flex-col sm:flex-row
gap-3 justify-center mt-6"
>
{action && (
<button
onClick={action.onClick}
className="px-6 py-3 bg-indigo-600
text-white rounded-lg
hover:bg-indigo-700
transition-colors font-semibold
inline-flex items-center
justify-center gap-2"
>
{action.label}
</button>
)}
{secondaryAction && (
<button
onClick={secondaryAction.onClick}
className="px-6 py-3 border
border-gray-300 text-gray-700
rounded-lg hover:bg-gray-50
transition-colors font-semibold
inline-flex items-center
justify-center gap-2"
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,143 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-gray-50
flex items-center justify-center p-4"
>
<div className="max-w-md w-full bg-white
rounded-lg shadow-lg p-8"
>
<div className="flex items-center
justify-center w-16 h-16 bg-red-100
rounded-full mx-auto mb-4"
>
<AlertCircle className="w-8 h-8
text-red-600"
/>
</div>
<h1 className="text-2xl font-bold
text-gray-900 text-center mb-2"
>
Đã xảy ra lỗi
</h1>
<p className="text-gray-600 text-center mb-6">
Xin lỗi, đã lỗi xảy ra. Vui lòng thử lại
hoặc liên hệ hỗ trợ nếu vấn đ vẫn tiếp diễn.
</p>
{process.env.NODE_ENV === 'development' &&
this.state.error && (
<div className="bg-red-50 border
border-red-200 rounded-lg p-4 mb-6"
>
<p className="text-sm font-mono
text-red-800 break-all"
>
{this.state.error.toString()}
</p>
{this.state.errorInfo && (
<details className="mt-2">
<summary className="text-sm
text-red-700 cursor-pointer
hover:text-red-800"
>
Chi tiết lỗi
</summary>
<pre className="mt-2 text-xs
text-red-600 overflow-auto
max-h-40"
>
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
)}
<div className="flex gap-3">
<button
onClick={this.handleReset}
className="flex-1 flex items-center
justify-center gap-2 bg-indigo-600
text-white px-6 py-3 rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
<RefreshCw className="w-5 h-5" />
Tải lại trang
</button>
<button
onClick={() => window.location.href = '/'}
className="flex-1 bg-gray-200
text-gray-700 px-6 py-3 rounded-lg
hover:bg-gray-300 transition-colors
font-semibold"
>
Về trang chủ
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoadingProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
fullScreen?: boolean;
className?: string;
}
const Loading: React.FC<LoadingProps> = ({
size = 'md',
text = 'Loading...',
fullScreen = false,
className = '',
}) => {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
const textSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
const content = (
<div className={`flex flex-col items-center
justify-center gap-3 ${className}`}
>
<Loader2
className={`${sizeClasses[size]}
text-indigo-600 animate-spin`}
/>
{text && (
<p className={`${textSizeClasses[size]}
text-gray-600 font-medium`}
>
{text}
</p>
)}
</div>
);
if (fullScreen) {
return (
<div className="min-h-screen bg-gray-50
flex items-center justify-center"
>
{content}
</div>
);
}
return content;
};
export default Loading;

View File

@@ -0,0 +1,86 @@
import React, {
useState,
useEffect,
ImgHTMLAttributes
} from 'react';
interface OptimizedImageProps
extends ImgHTMLAttributes<HTMLImageElement> {
src: string;
alt: string;
fallbackSrc?: string;
aspectRatio?: string;
className?: string;
}
const OptimizedImage: React.FC<OptimizedImageProps> = ({
src,
alt,
fallbackSrc = '/images/placeholder.jpg',
aspectRatio,
className = '',
...props
}) => {
const [imageSrc, setImageSrc] = useState<string>(src);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
useEffect(() => {
setImageSrc(src);
setIsLoading(true);
setHasError(false);
}, [src]);
const handleLoad = () => {
setIsLoading(false);
};
const handleError = () => {
console.error(`Failed to load image: ${imageSrc}`);
setImageSrc(fallbackSrc);
setHasError(true);
setIsLoading(false);
};
return (
<div
className={`relative overflow-hidden
bg-gray-200 ${className}`}
style={
aspectRatio
? { aspectRatio }
: undefined
}
>
{isLoading && (
<div
className="absolute inset-0
flex items-center justify-center"
>
<div
className="w-8 h-8 border-4
border-gray-300 border-t-indigo-600
rounded-full animate-spin"
/>
</div>
)}
<img
src={imageSrc}
alt={alt}
loading="lazy"
onLoad={handleLoad}
onError={handleError}
className={`
w-full h-full object-cover
transition-opacity duration-300
${isLoading ? 'opacity-0' : 'opacity-100'}
${hasError ? 'opacity-50' : ''}
`}
{...props}
/>
</div>
);
};
export default OptimizedImage;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalItems?: number;
itemsPerPage?: number;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
totalItems,
itemsPerPage = 5,
}) => {
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems || 0);
return (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
{/* Mobile */}
<div className="flex flex-1 justify-between sm:hidden">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
currentPage === 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
Trước
</button>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
currentPage === totalPages
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
Sau
</button>
</div>
{/* Desktop */}
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Hiển thị{' '}
<span className="font-medium">{startItem}</span> đến{' '}
<span className="font-medium">{endItem}</span> trong tổng số{' '}
<span className="font-medium">{totalItems || 0}</span> kết quả
</p>
</div>
<div>
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{/* Previous Button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 ${
currentPage === 1
? 'cursor-not-allowed bg-gray-100'
: 'hover:bg-gray-50 focus:z-20 focus:outline-offset-0'
}`}
>
<span className="sr-only">Previous</span>
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
</button>
{/* Page Numbers */}
{getPageNumbers().map((page, index) => {
if (page === '...') {
return (
<span
key={`ellipsis-${index}`}
className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
>
...
</span>
);
}
const pageNum = page as number;
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ${
currentPage === pageNum
? 'z-10 bg-blue-600 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600'
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0'
}`}
>
{pageNum}
</button>
);
})}
{/* Next Button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 ${
currentPage === totalPages
? 'cursor-not-allowed bg-gray-100'
: 'hover:bg-gray-50 focus:z-20 focus:outline-offset-0'
}`}
>
<span className="sr-only">Next</span>
<ChevronRight className="h-5 w-5" aria-hidden="true" />
</button>
</nav>
</div>
</div>
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { CreditCard, Building2 } from 'lucide-react';
interface PaymentMethodSelectorProps {
value: 'cash' | 'bank_transfer';
onChange: (value: 'cash' | 'bank_transfer') => void;
error?: string;
disabled?: boolean;
}
const PaymentMethodSelector: React.FC<
PaymentMethodSelectorProps
> = ({ value, onChange, error, disabled = false }) => {
return (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Payment Method
<span className="text-red-500 ml-1">*</span>
</h3>
<div className="space-y-3">
{/* Cash Payment */}
<label
className={`flex items-start p-4 border-2
rounded-lg cursor-pointer transition-all
${
value === 'cash'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="radio"
name="payment_method"
value="cash"
checked={value === 'cash'}
onChange={(e) =>
onChange(e.target.value as 'cash')
}
disabled={disabled}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CreditCard
className="w-5 h-5 text-gray-600"
/>
<span className="font-medium text-gray-900">
Pay at Hotel
</span>
</div>
<p className="text-sm text-gray-600">
Pay directly at the hotel when checking in.
Cash and card accepted.
</p>
<div className="mt-2 text-xs text-gray-500
bg-white rounded px-2 py-1 inline-block"
>
Payment at check-in
</div>
</div>
</label>
{/* Bank Transfer */}
<label
className={`flex items-start p-4 border-2
rounded-lg cursor-pointer transition-all
${
value === 'bank_transfer'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="radio"
name="payment_method"
value="bank_transfer"
checked={value === 'bank_transfer'}
onChange={(e) =>
onChange(e.target.value as 'bank_transfer')
}
disabled={disabled}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Building2
className="w-5 h-5 text-gray-600"
/>
<span className="font-medium text-gray-900">
Bank Transfer
</span>
<span className="text-xs bg-green-100
text-green-700 px-2 py-0.5 rounded-full
font-medium"
>
Recommended
</span>
</div>
<p className="text-sm text-gray-600">
Transfer via QR code or account number.
Quick confirmation within 24 hours.
</p>
<div className="mt-2 text-xs text-gray-500
bg-white rounded px-2 py-1 inline-block"
>
💳 Confirmation after booking
</div>
</div>
</label>
</div>
{error && (
<p className="text-sm text-red-600 mt-2">
{error}
</p>
)}
{/* Additional Info */}
<div className="mt-4 p-3 bg-blue-50 border
border-blue-200 rounded-lg"
>
<p className="text-xs text-blue-800">
💡 <strong>Note:</strong> You will not be
charged immediately. {' '}
{value === 'cash'
? 'Payment when checking in.'
: 'Transfer after booking confirmation.'}
</p>
</div>
</div>
);
};
export default PaymentMethodSelector;

View File

@@ -0,0 +1,98 @@
import React from 'react';
import {
CheckCircle,
XCircle,
Clock,
AlertCircle,
} from 'lucide-react';
interface PaymentStatusBadgeProps {
status: 'pending' | 'completed' | 'failed' | 'unpaid' | 'paid' | 'refunded';
size?: 'sm' | 'md' | 'lg';
showIcon?: boolean;
}
const PaymentStatusBadge: React.FC<PaymentStatusBadgeProps> = ({
status,
size = 'md',
showIcon = true,
}) => {
const getStatusConfig = () => {
switch (status) {
case 'paid':
case 'completed':
return {
icon: CheckCircle,
color: 'bg-green-100 text-green-800',
text: 'Paid',
};
case 'unpaid':
case 'pending':
return {
icon: Clock,
color: 'bg-yellow-100 text-yellow-800',
text: 'Unpaid',
};
case 'failed':
return {
icon: XCircle,
color: 'bg-red-100 text-red-800',
text: 'Payment Failed',
};
case 'refunded':
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: 'Refunded',
};
default:
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: status,
};
}
};
const getSizeClasses = () => {
switch (size) {
case 'sm':
return 'text-xs px-2 py-1';
case 'lg':
return 'text-base px-4 py-2';
case 'md':
default:
return 'text-sm px-3 py-1.5';
}
};
const getIconSize = () => {
switch (size) {
case 'sm':
return 'w-3 h-3';
case 'lg':
return 'w-5 h-5';
case 'md':
default:
return 'w-4 h-4';
}
};
const config = getStatusConfig();
const StatusIcon = config.icon;
return (
<span
className={`inline-flex items-center gap-1.5
rounded-full font-medium
${config.color} ${getSizeClasses()}`}
>
{showIcon && (
<StatusIcon className={getIconSize()} />
)}
{config.text}
</span>
);
};
export default PaymentStatusBadge;

View File

@@ -0,0 +1,197 @@
import React from 'react';
import { Link } from 'react-router-dom';
import {
Hotel,
Facebook,
Twitter,
Instagram,
Mail,
Phone,
MapPin
} from 'lucide-react';
const Footer: React.FC = () => {
return (
<footer className="bg-gray-900 text-gray-300">
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-4 gap-8">
{/* Company Info */}
<div>
<div className="flex items-center space-x-2 mb-4">
<Hotel className="w-8 h-8 text-blue-500" />
<span className="text-xl font-bold text-white">
Hotel Booking
</span>
</div>
<p className="text-sm text-gray-400 mb-4">
Leading online hotel management and
booking system.
</p>
<div className="flex space-x-4">
<a
href="#"
className="hover:text-blue-500
transition-colors"
aria-label="Facebook"
>
<Facebook className="w-5 h-5" />
</a>
<a
href="#"
className="hover:text-blue-500
transition-colors"
aria-label="Twitter"
>
<Twitter className="w-5 h-5" />
</a>
<a
href="#"
className="hover:text-blue-500
transition-colors"
aria-label="Instagram"
>
<Instagram className="w-5 h-5" />
</a>
</div>
</div>
{/* Quick Links */}
<div>
<h3 className="text-white font-semibold mb-4">
Quick Links
</h3>
<ul className="space-y-2">
<li>
<Link
to="/"
className="hover:text-blue-500
transition-colors text-sm"
>
Home
</Link>
</li>
<li>
<Link
to="/rooms"
className="hover:text-blue-500
transition-colors text-sm"
>
Rooms
</Link>
</li>
<li>
<Link
to="/bookings"
className="hover:text-blue-500
transition-colors text-sm"
>
Bookings
</Link>
</li>
<li>
<Link
to="/about"
className="hover:text-blue-500
transition-colors text-sm"
>
About
</Link>
</li>
</ul>
</div>
{/* Support */}
<div>
<h3 className="text-white font-semibold mb-4">
Support
</h3>
<ul className="space-y-2">
<li>
<Link
to="/faq"
className="hover:text-blue-500
transition-colors text-sm"
>
FAQ
</Link>
</li>
<li>
<Link
to="/terms"
className="hover:text-blue-500
transition-colors text-sm"
>
Terms of Service
</Link>
</li>
<li>
<Link
to="/privacy"
className="hover:text-blue-500
transition-colors text-sm"
>
Privacy Policy
</Link>
</li>
<li>
<Link
to="/contact"
className="hover:text-blue-500
transition-colors text-sm"
>
Contact
</Link>
</li>
</ul>
</div>
{/* Contact Info */}
<div>
<h3 className="text-white font-semibold mb-4">
Contact
</h3>
<ul className="space-y-3">
<li className="flex items-start space-x-3">
<MapPin className="w-5 h-5 text-blue-500
flex-shrink-0 mt-0.5"
/>
<span className="text-sm">
123 ABC Street, District 1, Ho Chi Minh City
</span>
</li>
<li className="flex items-center space-x-3">
<Phone className="w-5 h-5 text-blue-500
flex-shrink-0"
/>
<span className="text-sm">
(028) 1234 5678
</span>
</li>
<li className="flex items-center space-x-3">
<Mail className="w-5 h-5 text-blue-500
flex-shrink-0"
/>
<span className="text-sm">
info@hotelbooking.com
</span>
</li>
</ul>
</div>
</div>
{/* Copyright */}
<div className="border-t border-gray-800 mt-8
pt-4 -mb-8 text-center"
>
<p className="text-sm text-gray-400">
&copy; {new Date().getFullYear()} Hotel Booking.
All rights reserved.
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,370 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Hotel,
User,
LogOut,
Menu,
X,
LogIn,
UserPlus,
Heart,
} from 'lucide-react';
interface HeaderProps {
isAuthenticated?: boolean;
userInfo?: {
name: string;
email: string;
avatar?: string;
role: string;
} | null;
onLogout?: () => void;
}
const Header: React.FC<HeaderProps> = ({
isAuthenticated = false,
userInfo = null,
onLogout
}) => {
const [isMobileMenuOpen, setIsMobileMenuOpen] =
useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const toggleUserMenu = () => {
setIsUserMenuOpen(!isUserMenuOpen);
};
const handleLogout = () => {
if (onLogout) {
onLogout();
}
setIsUserMenuOpen(false);
setIsMobileMenuOpen(false);
};
return (
<header className="bg-white shadow-md sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between">
{/* Logo */}
<Link
to="/"
className="flex items-center space-x-2
hover:opacity-80 transition-opacity"
>
<Hotel className="w-8 h-8 text-blue-600" />
<span className="text-2xl font-bold text-gray-800">
Hotel Booking
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center
space-x-6"
>
<Link
to="/"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
>
Home
</Link>
<Link
to="/rooms"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
>
Rooms
</Link>
<Link
to="/bookings"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
>
Bookings
</Link>
<Link
to="/favorites"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium flex
items-center gap-1"
>
<Heart className="w-4 h-4" />
Favorites
</Link>
<Link
to="/about"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
>
About
</Link>
</nav>
{/* Desktop Auth Section */}
<div className="hidden md:flex items-center
space-x-4"
>
{!isAuthenticated ? (
<>
<Link
to="/login"
className="flex items-center space-x-2
px-4 py-2 text-blue-600
hover:text-blue-700 transition-colors
font-medium"
>
<LogIn className="w-4 h-4" />
<span>Login</span>
</Link>
<Link
to="/register"
className="flex items-center space-x-2
px-4 py-2 bg-blue-600 text-white
rounded-lg hover:bg-blue-700
transition-colors font-medium"
>
<UserPlus className="w-4 h-4" />
<span>Register</span>
</Link>
</>
) : (
<div className="relative">
<button
onClick={toggleUserMenu}
className="flex items-center space-x-3
px-3 py-2 rounded-lg hover:bg-gray-100
transition-colors"
>
{userInfo?.avatar ? (
<img
src={userInfo.avatar}
alt={userInfo.name}
className="w-8 h-8 rounded-full
object-cover"
/>
) : (
<div className="w-8 h-8 bg-blue-500
rounded-full flex items-center
justify-center"
>
<span className="text-white
font-semibold text-sm"
>
{userInfo?.name?.charAt(0)
.toUpperCase()}
</span>
</div>
)}
<span className="font-medium text-gray-700">
{userInfo?.name}
</span>
</button>
{/* User Dropdown Menu */}
{isUserMenuOpen && (
<div className="absolute right-0 mt-2
w-48 bg-white rounded-lg shadow-lg
py-2 border border-gray-200 z-50"
>
<Link
to="/profile"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center space-x-2
px-4 py-2 text-gray-700
hover:bg-gray-100 transition-colors"
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
{userInfo?.role === 'admin' && (
<Link
to="/admin"
onClick={() =>
setIsUserMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-gray-700
hover:bg-gray-100 transition-colors"
>
<User className="w-4 h-4" />
<span>Admin</span>
</Link>
)}
<button
onClick={handleLogout}
className="w-full flex items-center
space-x-2 px-4 py-2 text-red-600
hover:bg-gray-100 transition-colors
text-left"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</div>
)}
</div>
)}
</div>
{/* Mobile Menu Button */}
<button
onClick={toggleMobileMenu}
className="md:hidden p-2 rounded-lg
hover:bg-gray-100 transition-colors"
>
{isMobileMenuOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden py-4 border-t
border-gray-200 mt-4"
>
<div className="flex flex-col space-y-2">
<Link
to="/"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
>
Home
</Link>
<Link
to="/rooms"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
>
Rooms
</Link>
<Link
to="/bookings"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
>
Bookings
</Link>
<Link
to="/favorites"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors flex items-center gap-2"
>
<Heart className="w-4 h-4" />
Favorites
</Link>
<Link
to="/about"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
>
About
</Link>
<div className="border-t border-gray-200
pt-2 mt-2"
>
{!isAuthenticated ? (
<>
<Link
to="/login"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-blue-600
hover:bg-gray-100 rounded-lg
transition-colors"
>
<LogIn className="w-4 h-4" />
<span>Login</span>
</Link>
<Link
to="/register"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-blue-600
hover:bg-gray-100 rounded-lg
transition-colors"
>
<UserPlus className="w-4 h-4" />
<span>Register</span>
</Link>
</>
) : (
<>
<div className="px-4 py-2 text-sm
text-gray-500"
>
Hello, {userInfo?.name}
</div>
<Link
to="/profile"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
{userInfo?.role === 'admin' && (
<Link
to="/admin"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2
text-gray-700 hover:bg-gray-100
rounded-lg transition-colors"
>
<User className="w-4 h-4" />
<span>Admin</span>
</Link>
)}
<button
onClick={handleLogout}
className="w-full flex items-center
space-x-2 px-4 py-2 text-red-600
hover:bg-gray-100 rounded-lg
transition-colors text-left"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</>
)}
</div>
</div>
</div>
)}
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
interface LayoutMainProps {
isAuthenticated?: boolean;
userInfo?: {
name: string;
email: string;
avatar?: string;
role: string;
} | null;
onLogout?: () => void;
}
const LayoutMain: React.FC<LayoutMainProps> = ({
isAuthenticated = false,
userInfo = null,
onLogout
}) => {
return (
<div className="flex flex-col min-h-screen">
{/* Header with Navigation and Auth */}
<Header
isAuthenticated={isAuthenticated}
userInfo={userInfo}
onLogout={onLogout}
/>
{/* Main Content Area - Outlet renders child routes */}
<main className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Outlet />
</div>
</main>
{/* Footer */}
<Footer />
</div>
);
};
export default LayoutMain;

View File

@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Users,
Hotel,
Calendar,
CreditCard,
Settings,
FileText,
BarChart3,
Tag,
Image,
ChevronLeft,
ChevronRight,
Star,
LogIn,
LogOut
} from 'lucide-react';
interface SidebarAdminProps {
isCollapsed?: boolean;
onToggle?: () => void;
}
const SidebarAdmin: React.FC<SidebarAdminProps> = ({
isCollapsed: controlledCollapsed,
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] =
useState(false);
const location = useLocation();
const isCollapsed =
controlledCollapsed !== undefined
? controlledCollapsed
: internalCollapsed;
const handleToggle = () => {
if (onToggle) {
onToggle();
} else {
setInternalCollapsed(!internalCollapsed);
}
};
const menuItems = [
{
path: '/admin/dashboard',
icon: LayoutDashboard,
label: 'Dashboard'
},
{
path: '/admin/users',
icon: Users,
label: 'Người dùng'
},
{
path: '/admin/rooms',
icon: Hotel,
label: 'Phòng'
},
{
path: '/admin/bookings',
icon: Calendar,
label: 'Đặt phòng'
},
{
path: '/admin/payments',
icon: CreditCard,
label: 'Thanh toán'
},
{
path: '/admin/services',
icon: Settings,
label: 'Dịch vụ'
},
{
path: '/admin/promotions',
icon: Tag,
label: 'Khuyến mãi'
},
{
path: '/admin/check-in',
icon: LogIn,
label: 'Check-in'
},
{
path: '/admin/check-out',
icon: LogOut,
label: 'Check-out'
},
{
path: '/admin/reviews',
icon: Star,
label: 'Đánh giá'
},
{
path: '/admin/banners',
icon: Image,
label: 'Banner'
},
{
path: '/admin/reports',
icon: BarChart3,
label: 'Báo cáo'
},
{
path: '/admin/settings',
icon: FileText,
label: 'Cài đặt'
},
];
const isActive = (path: string) => {
return location.pathname === path ||
location.pathname.startsWith(`${path}/`);
};
return (
<aside
className={`bg-gray-900 text-white
transition-all duration-300 flex flex-col
${isCollapsed ? 'w-20' : 'w-64'}`}
>
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-800
flex items-center justify-between"
>
{!isCollapsed && (
<h2 className="text-xl font-bold">
Admin Panel
</h2>
)}
<button
onClick={handleToggle}
className="p-2 rounded-lg hover:bg-gray-800
transition-colors ml-auto"
aria-label="Toggle sidebar"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5" />
) : (
<ChevronLeft className="w-5 h-5" />
)}
</button>
</div>
{/* Menu Items */}
<nav className="flex-1 overflow-y-auto py-4">
<ul className="space-y-1 px-2">
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
className={`flex items-center
space-x-3 px-3 py-3 rounded-lg
transition-colors group
${active
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
title={isCollapsed ? item.label : undefined}
>
<Icon className={`flex-shrink-0
${isCollapsed ? 'w-6 h-6' : 'w-5 h-5'}`}
/>
{!isCollapsed && (
<span className="font-medium">
{item.label}
</span>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Sidebar Footer */}
<div className="p-4 border-t border-gray-800">
{!isCollapsed ? (
<div className="text-xs text-gray-400 text-center">
<p>Admin Dashboard v1.0</p>
<p className="mt-1">
© {new Date().getFullYear()}
</p>
</div>
) : (
<div className="flex justify-center">
<div className="w-2 h-2 bg-green-500
rounded-full"
></div>
</div>
)}
</div>
</aside>
);
};
export default SidebarAdmin;

View File

@@ -0,0 +1,4 @@
export { default as Header } from './Header';
export { default as Footer } from './Footer';
export { default as SidebarAdmin } from './SidebarAdmin';
export { default as LayoutMain } from './LayoutMain';

View File

@@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Banner } from '../../services/api/bannerService';
interface BannerCarouselProps {
banners: Banner[];
}
const BannerCarousel: React.FC<BannerCarouselProps> = ({
banners
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
// Auto-slide every 5 seconds
useEffect(() => {
if (banners.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) =>
prev === banners.length - 1 ? 0 : prev + 1
);
}, 5000);
return () => clearInterval(interval);
}, [banners.length]);
const goToPrevious = () => {
setCurrentIndex((prev) =>
prev === 0 ? banners.length - 1 : prev - 1
);
};
const goToNext = () => {
setCurrentIndex((prev) =>
prev === banners.length - 1 ? 0 : prev + 1
);
};
const goToSlide = (index: number) => {
setCurrentIndex(index);
};
// Default fallback banner if no banners provided
const defaultBanner = {
id: 0,
title: 'Chào mừng đến với Hotel Booking',
image_url: '/images/default-banner.jpg',
position: 'home',
display_order: 0,
is_active: true,
created_at: '',
updated_at: '',
};
const displayBanners = banners.length > 0
? banners
: [defaultBanner];
const currentBanner = displayBanners[currentIndex];
return (
<div
className="relative w-full h-[500px] md:h-[640px] \
overflow-hidden rounded-xl shadow-lg"
>
{/* Banner Image */}
<div className="relative w-full h-full">
<img
src={currentBanner.image_url}
alt={currentBanner.title}
className="w-full h-full object-cover"
onError={(e) => {
// Fallback to placeholder if image fails to load
e.currentTarget.src = '/images/default-banner.jpg';
}}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-gradient-to-t
from-black/60 via-black/20 to-transparent"
/>
{/* Title */}
{currentBanner.title && (
<div
className="absolute bottom-8 left-8 right-8
text-white"
>
<h2
className="text-3xl md:text-5xl font-bold
mb-2 drop-shadow-lg"
>
{currentBanner.title}
</h2>
</div>
)}
</div>
{/* Navigation Buttons */}
{displayBanners.length > 1 && (
<>
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2
-translate-y-1/2 bg-white/80
hover:bg-white text-gray-800 p-2
rounded-full shadow-lg transition-all"
aria-label="Previous banner"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2
-translate-y-1/2 bg-white/80
hover:bg-white text-gray-800 p-2
rounded-full shadow-lg transition-all"
aria-label="Next banner"
>
<ChevronRight className="w-6 h-6" />
</button>
</>
)}
{/* Dots Indicator */}
{displayBanners.length > 1 && (
<div
className="absolute bottom-4 left-1/2
-translate-x-1/2 flex gap-2"
>
{displayBanners.map((_, index) => (
<button
key={index}
onClick={() => goToSlide(index)}
className={`w-2 h-2 rounded-full
transition-all
${
index === currentIndex
? 'bg-white w-8'
: 'bg-white/50 hover:bg-white/75'
}`}
aria-label={`Go to banner ${index + 1}`}
/>
))}
</div>
)}
</div>
);
};
export default BannerCarousel;

View File

@@ -0,0 +1,19 @@
import React from 'react';
const BannerSkeleton: React.FC = () => {
return (
<div
className="w-full h-[500px] md:h-[640px] \
bg-gray-300 rounded-xl shadow-lg animate-pulse"
>
<div className="w-full h-full flex items-end p-8">
<div className="w-full max-w-xl space-y-3">
<div className="h-12 bg-gray-400 rounded w-3/4" />
<div className="h-8 bg-gray-400 rounded w-1/2" />
</div>
</div>
</div>
);
};
export default BannerSkeleton;

View File

@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { Heart } from 'lucide-react';
import useFavoritesStore from '../../store/useFavoritesStore';
interface FavoriteButtonProps {
roomId: number;
size?: 'sm' | 'md' | 'lg';
showTooltip?: boolean;
className?: string;
}
const FavoriteButton: React.FC<FavoriteButtonProps> = ({
roomId,
size = 'md',
showTooltip = true,
className = '',
}) => {
const {
isFavorited,
addToFavorites,
removeFromFavorites,
} = useFavoritesStore();
const [isProcessing, setIsProcessing] = useState(false);
const [showTooltipText, setShowTooltipText] =
useState(false);
const favorited = isFavorited(roomId);
// Size classes
const sizeClasses = {
sm: 'w-6 h-6 p-1',
md: 'w-10 h-10 p-2',
lg: 'w-12 h-12 p-2.5',
};
const iconSizes = {
sm: 16,
md: 20,
lg: 24,
};
const handleClick = async (
e: React.MouseEvent<HTMLButtonElement>
) => {
e.preventDefault();
e.stopPropagation();
if (isProcessing) return;
setIsProcessing(true);
try {
if (favorited) {
await removeFromFavorites(roomId);
} else {
await addToFavorites(roomId);
}
} finally {
setIsProcessing(false);
}
};
const tooltipText = favorited
? 'Bỏ yêu thích'
: 'Thêm vào yêu thích';
return (
<div className="relative inline-block">
<button
onClick={handleClick}
disabled={isProcessing}
onMouseEnter={() =>
showTooltip && setShowTooltipText(true)
}
onMouseLeave={() => setShowTooltipText(false)}
className={`
${sizeClasses[size]}
rounded-full
transition-all
duration-200
flex
items-center
justify-center
${
favorited
? 'bg-red-50 hover:bg-red-100'
: 'bg-white hover:bg-gray-100'
}
${
isProcessing
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}
border-2
${
favorited
? 'border-red-500'
: 'border-gray-300 hover:border-red-500'
}
shadow-sm
hover:shadow-md
${className}
`}
aria-label={tooltipText}
>
<Heart
size={iconSizes[size]}
className={`
transition-all
duration-200
${
favorited
? 'fill-red-500 text-red-500'
: 'text-gray-400'
}
${isProcessing ? 'animate-pulse' : ''}
`}
/>
</button>
{/* Tooltip */}
{showTooltip && showTooltipText && (
<div
className="absolute bottom-full left-1/2
-translate-x-1/2 mb-2 px-3 py-1
bg-gray-900 text-white text-xs
rounded-lg whitespace-nowrap
pointer-events-none z-50
animate-fade-in"
>
{tooltipText}
<div
className="absolute top-full left-1/2
-translate-x-1/2 -mt-1
border-4 border-transparent
border-t-gray-900"
/>
</div>
)}
</div>
);
};
export default FavoriteButton;

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange?: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const handlePageChange = (page: number) => {
if (page < 1 || page > totalPages) return;
// Update URL params
const newParams = new URLSearchParams(searchParams);
newParams.set('page', String(page));
setSearchParams(newParams);
// Callback
onPageChange?.(page);
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Generate page numbers to show
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 7; // Max page buttons to show
if (totalPages <= maxVisible) {
// Show all pages if total is small
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Show current page and neighbors
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
if (totalPages <= 1) {
return null;
}
return (
<div className="flex justify-center items-center gap-2 mt-8">
{/* Previous Button */}
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 border border-gray-300
rounded-lg hover:bg-gray-100
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Previous page"
>
<ChevronLeft size={18} />
</button>
{/* Page Numbers */}
{getPageNumbers().map((page, index) => {
if (page === '...') {
return (
<span
key={`ellipsis-${index}`}
className="px-3 py-2 text-gray-500"
>
...
</span>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`px-4 py-2 rounded-lg transition-colors
font-medium ${
isActive
? 'bg-blue-600 text-white'
: 'border border-gray-300 hover:bg-gray-100 text-gray-700'
}`}
aria-label={`Page ${pageNum}`}
aria-current={isActive ? 'page' : undefined}
>
{pageNum}
</button>
);
})}
{/* Next Button */}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 border border-gray-300
rounded-lg hover:bg-gray-100
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Next page"
>
<ChevronRight size={18} />
</button>
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { Star } from 'lucide-react';
interface RatingStarsProps {
rating: number;
maxRating?: number;
size?: 'sm' | 'md' | 'lg';
showNumber?: boolean;
interactive?: boolean;
onRatingChange?: (rating: number) => void;
}
const RatingStars: React.FC<RatingStarsProps> = ({
rating,
maxRating = 5,
size = 'md',
showNumber = false,
interactive = false,
onRatingChange,
}) => {
const [hoveredRating, setHoveredRating] =
React.useState<number | null>(null);
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
};
const handleClick = (value: number) => {
if (interactive && onRatingChange) {
onRatingChange(value);
}
};
const handleMouseEnter = (value: number) => {
if (interactive) {
setHoveredRating(value);
}
};
const handleMouseLeave = () => {
if (interactive) {
setHoveredRating(null);
}
};
const displayRating = hoveredRating ?? rating;
return (
<div className="flex items-center gap-1">
{Array.from({ length: maxRating }, (_, index) => {
const starValue = index + 1;
const isFilled = starValue <= displayRating;
return (
<button
key={index}
type="button"
onClick={() => handleClick(starValue)}
onMouseEnter={() => handleMouseEnter(starValue)}
onMouseLeave={handleMouseLeave}
disabled={!interactive}
className={`${
interactive
? 'cursor-pointer hover:scale-110 transition-transform'
: 'cursor-default'
}`}
aria-label={`${starValue} star${starValue > 1 ? 's' : ''}`}
>
<Star
className={`${sizeClasses[size]} ${
isFilled
? 'text-yellow-500 fill-yellow-500'
: 'text-gray-300'
}`}
/>
</button>
);
})}
{showNumber && (
<span className="ml-2 text-sm font-semibold text-gray-700">
{rating.toFixed(1)}
</span>
)}
</div>
);
};
export default RatingStars;

View File

@@ -0,0 +1,312 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { toast } from 'react-toastify';
import RatingStars from './RatingStars';
import {
getRoomReviews,
createReview,
type Review,
} from '../../services/api/reviewService';
import useAuthStore from '../../store/useAuthStore';
interface ReviewSectionProps {
roomId: number;
}
const reviewSchema = yup.object({
rating: yup
.number()
.min(1, 'Please select a rating')
.max(5)
.required('Please provide a rating'),
comment: yup
.string()
.min(10, 'Comment must be at least 10 characters')
.max(500, 'Comment must not exceed 500 characters')
.required('Please enter a comment'),
});
type ReviewFormData = {
rating: number;
comment: string;
};
const ReviewSection: React.FC<ReviewSectionProps> = ({
roomId
}) => {
const { isAuthenticated } = useAuthStore();
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [averageRating, setAverageRating] = useState<number>(0);
const [totalReviews, setTotalReviews] = useState<number>(0);
const {
register,
handleSubmit,
formState: { errors },
setValue,
watch,
reset,
} = useForm<ReviewFormData>({
resolver: yupResolver(reviewSchema),
defaultValues: {
rating: 0,
comment: '',
},
});
const rating = watch('rating');
useEffect(() => {
fetchReviews();
}, [roomId]);
const fetchReviews = async () => {
try {
setLoading(true);
const response = await getRoomReviews(roomId);
if (response.status === 'success' && response.data) {
setReviews(response.data.reviews || []);
setAverageRating(response.data.average_rating || 0);
setTotalReviews(response.data.total_reviews || 0);
}
} catch (error) {
console.error('Error fetching reviews:', error);
toast.error('Unable to load reviews');
} finally {
setLoading(false);
}
};
const onSubmit = async (data: ReviewFormData) => {
if (!isAuthenticated) {
toast.error('Please login to review');
return;
}
try {
setSubmitting(true);
const response = await createReview({
room_id: roomId,
rating: data.rating,
comment: data.comment,
});
if (response.success) {
toast.success(
'Your review has been submitted and is pending approval'
);
reset();
fetchReviews();
}
} catch (error: any) {
const message =
error.response?.data?.message ||
'Unable to submit review';
toast.error(message);
} finally {
setSubmitting(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<div className="space-y-8">
{/* Rating Summary */}
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-2xl font-bold text-gray-900 mb-4">
Customer Reviews
</h3>
<div className="flex items-center gap-6">
<div className="text-center">
<div className="text-5xl font-bold text-gray-900">
{averageRating > 0
? averageRating.toFixed(1)
: 'N/A'}
</div>
<RatingStars
rating={averageRating}
size="md"
/>
<div className="text-sm text-gray-600 mt-2">
{totalReviews} review{totalReviews !== 1 ? 's' : ''}
</div>
</div>
</div>
</div>
{/* Review Form */}
{isAuthenticated ? (
<div className="bg-white rounded-lg shadow-md p-6">
<h4 className="text-xl font-semibold mb-4">
Write Your Review
</h4>
<form onSubmit={handleSubmit(onSubmit)}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium
text-gray-700 mb-2"
>
Your Rating
</label>
<RatingStars
rating={rating}
size="lg"
interactive
onRatingChange={(value) =>
setValue('rating', value)
}
/>
{errors.rating && (
<p className="text-red-600 text-sm mt-1">
{errors.rating.message}
</p>
)}
</div>
<div>
<label
htmlFor="comment"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Comment
</label>
<textarea
{...register('comment')}
id="comment"
rows={4}
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
placeholder="Share your experience..."
/>
{errors.comment && (
<p className="text-red-600 text-sm mt-1">
{errors.comment.message}
</p>
)}
</div>
<button
type="submit"
disabled={submitting}
className="px-6 py-3 bg-blue-600 text-white
rounded-lg hover:bg-blue-700
disabled:bg-gray-400
disabled:cursor-not-allowed
transition-colors font-medium"
>
{submitting ? 'Submitting...' : 'Submit Review'}
</button>
</form>
</div>
) : (
<div className="bg-blue-50 border border-blue-200
rounded-lg p-6 text-center"
>
<p className="text-blue-800">
Please{' '}
<a
href="/login"
className="font-semibold underline
hover:text-blue-900"
>
login
</a>{' '}
to write a review
</p>
</div>
)}
{/* Reviews List */}
<div>
<h4 className="text-xl font-semibold mb-6">
All Reviews ({totalReviews})
</h4>
{loading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="bg-gray-100 rounded-lg p-6
animate-pulse"
>
<div className="h-4 bg-gray-300
rounded w-1/4 mb-2"
/>
<div className="h-4 bg-gray-300
rounded w-3/4"
/>
</div>
))}
</div>
) : reviews.length === 0 ? (
<div className="text-center py-12 bg-gray-50
rounded-lg"
>
<p className="text-gray-600 text-lg">
No reviews yet
</p>
<p className="text-gray-500 text-sm mt-2">
Be the first to review this room!
</p>
</div>
) : (
<div className="space-y-4">
{reviews.map((review) => (
<div
key={review.id}
className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-start
justify-between mb-3"
>
<div>
<h5 className="font-semibold
text-gray-900"
>
{review.user?.full_name || 'Guest'}
</h5>
<div className="flex items-center
gap-2 mt-1"
>
<RatingStars
rating={review.rating}
size="sm"
/>
<span className="text-sm
text-gray-500"
>
{formatDate(review.created_at)}
</span>
</div>
</div>
</div>
<p className="text-gray-700 leading-relaxed">
{review.comment}
</p>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ReviewSection;

View File

@@ -0,0 +1,217 @@
import React from 'react';
import {
Wifi,
Tv,
Wind,
Coffee,
Utensils,
Car,
Dumbbell,
Waves,
UtensilsCrossed,
Shield,
Cigarette,
Bath,
} from 'lucide-react';
interface RoomAmenitiesProps {
amenities: string[];
}
const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
amenities
}) => {
const normalizeAmenities = (input: any): string[] => {
if (Array.isArray(input)) return input;
if (!input) return [];
if (typeof input === 'string') {
// Try JSON.parse first (stringified JSON)
try {
const parsed = JSON.parse(input);
if (Array.isArray(parsed)) return parsed;
} catch (e) {
// ignore
}
// Fallback: comma separated list
return input
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
// If it's an object with values as amenities
if (typeof input === 'object') {
try {
// Convert object values to array if possible
const vals = Object.values(input);
if (Array.isArray(vals) && vals.length > 0) {
// flatten nested arrays
return vals.flat().map((v: any) => String(v).trim()).filter(Boolean);
}
} catch (e) {
// ignore
}
}
return [];
};
const safeAmenities = normalizeAmenities(amenities);
// Icon mapping for common amenities
const amenityIcons: Record<string, React.ReactNode> = {
wifi: <Wifi className="w-5 h-5" />,
'wi-fi': <Wifi className="w-5 h-5" />,
tv: <Tv className="w-5 h-5" />,
television: <Tv className="w-5 h-5" />,
'air-conditioning': <Wind className="w-5 h-5" />,
'air conditioning': <Wind className="w-5 h-5" />,
ac: <Wind className="w-5 h-5" />,
'mini bar': <Coffee className="w-5 h-5" />,
minibar: <Coffee className="w-5 h-5" />,
restaurant: <Utensils className="w-5 h-5" />,
parking: <Car className="w-5 h-5" />,
gym: <Dumbbell className="w-5 h-5" />,
fitness: <Dumbbell className="w-5 h-5" />,
pool: <Waves className="w-5 h-5" />,
'swimming pool': <Waves className="w-5 h-5" />,
'room service': <UtensilsCrossed className="w-5 h-5" />,
safe: <Shield className="w-5 h-5" />,
'no smoking': <Cigarette className="w-5 h-5" />,
bathtub: <Bath className="w-5 h-5" />,
shower: <Bath className="w-5 h-5" />,
breakfast: <Coffee className="w-5 h-5" />,
'breakfast included': <Coffee className="w-5 h-5" />,
kettle: <Coffee className="w-5 h-5" />,
'hair dryer': <Shield className="w-5 h-5" />,
hairdryer: <Shield className="w-5 h-5" />,
iron: <Shield className="w-5 h-5" />,
fridge: <Utensils className="w-5 h-5" />,
microwave: <Utensils className="w-5 h-5" />,
'private bathroom': <Bath className="w-5 h-5" />,
balcony: <Wind className="w-5 h-5" />,
'24-hour front desk': <Shield className="w-5 h-5" />,
'front desk': <Shield className="w-5 h-5" />,
spa: <Waves className="w-5 h-5" />,
sauna: <Waves className="w-5 h-5" />,
jacuzzi: <Waves className="w-5 h-5" />,
'airport shuttle': <Car className="w-5 h-5" />,
shuttle: <Car className="w-5 h-5" />,
laundry: <Shield className="w-5 h-5" />,
pets: <Car className="w-5 h-5" />,
};
const amenityLabels: Record<string, string> = {
wifi: 'WiFi',
tv: 'TV',
ac: 'Air Conditioning',
'air-conditioning': 'Air Conditioning',
minibar: 'Mini Bar',
'mini bar': 'Mini Bar',
restaurant: 'Restaurant',
parking: 'Parking',
gym: 'Gym',
pool: 'Swimming Pool',
'room service': 'Room Service',
safe: 'Safe',
'no smoking': 'No Smoking',
bathtub: 'Bathtub',
shower: 'Shower',
breakfast: 'Breakfast Included',
kettle: 'Electric Kettle',
hairdryer: 'Hair Dryer',
iron: 'Iron',
fridge: 'Refrigerator',
microwave: 'Microwave',
'private bathroom': 'Private Bathroom',
balcony: 'Balcony',
spa: 'Spa',
sauna: 'Sauna',
jacuzzi: 'Jacuzzi',
laundry: 'Laundry Service',
'24-hour front desk': '24/7 Front Desk',
'airport shuttle': 'Airport Shuttle',
pets: 'Pets Allowed',
};
const amenityDescriptions: Record<string, string> = {
wifi: 'Free wireless internet connection',
tv: 'TV with cable or satellite',
ac: 'Air conditioning system in room',
minibar: 'Drinks and snacks in mini bar',
pool: 'Outdoor or indoor swimming pool',
gym: 'Fitness center/gym',
'room service': 'Order food to room',
breakfast: 'Breakfast served at restaurant',
balcony: 'Private balcony with view',
'24-hour front desk': '24-hour front desk service',
spa: 'Spa and relaxation services',
};
const getIcon = (amenity: string) => {
const key = amenity.toLowerCase().trim();
return amenityIcons[key] || (
<span className="w-5 h-5 flex items-center
justify-center text-blue-600 font-bold"
>
</span>
);
};
const getLabel = (amenity: string) => {
const key = amenity.toLowerCase().trim();
if (amenityLabels[key]) return amenityLabels[key];
// Fallback: capitalize words and replace dashes/underscores
return amenity
.toLowerCase()
.replace(/[_-]/g, ' ')
.split(' ')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(' ');
};
const getDescription = (amenity: string) => {
const key = amenity.toLowerCase().trim();
return amenityDescriptions[key] || '';
};
if (safeAmenities.length === 0) {
return (
<div className="text-gray-500 text-center py-4">
Amenity information is being updated
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-4"
>
{safeAmenities.slice(0, 10).map((amenity, index) => (
<div
key={index}
className="flex items-center gap-3 p-3
bg-gray-50 rounded-lg hover:bg-gray-100
transition-colors"
title={getDescription(amenity)}
>
<div className="text-blue-600">{getIcon(amenity)}</div>
<div>
<div className="text-gray-800 font-medium">
{getLabel(amenity)}
</div>
{getDescription(amenity) && (
<div className="text-xs text-gray-500">
{getDescription(amenity)}
</div>
)}
</div>
</div>
))}
</div>
);
};
export default RoomAmenities;

View File

@@ -0,0 +1,222 @@
import React from 'react';
import { Link } from 'react-router-dom';
import {
Users,
Star,
MapPin,
Wifi,
Tv,
Wind,
ArrowRight,
} from 'lucide-react';
import type { Room } from '../../services/api/roomService';
import FavoriteButton from './FavoriteButton';
interface RoomCardProps {
room: Room;
}
const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
const roomType = room.room_type;
if (!roomType) {
return null;
}
// Get first image or use placeholder
const imageUrl = roomType.images?.[0] ||
'/images/room-placeholder.jpg';
// Format price
const formattedPrice = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(roomType.base_price);
// Prefer room-level amenities when available, otherwise use room type
const normalizeAmenities = (input: any): string[] => {
if (Array.isArray(input)) return input;
if (!input) return [];
if (typeof input === 'string') {
try {
const parsed = JSON.parse(input);
if (Array.isArray(parsed)) return parsed;
} catch {}
return input.split(',').map((s) => s.trim()).filter(Boolean);
}
if (typeof input === 'object') {
try {
const vals = Object.values(input);
if (Array.isArray(vals) && vals.length > 0) return vals.flat().map((v: any) => String(v).trim());
} catch {}
}
return [];
};
const amenitiesSource =
(room.amenities && normalizeAmenities(room.amenities).length > 0)
? normalizeAmenities(room.amenities)
: normalizeAmenities(roomType.amenities);
// Get amenities (limit to 3 for display)
const amenities = amenitiesSource.slice(0, 3);
// Amenity icons mapping
const amenityIcons: Record<string, React.ReactNode> = {
wifi: <Wifi className="w-4 h-4" />,
tv: <Tv className="w-4 h-4" />,
'air-conditioning': <Wind className="w-4 h-4" />,
};
return (
<div
className="bg-white rounded-lg shadow-md
overflow-hidden hover:shadow-xl
transition-shadow duration-300 group"
>
{/* Image */}
<div className="relative h-48 overflow-hidden
bg-gray-200"
>
<img
src={imageUrl}
alt={roomType.name}
loading="lazy"
className="w-full h-full object-cover
group-hover:scale-110 transition-transform
duration-300"
onLoad={(e) =>
e.currentTarget.classList.add('loaded')
}
/>
{/* Favorite Button */}
<div className="absolute top-3 right-3 z-5">
<FavoriteButton roomId={room.id} size="md" />
</div>
{/* Featured Badge */}
{room.featured && (
<div
className="absolute top-3 left-3
bg-yellow-500 text-white px-3 py-1
rounded-full text-xs font-semibold"
>
Featured
</div>
)}
{/* Status Badge */}
<div
className={`absolute bottom-3 left-3 px-3 py-1
rounded-full text-xs font-semibold
${
room.status === 'available'
? 'bg-green-500 text-white'
: room.status === 'occupied'
? 'bg-red-500 text-white'
: 'bg-gray-500 text-white'
}`}
>
{room.status === 'available'
? 'Available'
: room.status === 'occupied'
? 'Occupied'
: 'Maintenance'}
</div>
</div>
{/* Content */}
<div className="p-5">
{/* Room Type Name */}
<h3 className="text-xl font-bold text-gray-900 mb-2">
{roomType.name}
</h3>
{/* Room Number & Floor */}
<div
className="flex items-center text-sm
text-gray-600 mb-3"
>
<MapPin className="w-4 h-4 mr-1" />
<span>
Room {room.room_number} - Floor {room.floor}
</span>
</div>
{/* Description (truncated) */}
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
{roomType.description}
</p>
{/* Capacity & Rating */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center text-gray-700">
<Users className="w-4 h-4 mr-1" />
<span className="text-sm">
{roomType.capacity} guests
</span>
</div>
{room.average_rating != null && (
<div className="flex items-center">
<Star
className="w-4 h-4 text-yellow-500 mr-1"
fill="currentColor"
/>
<span className="text-sm font-semibold text-gray-900">
{Number(room.average_rating).toFixed(1)}
</span>
<span className="text-xs text-gray-500 ml-1">
({Number(room.total_reviews || 0)})
</span>
</div>
)}
</div>
{/* Amenities */}
{amenities.length > 0 && (
<div className="flex items-center gap-2 mb-4">
{amenities.map((amenity, index) => (
<div
key={index}
className="flex items-center gap-1
text-gray-600 text-xs bg-gray-100
px-2 py-1 rounded"
title={amenity}
>
{amenityIcons[amenity.toLowerCase()] ||
<span></span>}
<span className="capitalize">{amenity}</span>
</div>
))}
</div>
)}
{/* Price & Action */}
<div className="flex items-center justify-between pt-3 border-t">
<div>
<p className="text-xs text-gray-500">From</p>
<p className="text-xl font-bold text-indigo-600">
{formattedPrice}
</p>
<p className="text-xs text-gray-500">/ night</p>
</div>
<Link
to={`/rooms/${room.id}`}
className="flex items-center gap-1
bg-indigo-600 text-white px-4 py-2
rounded-lg hover:bg-indigo-700
transition-colors text-sm font-medium"
>
View Details
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
);
};
export default RoomCard;

View File

@@ -0,0 +1,56 @@
import React from 'react';
const RoomCardSkeleton: React.FC = () => {
return (
<div
className="bg-white rounded-lg shadow-md
overflow-hidden animate-pulse"
>
{/* Image Skeleton */}
<div className="h-48 bg-gray-300" />
{/* Content Skeleton */}
<div className="p-5">
{/* Title */}
<div className="h-6 bg-gray-300 rounded w-3/4 mb-2" />
{/* Room Number */}
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
{/* Description */}
<div className="space-y-2 mb-3">
<div className="h-3 bg-gray-200 rounded w-full" />
<div className="h-3 bg-gray-200 rounded w-5/6" />
</div>
{/* Capacity & Rating */}
<div className="flex items-center justify-between mb-3">
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="h-4 bg-gray-200 rounded w-16" />
</div>
{/* Amenities */}
<div className="flex gap-2 mb-4">
<div className="h-6 bg-gray-200 rounded w-16" />
<div className="h-6 bg-gray-200 rounded w-16" />
<div className="h-6 bg-gray-200 rounded w-16" />
</div>
{/* Price & Button */}
<div
className="flex items-center justify-between
pt-3 border-t"
>
<div>
<div className="h-3 bg-gray-200 rounded w-12 mb-1" />
<div className="h-7 bg-gray-300 rounded w-24 mb-1" />
<div className="h-3 bg-gray-200 rounded w-10" />
</div>
<div className="h-10 bg-gray-300 rounded w-28" />
</div>
</div>
</div>
);
};
export default RoomCardSkeleton;

View File

@@ -0,0 +1,444 @@
import React, { useState, useEffect } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { useSearchParams } from 'react-router-dom';
// no debounce needed when apply-on-submit is used
interface RoomFilterProps {
onFilterChange?: (filters: FilterValues) => void;
}
export interface FilterValues {
type?: string;
minPrice?: number;
maxPrice?: number;
capacity?: number;
from?: string;
to?: string;
}
const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState<FilterValues>({
type: searchParams.get('type') || '',
minPrice: searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined,
maxPrice: searchParams.get('maxPrice')
? Number(searchParams.get('maxPrice'))
: undefined,
capacity: searchParams.get('capacity')
? Number(searchParams.get('capacity'))
: undefined,
from: searchParams.get('from') || undefined,
to: searchParams.get('to') || undefined,
});
const [availableAmenities, setAvailableAmenities] = useState<string[]>([]);
const [selectedAmenities, setSelectedAmenities] = useState<string[]>(
searchParams.get('amenities')
? searchParams.get('amenities')!.split(',').map((s) => s.trim())
: []
);
const [checkInDate, setCheckInDate] = useState<Date | null>(
searchParams.get('from') ? new Date(searchParams.get('from')!) : null
);
const [checkOutDate, setCheckOutDate] = useState<Date | null>(
searchParams.get('to') ? new Date(searchParams.get('to')!) : null
);
// no debounce needed — apply on submit
// Sync filters with URL on mount and URL changes
useEffect(() => {
const type = searchParams.get('type') || '';
const minPrice = searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined;
const maxPrice = searchParams.get('maxPrice')
? Number(searchParams.get('maxPrice'))
: undefined;
const capacity = searchParams.get('capacity')
? Number(searchParams.get('capacity'))
: undefined;
const from = searchParams.get('from') || undefined;
const to = searchParams.get('to') || undefined;
setFilters({ type, minPrice, maxPrice, capacity, from, to });
// Sync local date state
setCheckInDate(from ? new Date(from) : null);
setCheckOutDate(to ? new Date(to) : null);
}, [searchParams]);
// Load amenities from API
useEffect(() => {
let mounted = true;
import('../../services/api/roomService').then((mod) => {
mod.getAmenities().then((res) => {
const list = res.data?.amenities || [];
if (mounted) setAvailableAmenities(list.slice(0, 8));
}).catch(() => {});
});
return () => {
mounted = false;
};
}, []);
const parseCurrency = (value: string): number | undefined => {
const digits = value.replace(/\D/g, '');
if (!digits) return undefined;
try {
return Number(digits);
} catch {
return undefined;
}
};
const formatCurrency = (n?: number): string => {
if (n == null) return '';
return new Intl.NumberFormat('en-US').format(n);
};
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
// Room type select
if (name === 'type') {
setFilters((prev) => ({ ...prev, type: value || '' }));
return;
}
// Capacity input
if (name === 'capacity') {
setFilters((prev) => ({
...prev,
capacity: value === '' ? undefined : Number(value),
}));
return;
}
// Price inputs: allow formatted VN style with dots
if (name === 'minPrice' || name === 'maxPrice') {
const parsed = parseCurrency(value);
setFilters((prev) => ({ ...prev, [name]: parsed }));
return;
}
// Fallback numeric parsing
setFilters((prev) => ({
...prev,
[name]: value === '' ? undefined : Number(value),
}));
};
const formatDate = (d: Date) => d.toISOString().split('T')[0];
// Filters are applied only when user clicks "Áp dụng".
// Debounced values are kept for UX but won't auto-submit.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Build new search params
const newParams = new URLSearchParams(searchParams);
// Reset page to 1 when filters change
newParams.set('page', '1');
// Update search params with filter values
if (filters.type) {
newParams.set('type', filters.type);
} else {
newParams.delete('type');
}
if (filters.minPrice !== undefined && filters.minPrice > 0) {
newParams.set('minPrice', String(filters.minPrice));
} else {
newParams.delete('minPrice');
}
if (filters.maxPrice !== undefined && filters.maxPrice > 0) {
newParams.set('maxPrice', String(filters.maxPrice));
} else {
newParams.delete('maxPrice');
}
if (filters.capacity !== undefined && filters.capacity > 0) {
newParams.set('capacity', String(filters.capacity));
} else {
newParams.delete('capacity');
}
// Dates
if (checkInDate) {
newParams.set('from', formatDate(checkInDate));
} else {
newParams.delete('from');
}
if (checkOutDate) {
newParams.set('to', formatDate(checkOutDate));
} else {
newParams.delete('to');
}
// Amenities
if (selectedAmenities.length > 0) {
newParams.set('amenities', selectedAmenities.join(','));
} else {
newParams.delete('amenities');
}
setSearchParams(newParams);
onFilterChange?.({
...filters,
from: checkInDate ? formatDate(checkInDate) : undefined,
to: checkOutDate ? formatDate(checkOutDate) : undefined,
// include amenities
...(selectedAmenities.length > 0 ? { amenities: selectedAmenities.join(',') } : {}),
});
};
const handleReset = () => {
setFilters({
type: '',
minPrice: undefined,
maxPrice: undefined,
capacity: undefined,
from: undefined,
to: undefined,
});
setCheckInDate(null);
setCheckOutDate(null);
setSelectedAmenities([]);
// Reset URL params but keep the base /rooms path
setSearchParams({});
onFilterChange?.({});
};
const toggleAmenity = (amenity: string) => {
setSelectedAmenities((prev) => {
if (prev.includes(amenity)) return prev.filter((a) => a !== amenity);
return [...prev, amenity];
});
};
return (
<div className="bg-white rounded-lg shadow-md p-4 mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-800">
Room Filters
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Room Type */}
<div>
<label
htmlFor="type"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Room Type
</label>
<select
id="type"
name="type"
value={filters.type || ''}
onChange={handleInputChange}
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-blue-500
focus:border-transparent"
>
<option value="">All</option>
<option value="Standard Room">Standard Room</option>
<option value="Deluxe Room">Deluxe Room</option>
<option value="Luxury Room">Luxury Room</option>
<option value="Family Room">Family Room</option>
<option value="Twin Room">Twin Room</option>
</select>
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="from"
className="block text-sm font-medium text-gray-700 mb-1"
>
Check-in Date
</label>
<DatePicker
selected={checkInDate}
onChange={(date: Date | null) => setCheckInDate(date)}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
minDate={new Date()}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label
htmlFor="to"
className="block text-sm font-medium text-gray-700 mb-1"
>
Check-out Date
</label>
<DatePicker
selected={checkOutDate}
onChange={(date: Date | null) => setCheckOutDate(date)}
selectsEnd
startDate={checkInDate}
endDate={checkOutDate}
minDate={checkInDate || new Date()}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Price Range */}
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="minPrice"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Min Price
</label>
<input
type="text"
id="minPrice"
name="minPrice"
value={
filters.minPrice != null
? formatCurrency(filters.minPrice)
: ''
}
onChange={handleInputChange}
placeholder="0"
inputMode="numeric"
pattern="[0-9.]*"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
/>
</div>
<div>
<label
htmlFor="maxPrice"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Max Price
</label>
<input
type="text"
id="maxPrice"
name="maxPrice"
value={
filters.maxPrice != null
? formatCurrency(filters.maxPrice)
: ''
}
onChange={handleInputChange}
placeholder="10.000.000"
inputMode="numeric"
pattern="[0-9.]*"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
/>
</div>
</div>
{/* Capacity */}
<div>
<label
htmlFor="capacity"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Number of Guests
</label>
<input
type="number"
id="capacity"
name="capacity"
value={filters.capacity || ''}
onChange={handleInputChange}
placeholder="1"
min="1"
max="10"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
/>
</div>
{/* Amenities */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Amenities
</label>
{availableAmenities.length === 0 ? (
<div className="text-sm text-gray-500">Loading amenities...</div>
) : (
<div className="flex flex-col gap-2 max-h-40 overflow-auto pr-2">
{availableAmenities.map((amenity) => (
<label
key={amenity}
className="flex items-center gap-2 text-sm w-full"
>
<input
type="checkbox"
checked={selectedAmenities.includes(amenity)}
onChange={() => toggleAmenity(amenity)}
className="h-4 w-4"
/>
<span className="text-gray-700">{amenity}</span>
</label>
))}
</div>
)}
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
type="submit"
className="flex-1 bg-blue-600 text-white
py-2 px-4 rounded-lg hover:bg-blue-700
transition-colors font-medium"
>
Apply
</button>
<button
type="button"
onClick={handleReset}
className="flex-1 bg-gray-200 text-gray-700
py-2 px-4 rounded-lg hover:bg-gray-300
transition-colors font-medium"
>
Reset
</button>
</div>
</form>
</div>
);
};
export default RoomFilter;

View File

@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
interface RoomGalleryProps {
images: string[];
roomName: string;
}
const RoomGallery: React.FC<RoomGalleryProps> = ({
images,
roomName
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isModalOpen, setIsModalOpen] = useState(false);
const safeImages = Array.isArray(images) && images.length > 0
? images
: ['/images/room-placeholder.jpg'];
const goToPrevious = () => {
setCurrentIndex((prev) =>
prev === 0 ? safeImages.length - 1 : prev - 1
);
};
const goToNext = () => {
setCurrentIndex((prev) =>
prev === safeImages.length - 1 ? 0 : prev + 1
);
};
const openModal = (index: number) => {
setCurrentIndex(index);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
return (
<>
{/* Main Gallery */}
<div className="grid grid-cols-4 gap-2 h-96">
{/* Main Image */}
<div
className="col-span-4 md:col-span-3 relative
overflow-hidden rounded-lg cursor-pointer
group"
onClick={() => openModal(0)}
>
<img
src={safeImages[0]}
alt={`${roomName} - Main`}
className="w-full h-full object-cover
transition-transform duration-300
group-hover:scale-110"
/>
<div
className="absolute inset-0 bg-black
bg-opacity-0 group-hover:bg-opacity-20
transition-all duration-300
flex items-center justify-center"
>
<span
className="text-white font-medium
opacity-0 group-hover:opacity-100
transition-opacity"
>
Xem nh lớn
</span>
</div>
</div>
{/* Thumbnail Grid */}
<div
className="hidden md:flex flex-col gap-2
col-span-1"
>
{safeImages.slice(1, 4).map((image, index) => (
<div
key={index + 1}
className="relative overflow-hidden
rounded-lg cursor-pointer group
flex-1"
onClick={() => openModal(index + 1)}
>
<img
src={image}
alt={`${roomName} - ${index + 2}`}
className="w-full h-full object-cover
transition-transform duration-300
group-hover:scale-110"
/>
{index === 2 && safeImages.length > 4 && (
<div
className="absolute inset-0 bg-black
bg-opacity-60 flex items-center
justify-center"
>
<span className="text-white font-semibold">
+{safeImages.length - 4} nh
</span>
</div>
)}
</div>
))}
</div>
</div>
{/* Modal Lightbox */}
{isModalOpen && (
<div
className="fixed inset-0 z-50 bg-black
bg-opacity-90 flex items-center
justify-center"
onClick={closeModal}
>
<button
onClick={closeModal}
className="absolute top-4 right-4
text-white hover:text-gray-300
transition-colors z-10"
aria-label="Close"
>
<X className="w-8 h-8" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
className="absolute left-4 text-white
hover:text-gray-300 transition-colors
z-10"
aria-label="Previous image"
>
<ChevronLeft className="w-12 h-12" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
className="absolute right-4 text-white
hover:text-gray-300 transition-colors
z-10"
aria-label="Next image"
>
<ChevronRight className="w-12 h-12" />
</button>
<div
className="max-w-6xl max-h-[90vh] relative"
onClick={(e) => e.stopPropagation()}
>
<img
src={safeImages[currentIndex]}
alt={`${roomName} - ${currentIndex + 1}`}
className="max-w-full max-h-[90vh]
object-contain"
/>
<div
className="absolute bottom-4 left-1/2
transform -translate-x-1/2
bg-black bg-opacity-50
text-white px-4 py-2 rounded-full"
>
{currentIndex + 1} / {safeImages.length}
</div>
</div>
</div>
)}
</>
);
};
export default RoomGallery;

View File

@@ -0,0 +1,178 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { Search } from 'lucide-react';
import { toast } from 'react-toastify';
interface SearchRoomFormProps {
className?: string;
}
const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
className = '',
}) => {
const navigate = useNavigate();
const [checkInDate, setCheckInDate] = useState<Date | null>(null);
const [checkOutDate, setCheckOutDate] = useState<Date | null>(null);
const [roomType, setRoomType] = useState('');
const [guestCount, setGuestCount] = useState<number>(1);
const [isSearching, setIsSearching] = useState(false);
// Set minimum date to today
const today = new Date();
today.setHours(0, 0, 0, 0);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!checkInDate) {
toast.error('Please select check-in date');
return;
}
if (!checkOutDate) {
toast.error('Please select check-out date');
return;
}
// Check if check-in is not in the past
const checkInStart = new Date(checkInDate);
checkInStart.setHours(0, 0, 0, 0);
if (checkInStart < today) {
toast.error(
'Check-in date cannot be in the past'
);
return;
}
// Check if check-out is after check-in
if (checkOutDate <= checkInDate) {
toast.error(
'Check-out date must be after check-in date'
);
return;
}
// Format dates to YYYY-MM-DD
const formatDate = (date: Date) => {
return date.toISOString().split('T')[0];
};
// Build search params
const params = new URLSearchParams({
from: formatDate(checkInDate),
to: formatDate(checkOutDate),
});
if (roomType.trim()) {
params.append('type', roomType.trim());
}
// Append guest count (capacity)
if (guestCount && guestCount > 0) {
params.append('capacity', String(guestCount));
}
// Navigate to search results
setIsSearching(true);
navigate(`/rooms/search?${params.toString()}`);
};
// Reset helper (kept for potential future use)
// const handleReset = () => {
// setCheckInDate(null);
// setCheckOutDate(null);
// setRoomType('');
// setGuestCount(1);
// };
return (
<div className={`w-full bg-white rounded-lg shadow-sm p-4 ${className}`}>
<div className="flex items-center justify-center gap-3 mb-6">
<h3 className="text-xl font-bold text-gray-900">
Find Available Rooms
</h3>
</div>
<form onSubmit={handleSearch}>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-center">
<div className="md:col-span-3">
<label className="sr-only">Check-in Date</label>
<DatePicker
selected={checkInDate}
onChange={(date) => setCheckInDate(date)}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
minDate={today}
placeholderText="Check-in Date"
dateFormat="dd/MM"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
</div>
<div className="md:col-span-3">
<label className="sr-only">Check-out Date</label>
<DatePicker
selected={checkOutDate}
onChange={(date) => setCheckOutDate(date)}
selectsEnd
startDate={checkInDate}
endDate={checkOutDate}
minDate={checkInDate || today}
placeholderText="Check-out Date"
dateFormat="dd/MM"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="sr-only">Room Type</label>
<select
value={roomType}
onChange={(e) => setRoomType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="">All Types</option>
<option value="Standard Room">Standard Room</option>
<option value="Deluxe Room">Deluxe Room</option>
<option value="Luxury Room">Luxury Room</option>
<option value="Family Room">Family Room</option>
<option value="Twin Room">Twin Room</option>
</select>
</div>
<div className="md:col-span-2">
<label className="sr-only">Number of Guests</label>
<select
value={guestCount}
onChange={(e) => setGuestCount(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
{Array.from({ length: 6 }).map((_, i) => (
<option key={i} value={i + 1}>{i + 1} guest{i !== 0 ? 's' : ''}</option>
))}
</select>
</div>
<div className="md:col-span-2 flex items-center mt-3 md:mt-0">
<button
type="submit"
disabled={isSearching}
className="w-full bg-indigo-600 text-white px-3 py-2 rounded-md text-sm hover:bg-indigo-700 disabled:bg-gray-400"
>
<span className="inline-flex items-center gap-2 justify-center w-full">
<Search className="w-4 h-4" />
{isSearching ? 'Searching...' : 'Search Rooms'}
</span>
</button>
</div>
</div>
</form>
</div>
);
};
export default SearchRoomForm;

View File

@@ -0,0 +1,12 @@
export { default as RoomCard } from './RoomCard';
export { default as RoomCardSkeleton } from './RoomCardSkeleton';
export { default as BannerCarousel } from './BannerCarousel';
export { default as BannerSkeleton } from './BannerSkeleton';
export { default as RoomFilter } from './RoomFilter';
export { default as Pagination } from './Pagination';
export { default as RoomGallery } from './RoomGallery';
export { default as RoomAmenities } from './RoomAmenities';
export { default as RatingStars } from './RatingStars';
export { default as ReviewSection } from './ReviewSection';
export { default as SearchRoomForm } from './SearchRoomForm';
export { default as FavoriteButton } from './FavoriteButton';

View File

@@ -0,0 +1,277 @@
/**
* Example: Cách sử dụng useAuthStore trong components
*
* File này chỉ để tham khảo, không được sử dụng
* trong production
*/
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../store/useAuthStore';
// ============================================
// Example 1: Login Component
// ============================================
export const LoginExample = () => {
const { login, isLoading, error } = useAuthStore();
const navigate = useNavigate();
const handleLogin = async (
email: string,
password: string
) => {
try {
await login({ email, password });
navigate('/dashboard');
} catch (error) {
// Error đã được xử lý trong store
console.error('Login failed:', error);
}
};
return (
<div>
{error && <p className="text-red-500">{error}</p>}
<button
onClick={() => handleLogin(
'user@example.com',
'password123'
)}
disabled={isLoading}
>
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
</button>
</div>
);
};
// ============================================
// Example 2: Register Component
// ============================================
export const RegisterExample = () => {
const { register, isLoading } = useAuthStore();
const navigate = useNavigate();
const handleRegister = async () => {
try {
await register({
name: 'John Doe',
email: 'john@example.com',
password: 'password123',
phone: '0123456789'
});
navigate('/login');
} catch (error) {
console.error('Register failed:', error);
}
};
return (
<button
onClick={handleRegister}
disabled={isLoading}
>
{isLoading ? 'Đang xử lý...' : 'Đăng ký'}
</button>
);
};
// ============================================
// Example 3: User Profile Display
// ============================================
export const UserProfileExample = () => {
const { userInfo, isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <p>Vui lòng đăng nhập</p>;
}
return (
<div>
<h2>Thông tin người dùng</h2>
<p>Tên: {userInfo?.name}</p>
<p>Email: {userInfo?.email}</p>
<p>Role: {userInfo?.role}</p>
{userInfo?.avatar && (
<img
src={userInfo.avatar}
alt={userInfo.name}
/>
)}
</div>
);
};
// ============================================
// Example 4: Logout Button
// ============================================
export const LogoutButtonExample = () => {
const { logout, isLoading } = useAuthStore();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<button
onClick={handleLogout}
disabled={isLoading}
>
{isLoading ? 'Đang xử lý...' : 'Đăng xuất'}
</button>
);
};
// ============================================
// Example 5: Forgot Password
// ============================================
export const ForgotPasswordExample = () => {
const { forgotPassword, isLoading } = useAuthStore();
const handleForgotPassword = async (
email: string
) => {
try {
await forgotPassword({ email });
// Toast sẽ hiển thị thông báo thành công
} catch (error) {
console.error('Forgot password failed:', error);
}
};
return (
<button
onClick={() =>
handleForgotPassword('user@example.com')
}
disabled={isLoading}
>
Gửi email đt lại mật khẩu
</button>
);
};
// ============================================
// Example 6: Reset Password
// ============================================
export const ResetPasswordExample = () => {
const { resetPassword, isLoading } = useAuthStore();
const navigate = useNavigate();
const handleResetPassword = async (
token: string,
password: string
) => {
try {
await resetPassword({
token,
password,
confirmPassword: password
});
navigate('/login');
} catch (error) {
console.error('Reset password failed:', error);
}
};
return (
<button
onClick={() =>
handleResetPassword(
'reset-token-123',
'newpassword123'
)
}
disabled={isLoading}
>
Đt lại mật khẩu
</button>
);
};
// ============================================
// Example 7: Conditional Rendering by Role
// ============================================
export const RoleBasedComponentExample = () => {
const { userInfo } = useAuthStore();
return (
<div>
{userInfo?.role === 'admin' && (
<button>Admin Panel</button>
)}
{userInfo?.role === 'staff' && (
<button>Staff Tools</button>
)}
{userInfo?.role === 'customer' && (
<button>Customer Dashboard</button>
)}
</div>
);
};
// ============================================
// Example 8: Auth State Check
// ============================================
export const AuthStateCheckExample = () => {
const {
isAuthenticated,
isLoading,
token
} = useAuthStore();
if (isLoading) {
return <p>Đang tải...</p>;
}
if (!isAuthenticated || !token) {
return <p>Bạn chưa đăng nhập</p>;
}
return <p>Bạn đã đăng nhập</p>;
};
// ============================================
// Example 9: Update User Info
// ============================================
export const UpdateUserInfoExample = () => {
const { userInfo, setUser } = useAuthStore();
const handleUpdateProfile = () => {
if (userInfo) {
setUser({
...userInfo,
name: 'New Name',
avatar: 'https://example.com/avatar.jpg'
});
}
};
return (
<button onClick={handleUpdateProfile}>
Cập nhật thông tin
</button>
);
};
// ============================================
// Example 10: Clear Error
// ============================================
export const ErrorHandlingExample = () => {
const { error, clearError } = useAuthStore();
if (!error) return null;
return (
<div className="bg-red-100 p-4 rounded">
<p className="text-red-800">{error}</p>
<button
onClick={clearError}
className="mt-2 text-sm text-red-600"
>
Đóng
</button>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react';
/**
* Custom hook for debouncing values
* Delays updating the debounced value until after the delay period
* @param value - The value to debounce
* @param delay - Delay in milliseconds (default: 500ms)
* @returns The debounced value
*/
function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// Set up the timeout
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Clean up the timeout if value changes before delay
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;

View File

@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react';
/**
* Custom hook for monitoring page performance
* Logs page load times in development mode
* @param pageName - Name of the page/component to monitor
*/
function usePagePerformance(pageName: string) {
const startTimeRef = useRef<number>(Date.now());
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
const loadTime = Date.now() - startTimeRef.current;
console.log(
`[Performance] ${pageName} loaded in ${loadTime}ms`
);
// Report Web Vitals if available
if ('performance' in window) {
const perfData = window.performance.timing;
const pageLoadTime =
perfData.loadEventEnd - perfData.navigationStart;
console.log(
`[Performance] ${pageName} total page load: ` +
`${pageLoadTime}ms`
);
}
}
}, [pageName]);
return null;
}
export default usePagePerformance;

17
client/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import ErrorBoundary from
'./components/common/ErrorBoundary.tsx';
import './styles/index.css';
import './styles/datepicker.css';
ReactDOM.createRoot(
document.getElementById('root')!
).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SidebarAdmin } from '../components/layout';
const AdminLayout: React.FC = () => {
return (
<div className="flex h-screen bg-gray-100">
{/* Admin Sidebar */}
<SidebarAdmin />
{/* Admin Content Area */}
<div className="flex-1 overflow-auto">
<div className="p-6">
<Outlet />
</div>
</div>
</div>
);
};
export default AdminLayout;

View File

@@ -0,0 +1,410 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowRight,
AlertCircle,
} from 'lucide-react';
import {
BannerCarousel,
BannerSkeleton,
RoomCard,
RoomCardSkeleton,
SearchRoomForm,
} from '../components/rooms';
import {
bannerService,
roomService
} from '../services/api';
import type { Banner } from '../services/api/bannerService';
import type { Room } from '../services/api/roomService';
const HomePage: React.FC = () => {
const [banners, setBanners] = useState<Banner[]>([]);
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
const [newestRooms, setNewestRooms] = useState<Room[]>([]);
const [isLoadingBanners, setIsLoadingBanners] =
useState(true);
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch banners
useEffect(() => {
const fetchBanners = async () => {
try {
setIsLoadingBanners(true);
const response = await bannerService
.getBannersByPosition('home');
// Handle both response formats
if (
response.success ||
response.status === 'success'
) {
setBanners(response.data?.banners || []);
}
} catch (err: any) {
console.error('Error fetching banners:', err);
// Don't show error for banners, just use fallback
// Silently fail - banners are not critical for page functionality
} finally {
setIsLoadingBanners(false);
}
};
fetchBanners();
}, []);
// Fetch featured rooms
useEffect(() => {
const fetchFeaturedRooms = async () => {
try {
setIsLoadingRooms(true);
setError(null);
const response = await roomService.getFeaturedRooms({
featured: true,
limit: 6,
});
// Handle both response formats
if (
response.success ||
response.status === 'success'
) {
const rooms = response.data?.rooms || [];
setFeaturedRooms(rooms);
// If no rooms found but request succeeded, don't show error
if (rooms.length === 0) {
setError(null);
}
} else {
// Response didn't indicate success
setError(
response.message ||
'Unable to load room list'
);
}
} catch (err: any) {
console.error('Error fetching rooms:', err);
// Check if it's a rate limit error
if (err.response?.status === 429) {
setError(
'Too many requests. Please wait a moment and refresh the page.'
);
} else {
setError(
err.response?.data?.message ||
err.message ||
'Unable to load room list'
);
}
} finally {
setIsLoadingRooms(false);
}
};
fetchFeaturedRooms();
}, []);
// Fetch newest rooms
useEffect(() => {
const fetchNewestRooms = async () => {
try {
setIsLoadingNewest(true);
const response = await roomService.getRooms({
page: 1,
limit: 6,
sort: 'newest',
});
// Handle both response formats
if (
response.success ||
response.status === 'success'
) {
setNewestRooms(response.data?.rooms || []);
}
} catch (err: any) {
console.error('Error fetching newest rooms:', err);
// Silently fail for newest rooms section - not critical
} finally {
setIsLoadingNewest(false);
}
};
fetchNewestRooms();
}, []);
return (
<div className="min-h-screen bg-gray-50">
{/* Banner Section */}
<section className="container mx-auto px-4 pb-8">
{isLoadingBanners ? (
<BannerSkeleton />
) : (
<BannerCarousel banners={banners} />
)}
</section>
{/* Search Section */}
<section className="container mx-auto px-4 py-8">
<SearchRoomForm />
</section>
{/* Featured Rooms Section */}
<section className="container mx-auto px-4 py-12">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
Featured Rooms
</h2>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
{/* Loading State */}
{isLoadingRooms && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{/* Error State */}
{error && !isLoadingRooms && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-6 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium">
{error}
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Try Again
</button>
</div>
)}
{/* Rooms Grid */}
{!isLoadingRooms && !error && (
<>
{featuredRooms.length > 0 ? (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{featuredRooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
>
<p className="text-gray-600 text-lg">
No featured rooms available
</p>
</div>
)}
{/* View All Button (Mobile) */}
{featuredRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<Link
to="/rooms"
className="inline-flex items-center gap-2
bg-indigo-600 text-white px-6 py-3
rounded-lg hover:bg-indigo-700
transition-colors font-semibold"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
)}
</>
)}
</section>
{/* Newest Rooms Section */}
<section className="container mx-auto px-4 py-12">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
Newest Rooms
</h2>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
{/* Loading State */}
{isLoadingNewest && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{/* Rooms Grid */}
{!isLoadingNewest && (
<>
{newestRooms.length > 0 ? (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{newestRooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
>
<p className="text-gray-600 text-lg">
No new rooms available
</p>
</div>
)}
{/* View All Button (Mobile) */}
{newestRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<Link
to="/rooms"
className="inline-flex items-center gap-2
bg-indigo-600 text-white px-6 py-3
rounded-lg hover:bg-indigo-700
transition-colors font-semibold"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
)}
</>
)}
</section>
{/* Features Section */}
<section
className="container mx-auto px-4 py-12
bg-white rounded-xl shadow-sm mx-4"
>
<div
className="grid grid-cols-1 md:grid-cols-3
gap-8"
>
<div className="text-center">
<div
className="w-16 h-16 bg-indigo-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<span className="text-3xl">🏨</span>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
>
Easy Booking
</h3>
<p className="text-gray-600">
Search and book rooms with just a few clicks
</p>
</div>
<div className="text-center">
<div
className="w-16 h-16 bg-green-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<span className="text-3xl">💰</span>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
>
Best Prices
</h3>
<p className="text-gray-600">
Best price guarantee in the market
</p>
</div>
<div className="text-center">
<div
className="w-16 h-16 bg-blue-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<span className="text-3xl">🎧</span>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
>
24/7 Support
</h3>
<p className="text-gray-600">
Support team always ready to serve
</p>
</div>
</div>
</section>
</div>
);
};
export default HomePage;

View File

@@ -0,0 +1,314 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const BookingManagementPage: React.FC = () => {
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchBookings();
}, [filters, currentPage]);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await bookingService.getAllBookings({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setBookings(response.data.bookings);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load bookings list');
} finally {
setLoading(false);
}
};
const handleUpdateStatus = async (id: number, status: string) => {
try {
await bookingService.updateBooking(id, { status } as any);
toast.success('Status updated successfully');
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const handleCancelBooking = async (id: number) => {
if (!window.confirm('Are you sure you want to cancel this booking?')) return;
try {
await bookingService.cancelBooking(id);
toast.success('Booking cancelled successfully');
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to cancel booking');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending confirmation' },
confirmed: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Confirmed' },
checked_in: { bg: 'bg-green-100', text: 'text-green-800', label: 'Checked in' },
checked_out: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Checked out' },
cancelled: { bg: 'bg-red-100', text: 'text-red-800', label: 'Cancelled' },
};
const badge = badges[status] || badges.pending;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Booking Management</h1>
<p className="text-gray-500 mt-1">Manage bookings</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by booking number, guest name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
<option value="pending">Pending confirmation</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">Checked in</option>
<option value="checked_out">Checked out</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Booking Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Room
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Check-in/out
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Total Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{bookings.map((booking) => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-blue-600">{booking.booking_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{booking.guest_info?.full_name || booking.user?.name}</div>
<div className="text-xs text-gray-500">{booking.guest_info?.email || booking.user?.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
Room {booking.room?.room_number} - {booking.room?.room_type?.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Date(booking.check_in_date).toLocaleDateString('en-US')}
</div>
<div className="text-xs text-gray-500">
to {new Date(booking.check_out_date).toLocaleDateString('en-US')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{formatCurrency(booking.total_price)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(booking.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedBooking(booking);
setShowDetailModal(true);
}}
className="text-blue-600 hover:text-blue-900 mr-2"
title="View details"
>
<Eye className="w-5 h-5" />
</button>
{booking.status === 'pending' && (
<>
<button
onClick={() => handleUpdateStatus(booking.id, 'confirmed')}
className="text-green-600 hover:text-green-900 mr-2"
title="Confirm"
>
<CheckCircle className="w-5 h-5" />
</button>
<button
onClick={() => handleCancelBooking(booking.id)}
className="text-red-600 hover:text-red-900"
title="Cancel"
>
<XCircle className="w-5 h-5" />
</button>
</>
)}
{booking.status === 'confirmed' && (
<button
onClick={() => handleUpdateStatus(booking.id, 'checked_in')}
className="text-green-600 hover:text-green-900"
title="Check-in"
>
<CheckCircle className="w-5 h-5" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{/* Detail Modal */}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Booking Details</h2>
<button onClick={() => setShowDetailModal(false)} className="text-gray-500 hover:text-gray-700">
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-500">Booking Number</label>
<p className="text-lg font-semibold">{selectedBooking.booking_number}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Status</label>
<div className="mt-1">{getStatusBadge(selectedBooking.status)}</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Customer Information</label>
<p className="text-gray-900">{selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</p>
<p className="text-gray-600">{selectedBooking.guest_info?.email || selectedBooking.user?.email}</p>
<p className="text-gray-600">{selectedBooking.guest_info?.phone || selectedBooking.user?.phone}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Room Information</label>
<p className="text-gray-900">Room {selectedBooking.room?.room_number} - {selectedBooking.room?.room_type?.name}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-500">Check-in Date</label>
<p className="text-gray-900">{new Date(selectedBooking.check_in_date).toLocaleDateString('en-US')}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Check-out Date</label>
<p className="text-gray-900">{new Date(selectedBooking.check_out_date).toLocaleDateString('en-US')}</p>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Number of Guests</label>
<p className="text-gray-900">{selectedBooking.guest_count} guest(s)</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Total Price</label>
<p className="text-2xl font-bold text-green-600">{formatCurrency(selectedBooking.total_price)}</p>
</div>
{selectedBooking.notes && (
<div>
<label className="text-sm font-medium text-gray-500">Notes</label>
<p className="text-gray-900">{selectedBooking.notes}</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowDetailModal(false)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Đóng
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BookingManagementPage;

View File

@@ -0,0 +1,402 @@
import React, { useState } from 'react';
import { Search, User, Hotel, CheckCircle, AlertCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
interface GuestInfo {
name: string;
id_number: string;
phone: string;
}
const CheckInPage: React.FC = () => {
const [bookingNumber, setBookingNumber] = useState('');
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [actualRoomNumber, setActualRoomNumber] = useState('');
const [guests, setGuests] = useState<GuestInfo[]>([{ name: '', id_number: '', phone: '' }]);
const [extraPersons, setExtraPersons] = useState(0);
const [children, setChildren] = useState(0);
const [additionalFee, setAdditionalFee] = useState(0);
const handleSearch = async () => {
if (!bookingNumber.trim()) {
toast.error('Please enter booking number');
return;
}
try {
setSearching(true);
const response = await bookingService.checkBookingByNumber(bookingNumber);
setBooking(response.data.booking);
setActualRoomNumber(response.data.booking.room?.room_number || '');
toast.success('Booking found');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Booking not found');
setBooking(null);
} finally {
setSearching(false);
}
};
const handleAddGuest = () => {
setGuests([...guests, { name: '', id_number: '', phone: '' }]);
};
const handleRemoveGuest = (index: number) => {
if (guests.length > 1) {
setGuests(guests.filter((_, i) => i !== index));
}
};
const handleGuestChange = (index: number, field: keyof GuestInfo, value: string) => {
const newGuests = [...guests];
newGuests[index][field] = value;
setGuests(newGuests);
};
const calculateAdditionalFee = () => {
// Logic to calculate additional fees: children and extra person
const extraPersonFee = extraPersons * 200000; // 200k/person
const childrenFee = children * 100000; // 100k/child
const total = extraPersonFee + childrenFee;
setAdditionalFee(total);
return total;
};
const handleCheckIn = async () => {
if (!booking) return;
// Validate
if (!actualRoomNumber.trim()) {
toast.error('Please enter actual room number');
return;
}
const mainGuest = guests[0];
if (!mainGuest.name || !mainGuest.id_number || !mainGuest.phone) {
toast.error('Please fill in all main guest information');
return;
}
try {
setLoading(true);
// Calculate additional fee
calculateAdditionalFee();
await bookingService.updateBooking(booking.id, {
status: 'checked_in',
// Can send additional data about guests, room_number, additional_fee
} as any);
toast.success('Check-in successful');
// Reset form
setBooking(null);
setBookingNumber('');
setActualRoomNumber('');
setGuests([{ name: '', id_number: '', phone: '' }]);
setExtraPersons(0);
setChildren(0);
setAdditionalFee(0);
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred during check-in');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Check-in</h1>
<p className="text-gray-500 mt-1">Customer check-in process</p>
</div>
</div>
{/* Search Booking */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
value={bookingNumber}
onChange={(e) => setBookingNumber(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter booking number"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
onClick={handleSearch}
disabled={searching}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{/* Booking Info */}
{booking && (
<>
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
2. Booking Information
</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Booking Number:</span>
<span className="font-semibold">{booking.booking_number}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Customer:</span>
<span className="font-semibold">{booking.user?.full_name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Email:</span>
<span>{booking.user?.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Phone:</span>
<span>{booking.user?.phone_number || 'N/A'}</span>
</div>
</div>
</div>
<div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Room Type:</span>
<span className="font-semibold">{booking.room?.room_type?.name || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Check-in:</span>
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Check-out:</span>
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Number of Guests:</span>
<span>{booking.guest_count} guest(s)</span>
</div>
</div>
</div>
</div>
{booking.status !== 'confirmed' && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<p className="text-sm text-yellow-800 font-medium">Warning</p>
<p className="text-sm text-yellow-700">
Booking status: <span className="font-semibold">{booking.status}</span>.
Only check-in confirmed bookings.
</p>
</div>
</div>
)}
</div>
{/* Assign Room */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Hotel className="w-5 h-5 text-blue-600" />
3. Assign Actual Room Number
</h2>
<div className="max-w-md">
<label className="block text-sm font-medium text-gray-700 mb-2">
Room Number <span className="text-red-500">*</span>
</label>
<input
type="text"
value={actualRoomNumber}
onChange={(e) => setActualRoomNumber(e.target.value)}
placeholder="VD: 101, 202, 305"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
Enter the actual room number to assign to the guest
</p>
</div>
</div>
{/* Guest Information */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<User className="w-5 h-5 text-purple-600" />
4. Guest Information
</h2>
<div className="space-y-4">
{guests.map((guest, index) => (
<div key={index} className="p-4 border border-gray-200 rounded-lg">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium">
{index === 0 ? 'Main Guest' : `Guest ${index + 1}`}
{index === 0 && <span className="text-red-500 ml-1">*</span>}
</h3>
{index > 0 && (
<button
onClick={() => handleRemoveGuest(index)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-gray-700 mb-1">
Full Name {index === 0 && <span className="text-red-500">*</span>}
</label>
<input
type="text"
value={guest.name}
onChange={(e) => handleGuestChange(index, 'name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Nguyễn Văn A"
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">
ID Number {index === 0 && <span className="text-red-500">*</span>}
</label>
<input
type="text"
value={guest.id_number}
onChange={(e) => handleGuestChange(index, 'id_number', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="001234567890"
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">
Phone Number {index === 0 && <span className="text-red-500">*</span>}
</label>
<input
type="tel"
value={guest.phone}
onChange={(e) => handleGuestChange(index, 'phone', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0912345678"
/>
</div>
</div>
</div>
))}
<button
onClick={handleAddGuest}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
+ Add Guest
</button>
</div>
</div>
{/* Additional Charges */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">5. Additional Fees (if any)</h2>
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Extra Persons
</label>
<input
type="number"
min="0"
value={extraPersons}
onChange={(e) => {
setExtraPersons(parseInt(e.target.value) || 0);
calculateAdditionalFee();
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">200,000 VND/person</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Number of Children
</label>
<input
type="number"
min="0"
value={children}
onChange={(e) => {
setChildren(parseInt(e.target.value) || 0);
calculateAdditionalFee();
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">100,000 VND/child</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Additional Fee
</label>
<div className="px-4 py-2 bg-gray-50 border border-gray-300 rounded-lg text-lg font-semibold text-blue-600">
{formatCurrency(calculateAdditionalFee())}
</div>
</div>
</div>
</div>
{/* Summary & Action */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-200">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900">Confirm Check-in</h3>
<p className="text-sm text-gray-600 mt-1">
Guest: <span className="font-medium">{booking.user?.full_name}</span> |
Room: <span className="font-medium">{actualRoomNumber || 'Not assigned'}</span>
{additionalFee > 0 && (
<> | Additional Fee: <span className="font-medium text-red-600">{formatCurrency(additionalFee)}</span></>
)}
</p>
</div>
<button
onClick={handleCheckIn}
disabled={!actualRoomNumber || !guests[0].name || booking.status !== 'confirmed'}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
>
<CheckCircle className="w-5 h-5" />
Confirm Check-in
</button>
</div>
</div>
</>
)}
{/* Empty State */}
{!booking && !searching && (
<div className="bg-gray-50 rounded-lg p-12 text-center">
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No booking selected
</h3>
<p className="text-gray-600">
Please enter booking number above to start check-in process
</p>
</div>
)}
</div>
);
};
export default CheckInPage;

View File

@@ -0,0 +1,448 @@
import React, { useState } from 'react';
import { Search, FileText, DollarSign, CreditCard, Printer, CheckCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
interface ServiceItem {
service_name: string;
quantity: number;
price: number;
total: number;
}
const CheckOutPage: React.FC = () => {
const [bookingNumber, setBookingNumber] = useState('');
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [services, setServices] = useState<ServiceItem[]>([]);
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'bank_transfer' | 'credit_card'>('cash');
const [discount, setDiscount] = useState(0);
const [showInvoice, setShowInvoice] = useState(false);
const handleSearch = async () => {
if (!bookingNumber.trim()) {
toast.error('Please enter booking number');
return;
}
try {
setSearching(true);
const response = await bookingService.checkBookingByNumber(bookingNumber);
const foundBooking = response.data.booking;
if (foundBooking.status !== 'checked_in') {
toast.warning('Only checked-in bookings can be checked out');
}
setBooking(foundBooking);
// Mock services data - in production will fetch from API
setServices([
{ service_name: 'Laundry', quantity: 2, price: 50000, total: 100000 },
{ service_name: 'Minibar', quantity: 1, price: 150000, total: 150000 },
]);
toast.success('Booking found');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Booking not found');
setBooking(null);
} finally {
setSearching(false);
}
};
const calculateRoomFee = () => {
if (!booking) return 0;
return booking.total_price || 0;
};
const calculateServiceFee = () => {
return services.reduce((sum, service) => sum + service.total, 0);
};
const calculateAdditionalFee = () => {
// Additional fees from check-in (children, extra person)
return 0; // In production will get from booking data
};
const calculateDeposit = () => {
// Deposit already paid
return booking?.total_price ? booking.total_price * 0.3 : 0;
};
const calculateSubtotal = () => {
return calculateRoomFee() + calculateServiceFee() + calculateAdditionalFee();
};
const calculateDiscount = () => {
return discount;
};
const calculateTotal = () => {
return calculateSubtotal() - calculateDiscount();
};
const calculateRemaining = () => {
return calculateTotal() - calculateDeposit();
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
const handleCheckOut = async () => {
if (!booking) return;
if (calculateRemaining() < 0) {
toast.error('Invalid refund amount');
return;
}
try {
setLoading(true);
// Update booking status
await bookingService.updateBooking(booking.id, {
status: 'checked_out',
} as any);
// Create payment record (if needed)
// await paymentService.createPayment({...});
toast.success('Check-out successful');
setShowInvoice(true);
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred during check-out');
} finally {
setLoading(false);
}
};
const handlePrintInvoice = () => {
window.print();
};
const resetForm = () => {
setBooking(null);
setBookingNumber('');
setServices([]);
setDiscount(0);
setPaymentMethod('cash');
setShowInvoice(false);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Check-out</h1>
<p className="text-gray-500 mt-1">Payment and check-out process</p>
</div>
</div>
{/* Search Booking */}
{!showInvoice && (
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
value={bookingNumber}
onChange={(e) => setBookingNumber(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter booking number or room number"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
onClick={handleSearch}
disabled={searching}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
)}
{/* Invoice */}
{booking && !showInvoice && (
<>
{/* Booking Info */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">2. Booking information</h2>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Booking number:</span>
<span className="font-semibold">{booking.booking_number}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Customer:</span>
<span className="font-semibold">{booking.user?.full_name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Room number:</span>
<span className="font-semibold">{booking.room?.room_number}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Check-in:</span>
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Check-out:</span>
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Nights:</span>
<span>
{booking.check_in_date && booking.check_out_date
? Math.ceil((new Date(booking.check_out_date).getTime() - new Date(booking.check_in_date).getTime()) / (1000 * 60 * 60 * 24))
: 0} night(s)
</span>
</div>
</div>
</div>
</div>
{/* Bill Details */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
3. Invoice details
</h2>
{/* Room Fee */}
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Room fee</h3>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between">
<span>{booking.room?.room_type?.name || 'Room'}</span>
<span className="font-semibold">{formatCurrency(calculateRoomFee())}</span>
</div>
</div>
</div>
{/* Service Fee */}
{services.length > 0 && (
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Services used</h3>
<div className="bg-gray-50 p-4 rounded-lg space-y-2">
{services.map((service, index) => (
<div key={index} className="flex justify-between text-sm">
<span>
{service.service_name} (x{service.quantity})
</span>
<span>{formatCurrency(service.total)}</span>
</div>
))}
<div className="pt-2 border-t border-gray-200 flex justify-between font-medium">
<span>Total services:</span>
<span>{formatCurrency(calculateServiceFee())}</span>
</div>
</div>
</div>
)}
{/* Additional Fee */}
{calculateAdditionalFee() > 0 && (
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Additional fees</h3>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between">
<span>Extra person/children fee</span>
<span className="font-semibold">{formatCurrency(calculateAdditionalFee())}</span>
</div>
</div>
</div>
)}
{/* Discount */}
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Discount</h3>
<div className="flex gap-4">
<input
type="number"
value={discount}
onChange={(e) => setDiscount(parseFloat(e.target.value) || 0)}
placeholder="Enter discount amount"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Summary */}
<div className="border-t-2 border-gray-300 pt-4 space-y-2">
<div className="flex justify-between text-lg">
<span>Subtotal:</span>
<span className="font-semibold">{formatCurrency(calculateSubtotal())}</span>
</div>
{discount > 0 && (
<div className="flex justify-between text-red-600">
<span>Discount:</span>
<span>-{formatCurrency(discount)}</span>
</div>
)}
<div className="flex justify-between text-xl font-bold text-blue-600">
<span>Total:</span>
<span>{formatCurrency(calculateTotal())}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Deposit paid:</span>
<span>-{formatCurrency(calculateDeposit())}</span>
</div>
<div className="flex justify-between text-2xl font-bold text-green-600 pt-2 border-t border-gray-200">
<span>Remaining payment:</span>
<span>{formatCurrency(calculateRemaining())}</span>
</div>
</div>
</div>
{/* Payment Method */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-green-600" />
4. Payment method
</h2>
<div className="grid grid-cols-3 gap-4">
<button
onClick={() => setPaymentMethod('cash')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'cash'
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<DollarSign className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Cash</div>
</button>
<button
onClick={() => setPaymentMethod('bank_transfer')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'bank_transfer'
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<CreditCard className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Bank transfer</div>
</button>
<button
onClick={() => setPaymentMethod('credit_card')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'credit_card'
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<CreditCard className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Credit card</div>
</button>
</div>
</div>
{/* Action */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-6 rounded-lg border border-green-200">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900">Confirm check-out</h3>
<p className="text-sm text-gray-600 mt-1">
Total payment: <span className="font-bold text-green-600 text-lg">{formatCurrency(calculateRemaining())}</span>
</p>
</div>
<button
onClick={handleCheckOut}
disabled={booking.status !== 'checked_in'}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
>
<CheckCircle className="w-5 h-5" />
Confirm payment & Check-out
</button>
</div>
</div>
</>
)}
{/* Invoice Display */}
{showInvoice && booking && (
<div className="bg-white p-8 rounded-lg shadow-lg">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">PAYMENT INVOICE</h2>
<p className="text-gray-600 mt-1">Check-out successful</p>
</div>
<div className="border-t-2 border-b-2 border-gray-300 py-6 mb-6">
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-sm text-gray-600">Booking number:</p>
<p className="font-semibold">{booking.booking_number}</p>
</div>
<div>
<p className="text-sm text-gray-600">Check-out date:</p>
<p className="font-semibold">{new Date().toLocaleString('en-US')}</p>
</div>
<div>
<p className="text-sm text-gray-600">Customer:</p>
<p className="font-semibold">{booking.user?.full_name}</p>
</div>
<div>
<p className="text-sm text-gray-600">Payment method:</p>
<p className="font-semibold">
{paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'bank_transfer' ? 'Bank transfer' : 'Credit card'}
</p>
</div>
</div>
</div>
<div className="mb-6">
<div className="flex justify-between text-xl font-bold text-green-600 mb-4">
<span>Total payment:</span>
<span>{formatCurrency(calculateRemaining())}</span>
</div>
</div>
<div className="flex gap-4">
<button
onClick={handlePrintInvoice}
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
>
<Printer className="w-5 h-5" />
Print invoice
</button>
<button
onClick={resetForm}
className="flex-1 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
>
Complete
</button>
</div>
</div>
)}
{/* Empty State */}
{!booking && !searching && !showInvoice && (
<div className="bg-gray-50 rounded-lg p-12 text-center">
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No booking selected
</h3>
<p className="text-gray-600">
Please enter booking number to start check-out process
</p>
</div>
)}
</div>
);
};
export default CheckOutPage;

View File

@@ -0,0 +1,290 @@
import React, { useEffect, useState } from 'react';
import {
BarChart3,
Users,
Hotel,
DollarSign,
Calendar,
TrendingUp
} from 'lucide-react';
import { reportService, ReportData } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
const DashboardPage: React.FC = () => {
const [stats, setStats] = useState<ReportData | null>(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
});
useEffect(() => {
fetchDashboardData();
}, [dateRange]);
const fetchDashboardData = async () => {
try {
setLoading(true);
const response = await reportService.getReports({
from: dateRange.from,
to: dateRange.to,
});
setStats(response.data);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load dashboard data');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1">Hotel operations overview</p>
</div>
{/* Date Range Filter */}
<div className="flex gap-3 items-center">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<span className="text-gray-500">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Revenue */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{formatCurrency(stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-green-100 p-3 rounded-full">
<DollarSign className="w-6 h-6 text-green-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+12.5%</span>
<span className="text-gray-500 ml-2">compared to last month</span>
</div>
</div>
{/* Total Bookings */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{stats?.total_bookings || 0}
</p>
</div>
<div className="bg-blue-100 p-3 rounded-full">
<Calendar className="w-6 h-6 text-blue-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+8.2%</span>
<span className="text-gray-500 ml-2">compared to last month</span>
</div>
</div>
{/* Available Rooms */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{stats?.available_rooms || 0}
</p>
</div>
<div className="bg-purple-100 p-3 rounded-full">
<Hotel className="w-6 h-6 text-purple-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<span className="text-gray-500">
{stats?.occupied_rooms || 0} rooms in use
</span>
</div>
</div>
{/* Total Customers */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Customers</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{stats?.total_customers || 0}
</p>
</div>
<div className="bg-orange-100 p-3 rounded-full">
<Users className="w-6 h-6 text-orange-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+15.3%</span>
<span className="text-gray-500 ml-2">new customers</span>
</div>
</div>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Daily Revenue</h2>
<BarChart3 className="w-5 h-5 text-gray-400" />
</div>
{stats?.revenue_by_date && stats.revenue_by_date.length > 0 ? (
<div className="space-y-3">
{stats.revenue_by_date.slice(0, 7).map((item, index) => (
<div key={index} className="flex items-center">
<span className="text-sm text-gray-600 w-24">
{new Date(item.date).toLocaleDateString('en-US')}
</span>
<div className="flex-1 mx-3">
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
<div
className="bg-blue-500 h-4 rounded-full transition-all"
style={{
width: `${Math.min((item.revenue / (stats.revenue_by_date?.[0]?.revenue || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
{formatCurrency(item.revenue)}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">Không dữ liệu</p>
)}
</div>
{/* Bookings by Status */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Status</h2>
{stats?.bookings_by_status ? (
<div className="space-y-4">
{Object.entries(stats.bookings_by_status).map(([status, count]) => {
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
confirmed: 'bg-blue-500',
checked_in: 'bg-green-500',
checked_out: 'bg-gray-500',
cancelled: 'bg-red-500',
};
const statusLabels: Record<string, string> = {
pending: 'Pending confirmation',
confirmed: 'Confirmed',
checked_in: 'Checked in',
checked_out: 'Checked out',
cancelled: 'Cancelled',
};
return (
<div key={status} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${statusColors[status]}`} />
<span className="text-gray-700">{statusLabels[status]}</span>
</div>
<span className="font-semibold text-gray-900">{count}</span>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">Không dữ liệu</p>
)}
</div>
</div>
{/* Top Rooms and Services */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Rooms */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top phòng đưc đt</h2>
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
<div className="space-y-3">
{stats.top_rooms.map((room, index) => (
<div key={room.room_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-8 h-8 bg-blue-500 text-white rounded-full font-bold">
{index + 1}
</span>
<div>
<p className="font-medium text-gray-900">Phòng {room.room_number}</p>
<p className="text-sm text-gray-500">{room.bookings} lượt đt</p>
</div>
</div>
<span className="font-semibold text-green-600">
{formatCurrency(room.revenue)}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">Không dữ liệu</p>
)}
</div>
{/* Service Usage */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Dịch vụ đưc sử dụng</h2>
{stats?.service_usage && stats.service_usage.length > 0 ? (
<div className="space-y-3">
{stats.service_usage.map((service) => (
<div key={service.service_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium text-gray-900">{service.service_name}</p>
<p className="text-sm text-gray-500">{service.usage_count} lần sử dụng</p>
</div>
<span className="font-semibold text-purple-600">
{formatCurrency(service.total_revenue)}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">Không dữ liệu</p>
)}
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,195 @@
import React, { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
import { paymentService, Payment } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const PaymentManagementPage: React.FC = () => {
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
search: '',
method: '',
from: '',
to: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPayments();
}, [filters, currentPage]);
const fetchPayments = async () => {
try {
setLoading(true);
const response = await paymentService.getPayments({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setPayments(response.data.payments);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load payments list');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(amount);
};
const getMethodBadge = (method: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
cash: { bg: 'bg-green-100', text: 'text-green-800', label: 'Cash' },
bank_transfer: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Bank transfer' },
credit_card: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Credit card' },
};
const badge = badges[method] || badges.cash;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Payment Management</h1>
<p className="text-gray-500 mt-1">Track payment transactions</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={filters.method}
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="bank_transfer">Bank transfer</option>
<option value="credit_card">Credit card</option>
</select>
<input
type="date"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="From date"
/>
<input
type="date"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="To date"
/>
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Transaction ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Booking Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Method
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Payment Date
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{payments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{payment.transaction_id || `PAY-${payment.id}`}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-blue-600">{payment.booking?.booking_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{payment.booking?.user?.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getMethodBadge(payment.payment_method)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-green-600">
{formatCurrency(payment.amount)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(payment.payment_date || payment.createdAt).toLocaleDateString('en-US')}
</div>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{/* Summary Card */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg shadow-lg p-6 text-white">
<h3 className="text-lg font-semibold mb-2">Total Revenue</h3>
<p className="text-3xl font-bold">
{formatCurrency(payments.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-sm mt-2 opacity-90">Total {payments.length} transactions</p>
</div>
</div>
);
};
export default PaymentManagementPage;

View File

@@ -0,0 +1,488 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
import { promotionService, Promotion } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const PromotionManagementPage: React.FC = () => {
const [promotions, setPromotions] = useState<Promotion[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
type: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
code: '',
name: '',
description: '',
discount_type: 'percentage' as 'percentage' | 'fixed',
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active' as 'active' | 'inactive' | 'expired',
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPromotions();
}, [filters, currentPage]);
const fetchPromotions = async () => {
try {
setLoading(true);
const response = await promotionService.getPromotions({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setPromotions(response.data.promotions);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load promotions list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingPromotion) {
await promotionService.updatePromotion(editingPromotion.id, formData);
toast.success('Promotion updated successfully');
} else {
await promotionService.createPromotion(formData);
toast.success('Promotion added successfully');
}
setShowModal(false);
resetForm();
fetchPromotions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (promotion: Promotion) => {
setEditingPromotion(promotion);
setFormData({
code: promotion.code,
name: promotion.name,
description: promotion.description || '',
discount_type: promotion.discount_type,
discount_value: promotion.discount_value,
min_booking_amount: promotion.min_booking_amount || 0,
max_discount_amount: promotion.max_discount_amount || 0,
start_date: promotion.start_date?.split('T')[0] || '',
end_date: promotion.end_date?.split('T')[0] || '',
usage_limit: promotion.usage_limit || 0,
status: promotion.status,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this promotion?')) return;
try {
await promotionService.deletePromotion(id);
toast.success('Promotion deleted successfully');
fetchPromotions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete promotion');
}
};
const resetForm = () => {
setEditingPromotion(null);
setFormData({
code: '',
name: '',
description: '',
discount_type: 'percentage',
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
active: { bg: 'bg-green-100', text: 'text-green-800', label: 'Active' },
inactive: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Inactive' },
};
const badge = badges[status] || badges.active;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Quản khuyến mãi</h1>
<p className="text-gray-500 mt-1">Quản giảm giá chương trình khuyến mãi</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Thêm khuyến mãi
</button>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Tìm theo code hoặc tên..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Tất cả loại</option>
<option value="percentage">Phần trăm</option>
<option value="fixed">Số tiền cố đnh</option>
</select>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Tất cả trạng thái</option>
<option value="active">Hoạt đng</option>
<option value="inactive">Ngừng</option>
</select>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
code
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Tên chương trình
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Giá trị
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Thời gian
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Đã dùng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Trạng thái
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Thao tác
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{promotions.map((promotion) => (
<tr key={promotion.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-blue-600" />
<span className="text-sm font-mono font-bold text-blue-600">{promotion.code}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">{promotion.name}</div>
<div className="text-xs text-gray-500">{promotion.description}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{promotion.discount_type === 'percentage'
? `${promotion.discount_value}%`
: formatCurrency(promotion.discount_value)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-xs text-gray-500">
{promotion.start_date ? new Date(promotion.start_date).toLocaleDateString('en-US') : 'N/A'}
{' → '}
{promotion.end_date ? new Date(promotion.end_date).toLocaleDateString('en-US') : 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{promotion.used_count || 0} / {promotion.usage_limit || '∞'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(promotion.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(promotion)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(promotion.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingPromotion ? 'Cập nhật khuyến mãi' : 'Thêm khuyến mãi mới'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
code <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="VD: SUMMER2024"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tên chương trình <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="VD: Giảm giá mùa hè"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
tả
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Mô tả chi tiết về chương trình..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Loại giảm giá <span className="text-red-500">*</span>
</label>
<select
value={formData.discount_type}
onChange={(e) => setFormData({ ...formData, discount_type: e.target.value as 'percentage' | 'fixed' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="percentage">Phần trăm (%)</option>
<option value="fixed">Số tiền cố đnh (VND)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Giá trị giảm <span className="text-red-500">*</span>
</label>
<input
type="number"
value={formData.discount_value}
onChange={(e) => setFormData({ ...formData, discount_value: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
max={formData.discount_type === 'percentage' ? 100 : undefined}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Giá trị đơn tối thiểu (VND)
</label>
<input
type="number"
value={formData.min_booking_amount}
onChange={(e) => setFormData({ ...formData, min_booking_amount: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Giảm tối đa (VND)
</label>
<input
type="number"
value={formData.max_discount_amount}
onChange={(e) => setFormData({ ...formData, max_discount_amount: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ngày bắt đu <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ngày kết thúc <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Giới hạn lượt dùng (0 = không giới hạn)
</label>
<input
type="number"
value={formData.usage_limit}
onChange={(e) => setFormData({ ...formData, usage_limit: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Trạng thái
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'active' | 'inactive' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="active">Hoạt đng</option>
<option value="inactive">Ngừng</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Hủy
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingPromotion ? 'Cập nhật' : 'Thêm mới'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default PromotionManagementPage;

View File

@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { reviewService, Review } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const ReviewManagementPage: React.FC = () => {
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchReviews();
}, [filters, currentPage]);
const fetchReviews = async () => {
try {
setLoading(true);
const response = await reviewService.getReviews({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setReviews(response.data.reviews);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load reviews list');
} finally {
setLoading(false);
}
};
const handleApprove = async (id: number) => {
try {
await reviewService.approveReview(id);
toast.success('Review approved successfully');
fetchReviews();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to approve review');
}
};
const handleReject = async (id: number) => {
if (!window.confirm('Are you sure you want to reject this review?')) return;
try {
await reviewService.rejectReview(id);
toast.success('Review rejected successfully');
fetchReviews();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to reject review');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending' },
approved: { bg: 'bg-green-100', text: 'text-green-800', label: 'Approved' },
rejected: { bg: 'bg-red-100', text: 'text-red-800', label: 'Rejected' },
};
const badge = badges[status] || badges.pending;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
const renderStars = (rating: number) => {
return (
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<span key={star} className={star <= rating ? 'text-yellow-400' : 'text-gray-300'}>
</span>
))}
</div>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Review Management</h1>
<p className="text-gray-500 mt-1">Approve and manage customer reviews</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Room
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Rating
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Comment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reviews.map((review) => (
<tr key={review.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{review.user?.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
Phòng {review.room?.room_number} - {review.room?.room_type?.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{renderStars(review.rating)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">{review.comment}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(review.created_at).toLocaleDateString('en-US')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(review.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{review.status === 'pending' && (
<>
<button
onClick={() => handleApprove(review.id)}
className="text-green-600 hover:text-green-900 mr-3"
title="Phê duyệt"
>
</button>
<button
onClick={() => handleReject(review.id)}
className="text-red-600 hover:text-red-900"
title="Từ chối"
>
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
</div>
);
};
export default ReviewManagementPage;

View File

@@ -0,0 +1,512 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon } from 'lucide-react';
import { roomService, Room } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import apiClient from '../../services/api/apiClient';
const RoomManagementPage: React.FC = () => {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
type: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
room_number: '',
floor: 1,
room_type_id: 1,
status: 'available' as 'available' | 'occupied' | 'maintenance',
featured: false,
});
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchRooms();
}, [filters, currentPage]);
const fetchRooms = async () => {
try {
setLoading(true);
const response = await roomService.getRooms({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setRooms(response.data.rooms);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load rooms list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingRoom) {
// Update room
await roomService.updateRoom(editingRoom.id, formData);
toast.success('Room updated successfully');
} else {
// Create room
await roomService.createRoom(formData);
toast.success('Room added successfully');
}
setShowModal(false);
resetForm();
fetchRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (room: Room) => {
setEditingRoom(room);
setFormData({
room_number: room.room_number,
floor: room.floor,
room_type_id: room.room_type_id,
status: room.status,
featured: room.featured,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this room?')) return;
try {
await roomService.deleteRoom(id);
toast.success('Room deleted successfully');
fetchRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete room');
}
};
const resetForm = () => {
setEditingRoom(null);
setFormData({
room_number: '',
floor: 1,
room_type_id: 1,
status: 'available',
featured: false,
});
setSelectedFiles([]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
setSelectedFiles(files);
}
};
const handleUploadImages = async () => {
if (!editingRoom || selectedFiles.length === 0) return;
try {
setUploadingImages(true);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
await apiClient.post(`/rooms/${editingRoom.id}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
toast.success('Images uploaded successfully');
setSelectedFiles([]);
fetchRooms();
// Refresh editing room data
const response = await roomService.getRoomById(editingRoom.id);
setEditingRoom(response.data.room);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to upload images');
} finally {
setUploadingImages(false);
}
};
const handleDeleteImage = async (imageUrl: string) => {
if (!editingRoom) return;
if (!window.confirm('Bạn có chắc muốn xóa ảnh này?')) return;
try {
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
data: { imageUrl },
});
toast.success('Xóa ảnh thành công');
fetchRooms();
// Refresh editing room data
const response = await roomService.getRoomById(editingRoom.id);
setEditingRoom(response.data.room);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Không thể xóa ảnh');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
available: { bg: 'bg-green-100', text: 'text-green-800', label: 'Trống' },
occupied: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Đang sử dụng' },
maintenance: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Bảo trì' },
};
const badge = badges[status] || badges.available;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Quản phòng</h1>
<p className="text-gray-500 mt-1">Quản thông tin phòng khách sạn</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
Thêm phòng
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Tìm kiếm phòng..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Tất cả trạng thái</option>
<option value="available">Trống</option>
<option value="occupied">Đang sử dụng</option>
<option value="maintenance">Bảo trì</option>
</select>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Tất cả loại phòng</option>
<option value="1">Standard</option>
<option value="2">Deluxe</option>
<option value="3">Suite</option>
</select>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Số phòng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Loại phòng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tầng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Giá
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Trạng thái
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nổi bật
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Thao tác
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{rooms.map((room) => (
<tr key={room.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{room.room_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{room.room_type?.name || 'N/A'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">Tầng {room.floor}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(room.room_type?.base_price || 0)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(room.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{room.featured ? (
<span className="text-yellow-500"></span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(room)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(room.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingRoom ? 'Cập nhật phòng' : 'Thêm phòng mới'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Số phòng
</label>
<input
type="text"
value={formData.room_number}
onChange={(e) => setFormData({ ...formData, room_number: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tầng
</label>
<input
type="number"
value={formData.floor}
onChange={(e) => setFormData({ ...formData, floor: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
min="1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Loại phòng
</label>
<select
value={formData.room_type_id}
onChange={(e) => setFormData({ ...formData, room_type_id: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="1">Standard</option>
<option value="2">Deluxe</option>
<option value="3">Suite</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Trạng thái
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="available">Trống</option>
<option value="occupied">Đang sử dụng</option>
<option value="maintenance">Bảo trì</option>
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="featured"
checked={formData.featured}
onChange={(e) => setFormData({ ...formData, featured: e.target.checked })}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="featured" className="ml-2 text-sm text-gray-700">
Phòng nổi bật
</label>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Hủy
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingRoom ? 'Cập nhật' : 'Thêm'}
</button>
</div>
</form>
{/* Image Upload Section - Only for editing */}
{editingRoom && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
Hình nh phòng
</h3>
{/* Current Images */}
{editingRoom.room_type?.images && editingRoom.room_type.images.length > 0 && (
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">nh hiện tại:</p>
<div className="grid grid-cols-3 gap-3">
{editingRoom.room_type.images.map((img, index) => (
<div key={index} className="relative group">
<img
src={`http://localhost:3000${img}`}
alt={`Room ${index + 1}`}
className="w-full h-24 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => handleDeleteImage(img)}
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* Upload New Images */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Thêm nh mới (tối đa 5 nh):
</label>
<div className="flex gap-3">
<input
type="file"
accept="image/*"
multiple
onChange={handleFileSelect}
className="flex-1 text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<button
type="button"
onClick={handleUploadImages}
disabled={selectedFiles.length === 0 || uploadingImages}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{uploadingImages ? 'Đang tải...' : 'Upload'}
</button>
</div>
{selectedFiles.length > 0 && (
<p className="text-sm text-gray-600 mt-2">
{selectedFiles.length} file đã chọn
</p>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default RoomManagementPage;

View File

@@ -0,0 +1,336 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import { serviceService, Service } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const ServiceManagementPage: React.FC = () => {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
name: '',
description: '',
price: 0,
unit: 'lần',
status: 'active' as 'active' | 'inactive',
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchServices();
}, [filters, currentPage]);
const fetchServices = async () => {
try {
setLoading(true);
const response = await serviceService.getServices({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setServices(response.data.services);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load services list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingService) {
await serviceService.updateService(editingService.id, formData);
toast.success('Service updated successfully');
} else {
await serviceService.createService(formData);
toast.success('Service added successfully');
}
setShowModal(false);
resetForm();
fetchServices();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (service: Service) => {
setEditingService(service);
setFormData({
name: service.name,
description: service.description || '',
price: service.price,
unit: service.unit || 'time',
status: service.status,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this service?')) return;
try {
await serviceService.deleteService(id);
toast.success('Service deleted successfully');
fetchServices();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete service');
}
};
const resetForm = () => {
setEditingService(null);
setFormData({
name: '',
description: '',
price: 0,
unit: 'time',
status: 'active',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Service Management</h1>
<p className="text-gray-500 mt-1">Manage hotel services</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-5 h-5" />
Add Service
</button>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search services..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Tất cả trạng thái</option>
<option value="active">Hoạt đng</option>
<option value="inactive">Tạm dừng</option>
</select>
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Tên dịch vụ
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
tả
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Giá
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Đơn vị
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Trạng thái
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Thao tác
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{services.map((service) => (
<tr key={service.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{service.name}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">{service.description}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">{formatCurrency(service.price)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{service.unit}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
service.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{service.status === 'active' ? 'Hoạt động' : 'Tạm dừng'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(service)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingService ? 'Cập nhật dịch vụ' : 'Thêm dịch vụ mới'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tên dịch vụ
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
tả
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Giá
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Đơn vị
</label>
<input
type="text"
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="VD: lần, giờ, ngày..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Trạng thái
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="active">Hoạt đng</option>
<option value="inactive">Tạm dừng</option>
</select>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Hủy
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingService ? 'Cập nhật' : 'Thêm'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ServiceManagementPage;

View File

@@ -0,0 +1,412 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import { userService, User } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import useAuthStore from '../../store/useAuthStore';
const UserManagementPage: React.FC = () => {
const { userInfo } = useAuthStore();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [filters, setFilters] = useState({
search: '',
role: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
full_name: '',
email: '',
phone_number: '',
password: '',
role: 'customer',
status: 'active',
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchUsers();
}, [filters, currentPage]);
const fetchUsers = async () => {
try {
setLoading(true);
console.log('Fetching users with filters:', filters, 'page:', currentPage);
const response = await userService.getUsers({
...filters,
page: currentPage,
limit: itemsPerPage,
});
console.log('Users response:', response);
setUsers(response.data.users);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
console.error('Error fetching users:', error);
toast.error(error.response?.data?.message || 'Unable to load users list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingUser) {
// When updating, only send password if changed
const updateData: any = {
full_name: formData.full_name,
email: formData.email,
phone_number: formData.phone_number,
role: formData.role,
status: formData.status,
};
// Only add password if user entered a new one
if (formData.password && formData.password.trim() !== '') {
updateData.password = formData.password;
}
console.log('Updating user:', editingUser.id, 'with data:', updateData);
const response = await userService.updateUser(editingUser.id, updateData);
console.log('Update response:', response);
toast.success('User updated successfully');
} else {
// When creating new, need complete information
if (!formData.password || formData.password.trim() === '') {
toast.error('Please enter password');
return;
}
console.log('Creating user with data:', formData);
const response = await userService.createUser(formData);
console.log('Create response:', response);
toast.success('User added successfully');
}
// Close modal and reset form first
setShowModal(false);
resetForm();
// Reload users list after a bit to ensure DB is updated
setTimeout(() => {
fetchUsers();
}, 300);
} catch (error: any) {
console.error('Error submitting user:', error);
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (user: User) => {
setEditingUser(user);
setFormData({
full_name: user.full_name,
email: user.email,
phone_number: user.phone_number || '',
password: '',
role: user.role,
status: user.status || 'active',
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
// Prevent self-deletion
if (userInfo?.id === id) {
toast.error('You cannot delete your own account');
return;
}
if (!window.confirm('Are you sure you want to delete this user?')) return;
try {
console.log('Deleting user:', id);
await userService.deleteUser(id);
toast.success('User deleted successfully');
fetchUsers();
} catch (error: any) {
console.error('Error deleting user:', error);
toast.error(error.response?.data?.message || 'Unable to delete user');
}
};
const resetForm = () => {
setEditingUser(null);
setFormData({
full_name: '',
email: '',
phone_number: '',
password: '',
role: 'customer',
status: 'active',
});
};
const getRoleBadge = (role: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
admin: { bg: 'bg-red-100', text: 'text-red-800', label: 'Admin' },
staff: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Staff' },
customer: { bg: 'bg-green-100', text: 'text-green-800', label: 'Customer' },
};
const badge = badges[role] || badges.customer;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-500 mt-1">Manage accounts and permissions</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-5 h-5" />
Add User
</button>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by name, email..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={filters.role}
onChange={(e) => setFilters({ ...filters, role: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All roles</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
<option value="customer">Customer</option>
</select>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Phone
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{user.full_name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.phone_number || 'N/A'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getRoleBadge(user.role)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{user.created_at ? new Date(user.created_at).toLocaleDateString('en-US') : 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(user)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900"
disabled={userInfo?.id === user.id}
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingUser ? 'Update User' : 'Add New User'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone Number
</label>
<input
type="tel"
value={formData.phone_number}
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password {editingUser && '(leave blank if not changing)'}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required={!editingUser}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="customer">Customer</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingUser ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default UserManagementPage;

View File

@@ -0,0 +1,10 @@
export { default as DashboardPage } from './DashboardPage';
export { default as RoomManagementPage } from './RoomManagementPage';
export { default as UserManagementPage } from './UserManagementPage';
export { default as BookingManagementPage } from './BookingManagementPage';
export { default as PaymentManagementPage } from './PaymentManagementPage';
export { default as ServiceManagementPage } from './ServiceManagementPage';
export { default as ReviewManagementPage } from './ReviewManagementPage';
export { default as PromotionManagementPage } from './PromotionManagementPage';
export { default as CheckInPage } from './CheckInPage';
export { default as CheckOutPage } from './CheckOutPage';

View File

@@ -0,0 +1,328 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Link } from 'react-router-dom';
import {
Mail,
ArrowLeft,
Send,
Loader2,
CheckCircle,
Hotel,
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
forgotPasswordSchema,
ForgotPasswordFormData,
} from '../../utils/validationSchemas';
const ForgotPasswordPage: React.FC = () => {
const { forgotPassword, isLoading, error, clearError } =
useAuthStore();
const [isSuccess, setIsSuccess] = useState(false);
const [submittedEmail, setSubmittedEmail] = useState('');
// React Hook Form setup
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordFormData>({
resolver: yupResolver(forgotPasswordSchema),
defaultValues: {
email: '',
},
});
// Handle form submission
const onSubmit = async (data: ForgotPasswordFormData) => {
try {
clearError();
setSubmittedEmail(data.email);
await forgotPassword({ email: data.email });
// Show success state
setIsSuccess(true);
} catch (error) {
// Error đã được xử lý trong store
console.error('Forgot password error:', error);
}
};
return (
<div
className="min-h-screen bg-gradient-to-br
from-blue-50 to-indigo-100 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div className="max-w-md w-full space-y-8">
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-blue-600 rounded-full">
<Hotel className="w-12 h-12 text-white" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900">
Forgot Password?
</h2>
<p className="mt-2 text-sm text-gray-600">
Enter your email to receive a password reset link
</p>
</div>
{/* Form Container */}
<div className="bg-white rounded-lg shadow-xl p-8">
{isSuccess ? (
// Success State
<div className="text-center space-y-6">
<div className="flex justify-center">
<div
className="w-16 h-16 bg-green-100
rounded-full flex items-center
justify-center"
>
<CheckCircle
className="w-10 h-10 text-green-600"
/>
</div>
</div>
<div className="space-y-2">
<h3
className="text-xl font-semibold
text-gray-900"
>
Email Sent!
</h3>
<p className="text-sm text-gray-600">
We have sent a password reset link to
</p>
<p className="text-sm font-medium text-blue-600">
{submittedEmail}
</p>
</div>
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-4 text-left"
>
<p className="text-sm text-gray-700">
<strong>Note:</strong>
</p>
<ul
className="mt-2 space-y-1 text-sm
text-gray-600 list-disc list-inside"
>
<li>Link is valid for 1 hour</li>
<li>Check your Spam/Junk folder</li>
<li>
If you don't receive the email, please try again
</li>
</ul>
</div>
<div className="space-y-3">
<button
onClick={() => {
setIsSuccess(false);
clearError();
}}
className="w-full flex items-center
justify-center py-3 px-4 border
border-gray-300 rounded-lg
text-sm font-medium text-gray-700
bg-white hover:bg-gray-50
focus:outline-none focus:ring-2
focus:ring-offset-2
focus:ring-blue-500
transition-colors"
>
<Mail className="-ml-1 mr-2 h-5 w-5" />
Resend Email
</button>
<Link
to="/login"
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg
text-sm font-medium text-white
bg-blue-600 hover:bg-blue-700
focus:outline-none focus:ring-2
focus:ring-offset-2
focus:ring-blue-500
transition-colors"
>
<ArrowLeft
className="-ml-1 mr-2 h-5 w-5"
/>
Back to Login
</Link>
</div>
</div>
) : (
// Form State
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Error Message */}
{error && (
<div
className="bg-red-50 border
border-red-200 text-red-700
px-4 py-3 rounded-lg text-sm"
>
{error}
</div>
)}
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Email
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Mail
className="h-5 w-5 text-gray-400"
/>
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
autoFocus
className={`block w-full pl-10 pr-3
py-3 border rounded-lg
focus:outline-none focus:ring-2
transition-colors
${
errors.email
? 'border-red-300 ' +
'focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-blue-500'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg
shadow-sm text-sm font-medium
text-white bg-blue-600
hover:bg-blue-700 focus:outline-none
focus:ring-2 focus:ring-offset-2
focus:ring-blue-500
disabled:opacity-50
disabled:cursor-not-allowed
transition-colors"
>
{isLoading ? (
<>
<Loader2
className="animate-spin -ml-1
mr-2 h-5 w-5"
/>
Processing...
</>
) : (
<>
<Send className="-ml-1 mr-2 h-5 w-5" />
Send Reset Link
</>
)}
</button>
{/* Back to Login Link */}
<div className="text-center">
<Link
to="/login"
className="inline-flex items-center
text-sm font-medium text-blue-600
hover:text-blue-500 transition-colors"
>
<ArrowLeft
className="mr-1 h-4 w-4"
/>
Back to Login
</Link>
</div>
</form>
)}
</div>
{/* Footer Info */}
{!isSuccess && (
<div className="text-center text-sm text-gray-500">
<p>
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-blue-600
hover:underline"
>
Register now
</Link>
</p>
</div>
)}
{/* Help Section */}
<div
className="bg-white rounded-lg shadow-sm
border border-gray-200 p-4"
>
<h3
className="text-sm font-semibold text-gray-900
mb-2"
>
Need Help?
</h3>
<p className="text-xs text-gray-600">
If you're having trouble resetting your password,
please contact our support team via email{' '}
<a
href="mailto:support@hotel.com"
className="text-blue-600 hover:underline"
>
support@hotel.com
</a>{' '}
or hotline{' '}
<a
href="tel:1900-xxxx"
className="text-blue-600 hover:underline"
>
1900-xxxx
</a>
</p>
</div>
</div>
</div>
);
};
export default ForgotPasswordPage;

View File

@@ -0,0 +1,287 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import {
Eye,
EyeOff,
LogIn,
Loader2,
Mail,
Lock,
Hotel
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
loginSchema,
LoginFormData
} from '../../utils/validationSchemas';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { login, isLoading, error, clearError } =
useAuthStore();
const [showPassword, setShowPassword] = useState(false);
// React Hook Form setup
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: yupResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
// Handle form submission
const onSubmit = async (data: LoginFormData) => {
try {
clearError();
await login({
email: data.email,
password: data.password,
rememberMe: data.rememberMe,
});
// Redirect về trang trước đó hoặc dashboard
const from = location.state?.from?.pathname ||
'/dashboard';
navigate(from, { replace: true });
} catch (error) {
// Error đã được xử lý trong store
console.error('Login error:', error);
}
};
return (
<div className="min-h-screen bg-gradient-to-br
from-blue-50 to-indigo-100 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div className="max-w-md w-full space-y-8">
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-blue-600 rounded-full">
<Hotel className="w-12 h-12 text-white" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900">
Login
</h2>
<p className="mt-2 text-sm text-gray-600">
Welcome back to Hotel Booking
</p>
</div>
{/* Login Form */}
<div className="bg-white rounded-lg shadow-xl p-8">
<form onSubmit={handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200
text-red-700 px-4 py-3 rounded-lg
text-sm"
>
{error}
</div>
)}
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Email
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0
pl-3 flex items-center pointer-events-none"
>
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${errors.email
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0
pl-3 flex items-center pointer-events-none"
>
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className={`block w-full pl-10 pr-10 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${errors.password
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5
text-gray-400 hover:text-gray-600"
/>
) : (
<Eye className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
{...register('rememberMe')}
id="rememberMe"
type="checkbox"
className="h-4 w-4 text-blue-600
focus:ring-blue-500 border-gray-300
rounded cursor-pointer"
/>
<label
htmlFor="rememberMe"
className="ml-2 block text-sm
text-gray-700 cursor-pointer"
>
Remember me
</label>
</div>
<Link
to="/forgot-password"
className="text-sm font-medium
text-blue-600 hover:text-blue-500
transition-colors"
>
Forgot password?
</Link>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg shadow-sm
text-sm font-medium text-white
bg-blue-600 hover:bg-blue-700
focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isLoading ? (
<>
<Loader2 className="animate-spin -ml-1
mr-2 h-5 w-5"
/>
Processing...
</>
) : (
<>
<LogIn className="-ml-1 mr-2 h-5 w-5" />
Login
</>
)}
</button>
</form>
{/* Register Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-blue-600
hover:text-blue-500 transition-colors"
>
Register now
</Link>
</p>
</div>
</div>
{/* Footer Info */}
<div className="text-center text-sm text-gray-500">
<p>
By logging in, you agree to our{' '}
<Link
to="/terms"
className="text-blue-600 hover:underline"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="/privacy"
className="text-blue-600 hover:underline"
>
Privacy Policy
</Link>
</p>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,517 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Link, useNavigate } from 'react-router-dom';
import {
Eye,
EyeOff,
UserPlus,
Loader2,
Mail,
Lock,
User,
Phone,
Hotel,
CheckCircle2,
XCircle,
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
registerSchema,
RegisterFormData,
} from '../../utils/validationSchemas';
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const { register: registerUser, isLoading, error, clearError } =
useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState(false);
// React Hook Form setup
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: yupResolver(registerSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
phone: '',
},
});
// Watch password để hiển thị password strength
const password = watch('password');
// Password strength checker
const getPasswordStrength = (pwd: string) => {
if (!pwd) return { strength: 0, label: '', color: '' };
let strength = 0;
if (pwd.length >= 8) strength++;
if (/[a-z]/.test(pwd)) strength++;
if (/[A-Z]/.test(pwd)) strength++;
if (/\d/.test(pwd)) strength++;
if (/[@$!%*?&]/.test(pwd)) strength++;
const labels = [
{ label: 'Very Weak', color: 'bg-red-500' },
{ label: 'Weak', color: 'bg-orange-500' },
{ label: 'Medium', color: 'bg-yellow-500' },
{ label: 'Strong', color: 'bg-blue-500' },
{ label: 'Very Strong', color: 'bg-green-500' },
];
return { strength, ...labels[strength] };
};
const passwordStrength = getPasswordStrength(password || '');
// Handle form submission
const onSubmit = async (data: RegisterFormData) => {
try {
clearError();
await registerUser({
name: data.name,
email: data.email,
password: data.password,
phone: data.phone,
});
// Redirect to login page
navigate('/login', { replace: true });
} catch (error) {
// Error đã được xử lý trong store
console.error('Register error:', error);
}
};
return (
<div
className="min-h-screen bg-gradient-to-br
from-purple-50 to-pink-100 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div className="max-w-md w-full space-y-8">
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-purple-600 rounded-full">
<Hotel className="w-12 h-12 text-white" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900">
Create Account
</h2>
<p className="mt-2 text-sm text-gray-600">
Create a new account to book hotel rooms
</p>
</div>
{/* Register Form */}
<div className="bg-white rounded-lg shadow-xl p-8">
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-5"
>
{/* Error Message */}
{error && (
<div
className="bg-red-50 border border-red-200
text-red-700 px-4 py-3 rounded-lg
text-sm"
>
{error}
</div>
)}
{/* Name Field */}
<div>
<label
htmlFor="name"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Full Name
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<User className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('name')}
id="name"
type="text"
autoComplete="name"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.name
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
placeholder="John Doe"
/>
</div>
{errors.name && (
<p className="mt-1 text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Email
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.email
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Phone Field */}
<div>
<label
htmlFor="phone"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Phone Number (Optional)
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Phone className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('phone')}
id="phone"
type="tel"
autoComplete="tel"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.phone
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
placeholder="0123456789"
/>
</div>
{errors.phone && (
<p className="mt-1 text-sm text-red-600">
{errors.phone.message}
</p>
)}
</div>
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Password
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
className={`block w-full pl-10 pr-10 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.password
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
{/* Password Strength Indicator */}
{password && password.length > 0 && (
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200
rounded-full overflow-hidden"
>
<div
className={`h-full transition-all
duration-300 ${passwordStrength.color}`}
style={{
width: `${
(passwordStrength.strength / 5) * 100
}%`,
}}
/>
</div>
<span className="text-xs font-medium
text-gray-600"
>
{passwordStrength.label}
</span>
</div>
{/* Password Requirements */}
<div className="mt-2 space-y-1">
<PasswordRequirement
met={password.length >= 8}
text="At least 8 characters"
/>
<PasswordRequirement
met={/[a-z]/.test(password)}
text="Lowercase letter (a-z)"
/>
<PasswordRequirement
met={/[A-Z]/.test(password)}
text="Uppercase letter (A-Z)"
/>
<PasswordRequirement
met={/\d/.test(password)}
text="Number (0-9)"
/>
<PasswordRequirement
met={/[@$!%*?&]/.test(password)}
text="Special character (@$!%*?&)"
/>
</div>
</div>
)}
</div>
{/* Confirm Password Field */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Confirm Password
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('confirmPassword')}
id="confirmPassword"
type={
showConfirmPassword ? 'text' : 'password'
}
autoComplete="new-password"
className={`block w-full pl-10 pr-10 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.confirmPassword
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showConfirmPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg shadow-sm
text-sm font-medium text-white
bg-purple-600 hover:bg-purple-700
focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-purple-500
disabled:opacity-50
disabled:cursor-not-allowed
transition-colors"
>
{isLoading ? (
<>
<Loader2 className="animate-spin -ml-1
mr-2 h-5 w-5"
/>
Processing...
</>
) : (
<>
<UserPlus className="-ml-1 mr-2 h-5 w-5" />
Register
</>
)}
</button>
</form>
{/* Login Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-purple-600
hover:text-purple-500 transition-colors"
>
Login now
</Link>
</p>
</div>
</div>
{/* Footer Info */}
<div className="text-center text-sm text-gray-500">
<p>
By registering, you agree to our{' '}
<Link
to="/terms"
className="text-purple-600 hover:underline"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="/privacy"
className="text-purple-600 hover:underline"
>
Privacy Policy
</Link>
</p>
</div>
</div>
</div>
);
};
// Helper component for password requirements
const PasswordRequirement: React.FC<{
met: boolean;
text: string;
}> = ({ met, text }) => (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-gray-300" />
)}
<span className={met ? 'text-green-600' : 'text-gray-500'}>
{text}
</span>
</div>
);
export default RegisterPage;

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { useNavigate, useParams, Link } from 'react-router-dom';
import {
Eye,
EyeOff,
Lock,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
KeyRound,
Hotel,
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
resetPasswordSchema,
ResetPasswordFormData,
} from '../../utils/validationSchemas';
const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { token } = useParams<{ token: string }>();
const { resetPassword, isLoading, error, clearError } =
useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// React Hook Form setup
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<ResetPasswordFormData>({
resolver: yupResolver(resetPasswordSchema),
defaultValues: {
password: '',
confirmPassword: '',
},
});
// Watch password để hiển thị password strength
const password = watch('password');
// Check if token exists
useEffect(() => {
if (!token) {
navigate('/forgot-password', { replace: true });
}
}, [token, navigate]);
// Password strength checker
const getPasswordStrength = (pwd: string) => {
if (!pwd) return { strength: 0, label: '', color: '' };
let strength = 0;
if (pwd.length >= 8) strength++;
if (/[a-z]/.test(pwd)) strength++;
if (/[A-Z]/.test(pwd)) strength++;
if (/\d/.test(pwd)) strength++;
if (/[@$!%*?&]/.test(pwd)) strength++;
const labels = [
{ label: 'Rất yếu', color: 'bg-red-500' },
{ label: 'Yếu', color: 'bg-orange-500' },
{ label: 'Trung bình', color: 'bg-yellow-500' },
{ label: 'Mạnh', color: 'bg-blue-500' },
{ label: 'Rất mạnh', color: 'bg-green-500' },
];
return { strength, ...labels[strength] };
};
const passwordStrength = getPasswordStrength(password || '');
// Handle form submission
const onSubmit = async (data: ResetPasswordFormData) => {
if (!token) {
return;
}
try {
clearError();
await resetPassword({
token,
password: data.password,
confirmPassword: data.confirmPassword,
});
// Show success state
setIsSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
navigate('/login', { replace: true });
}, 3000);
} catch (error) {
// Error đã được xử lý trong store
console.error('Reset password error:', error);
}
};
// Invalid token error check
const isTokenError =
error?.includes('token') || error?.includes('expired');
// New password reuse error check
const isReuseError =
error?.toLowerCase().includes('must be different') ||
error?.toLowerCase().includes('different from old');
return (
<div
className="min-h-screen bg-gradient-to-br
from-indigo-50 to-purple-100 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div className="max-w-md w-full space-y-8">
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-indigo-600 rounded-full">
<Hotel className="w-12 h-12 text-white" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900">
{isSuccess ? 'Hoàn tất!' : 'Đặt lại mật khẩu'}
</h2>
<p className="mt-2 text-sm text-gray-600">
{isSuccess
? 'Mật khẩu đã được đặt lại thành công'
: 'Nhập mật khẩu mới cho tài khoản của bạn'}
</p>
</div>
{/* Form Container */}
<div className="bg-white rounded-lg shadow-xl p-8">
{isSuccess ? (
// Success State
<div className="text-center space-y-6">
<div className="flex justify-center">
<div
className="w-16 h-16 bg-green-100
rounded-full flex items-center
justify-center"
>
<CheckCircle2
className="w-10 h-10 text-green-600"
/>
</div>
</div>
<div className="space-y-2">
<h3
className="text-xl font-semibold
text-gray-900"
>
Đt lại mật khẩu thành công!
</h3>
<p className="text-sm text-gray-600">
Mật khẩu của bạn đã đưc cập nhật.
</p>
<p className="text-sm text-gray-600">
Bạn thể đăng nhập bằng mật khẩu mới.
</p>
</div>
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-4"
>
<p className="text-sm text-gray-700">
Đang chuyển hướng đến trang đăng nhập...
</p>
<div className="mt-2 flex justify-center">
<Loader2
className="animate-spin h-5 w-5
text-blue-600"
/>
</div>
</div>
<Link
to="/login"
className="inline-flex items-center
justify-center w-full py-3 px-4
border border-transparent rounded-lg
text-sm font-medium text-white
bg-indigo-600 hover:bg-indigo-700
focus:outline-none focus:ring-2
focus:ring-offset-2
focus:ring-indigo-500
transition-colors"
>
<KeyRound className="-ml-1 mr-2 h-5 w-5" />
Đăng nhập ngay
</Link>
</div>
) : (
// Form State
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-5"
>
{/* Error Message */}
{error && (
<div
className={`border px-4 py-3 rounded-lg
text-sm flex items-start gap-2
${
isTokenError
? 'bg-yellow-50 border-yellow-200 ' +
'text-yellow-800'
: 'bg-red-50 border-red-200 ' +
'text-red-700'
}`}
>
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium">
{isReuseError
? 'Mật khẩu mới phải khác mật khẩu cũ'
: error}
</p>
{isTokenError && (
<Link
to="/forgot-password"
className="mt-2 inline-block text-sm
font-medium underline
hover:text-yellow-900"
>
Yêu cầu link mới
</Link>
)}
</div>
</div>
)}
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Mật khẩu mới
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock
className="h-5 w-5 text-gray-400"
/>
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
autoFocus
className={`block w-full pl-10 pr-10
py-3 border rounded-lg
focus:outline-none focus:ring-2
transition-colors
${
errors.password
? 'border-red-300 ' +
'focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-indigo-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() =>
setShowPassword(!showPassword)
}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
{/* Password Strength Indicator */}
{password && password.length > 0 && (
<div className="mt-2">
<div className="flex items-center gap-2">
<div
className="flex-1 h-2 bg-gray-200
rounded-full overflow-hidden"
>
<div
className={`h-full transition-all
duration-300
${passwordStrength.color}`}
style={{
width: `${
(passwordStrength.strength / 5) *
100
}%`,
}}
/>
</div>
<span
className="text-xs font-medium
text-gray-600"
>
{passwordStrength.label}
</span>
</div>
{/* Password Requirements */}
<div className="mt-2 space-y-1">
<PasswordRequirement
met={password.length >= 8}
text="Ít nhất 8 ký tự"
/>
<PasswordRequirement
met={/[a-z]/.test(password)}
text="Chữ thường (a-z)"
/>
<PasswordRequirement
met={/[A-Z]/.test(password)}
text="Chữ hoa (A-Z)"
/>
<PasswordRequirement
met={/\d/.test(password)}
text="Số (0-9)"
/>
<PasswordRequirement
met={/[@$!%*?&]/.test(password)}
text="Ký tự đặc biệt (@$!%*?&)"
/>
</div>
</div>
)}
</div>
{/* Confirm Password Field */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Xác nhận mật khẩu
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock
className="h-5 w-5 text-gray-400"
/>
</div>
<input
{...register('confirmPassword')}
id="confirmPassword"
type={
showConfirmPassword ? 'text' : 'password'
}
autoComplete="new-password"
className={`block w-full pl-10 pr-10
py-3 border rounded-lg
focus:outline-none focus:ring-2
transition-colors
${
errors.confirmPassword
? 'border-red-300 ' +
'focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-indigo-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(
!showConfirmPassword
)
}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showConfirmPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg
shadow-sm text-sm font-medium
text-white bg-indigo-600
hover:bg-indigo-700
focus:outline-none focus:ring-2
focus:ring-offset-2
focus:ring-indigo-500
disabled:opacity-50
disabled:cursor-not-allowed
transition-colors"
>
{isLoading ? (
<>
<Loader2
className="animate-spin -ml-1 mr-2
h-5 w-5"
/>
Đang xử ...
</>
) : (
<>
<KeyRound
className="-ml-1 mr-2 h-5 w-5"
/>
Đt lại mật khẩu
</>
)}
</button>
{/* Back to Login Link */}
<div className="text-center">
<Link
to="/login"
className="text-sm font-medium
text-indigo-600 hover:text-indigo-500
transition-colors"
>
Quay lại đăng nhập
</Link>
</div>
</form>
)}
</div>
{/* Security Info */}
{!isSuccess && (
<div
className="bg-white rounded-lg shadow-sm
border border-gray-200 p-4"
>
<h3
className="text-sm font-semibold
text-gray-900 mb-2 flex items-center
gap-2"
>
<Lock className="h-4 w-4" />
Bảo mật
</h3>
<ul
className="text-xs text-gray-600 space-y-1
list-disc list-inside"
>
<li>Link đt lại chỉ hiệu lực trong 1 giờ</li>
<li>Mật khẩu đưc hóa an toàn</li>
<li>
Nếu link hết hạn, hãy yêu cầu link mới
</li>
</ul>
</div>
)}
</div>
</div>
);
};
// Helper component for password requirements
const PasswordRequirement: React.FC<{
met: boolean;
text: string;
}> = ({ met, text }) => (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-gray-300" />
)}
<span className={met ? 'text-green-600' : 'text-gray-500'}>
{text}
</span>
</div>
);
export default ResetPasswordPage;

View File

@@ -0,0 +1,4 @@
export { default as LoginPage } from './LoginPage';
export { default as RegisterPage } from './RegisterPage';
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
export { default as ResetPasswordPage } from './ResetPasswordPage';

View File

@@ -0,0 +1,644 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import {
ArrowLeft,
Calendar,
MapPin,
Users,
CreditCard,
User,
Mail,
Phone,
FileText,
Building2,
CheckCircle,
AlertCircle,
Clock,
XCircle,
DoorOpen,
DoorClosed,
Loader2,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getBookingById,
cancelBooking,
type Booking,
} from '../../services/api/bookingService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
const BookingDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const [booking, setBooking] = useState<Booking | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
// Redirect if not authenticated
useEffect(() => {
if (!isAuthenticated) {
toast.error(
'Vui lòng đăng nhập để xem chi tiết đặt phòng'
);
navigate('/login', {
state: { from: `/bookings/${id}` }
});
}
}, [isAuthenticated, navigate, id]);
// Fetch booking details
useEffect(() => {
if (id && isAuthenticated) {
fetchBookingDetails(Number(id));
}
}, [id, isAuthenticated]);
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
setError(null);
const response = await getBookingById(bookingId);
if (
response.success &&
response.data?.booking
) {
setBooking(response.data.booking);
} else {
throw new Error(
'Không thể tải thông tin đặt phòng'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Không thể tải thông tin đặt phòng';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const handleCancelBooking = async () => {
if (!booking) return;
const confirmed = window.confirm(
`Bạn có chắc muốn hủy đặt phòng ` +
`${booking.booking_number}?\n\n` +
`⚠️ Lưu ý:\n` +
`- Bạn sẽ bị giữ 20% giá trị đơn\n` +
`- 80% còn lại sẽ được hoàn trả\n` +
`- Trạng thái phòng sẽ được cập nhật về "available"`
);
if (!confirmed) return;
try {
setCancelling(true);
const response = await cancelBooking(booking.id);
if (response.success) {
toast.success(
`✅ Đã hủy đặt phòng ${booking.booking_number} ` +
`thành công!`
);
// Update local state
setBooking((prev) =>
prev
? { ...prev, status: 'cancelled' }
: null
);
} else {
throw new Error(
response.message ||
'Không thể hủy đặt phòng'
);
}
} catch (err: any) {
console.error('Error cancelling booking:', err);
const message =
err.response?.data?.message ||
'Không thể hủy đặt phòng. Vui lòng thử lại.';
toast.error(message);
} finally {
setCancelling(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(price);
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
icon: Clock,
color: 'bg-yellow-100 text-yellow-800',
text: 'Chờ xác nhận',
};
case 'confirmed':
return {
icon: CheckCircle,
color: 'bg-green-100 text-green-800',
text: 'Đã xác nhận',
};
case 'cancelled':
return {
icon: XCircle,
color: 'bg-red-100 text-red-800',
text: 'Đã hủy',
};
case 'checked_in':
return {
icon: DoorOpen,
color: 'bg-blue-100 text-blue-800',
text: 'Đã nhận phòng',
};
case 'checked_out':
return {
icon: DoorClosed,
color: 'bg-gray-100 text-gray-800',
text: 'Đã trả phòng',
};
default:
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: status,
};
}
};
const canCancelBooking = (booking: Booking) => {
return (
booking.status === 'pending' ||
booking.status === 'confirmed'
);
};
if (loading) {
return <Loading fullScreen text="Đang tải..." />;
}
if (error || !booking) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error || 'Không tìm thấy đặt phòng'}
</p>
<button
onClick={() => navigate('/bookings')}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Quay lại danh sách
</button>
</div>
</div>
</div>
);
}
const room = booking.room;
const roomType = room?.room_type;
const statusConfig = getStatusConfig(booking.status);
const StatusIcon = statusConfig.icon;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to="/bookings"
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Quay lại danh sách</span>
</Link>
{/* Page Title */}
<div className="flex items-center justify-between
mb-6"
>
<h1 className="text-3xl font-bold text-gray-900">
Chi tiết đt phòng
</h1>
{/* Status Badge */}
<div
className={`flex items-center gap-2 px-4
py-2 rounded-full font-medium
${statusConfig.color}`}
>
<StatusIcon className="w-5 h-5" />
{statusConfig.text}
</div>
</div>
{/* Booking Number */}
<div className="bg-indigo-50 border
border-indigo-200 rounded-lg p-4 mb-6"
>
<p className="text-sm text-indigo-600
font-medium mb-1"
>
đt phòng
</p>
<p className="text-2xl font-bold text-indigo-900
font-mono"
>
{booking.booking_number}
</p>
</div>
{/* Room Information */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Thông tin phòng
</h2>
{roomType && (
<div className="flex flex-col md:flex-row
gap-6"
>
{/* Room Image */}
{roomType.images?.[0] && (
<div className="md:w-64 flex-shrink-0">
<img
src={roomType.images[0]}
alt={roomType.name}
className="w-full h-48 md:h-full
object-cover rounded-lg"
/>
</div>
)}
{/* Room Details */}
<div className="flex-1">
<h3 className="text-2xl font-bold
text-gray-900 mb-2"
>
{roomType.name}
</h3>
<p className="text-gray-600 mb-4">
<MapPin className="w-4 h-4 inline mr-1" />
Phòng {room?.room_number} -
Tầng {room?.floor}
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">
Sức chứa
</p>
<p className="font-medium text-gray-900">
Tối đa {roomType.capacity} người
</p>
</div>
<div>
<p className="text-sm text-gray-500">
Giá phòng
</p>
<p className="font-medium text-indigo-600">
{formatPrice(roomType.base_price)}/đêm
</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Booking Details */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Chi tiết đt phòng
</h2>
<div className="space-y-4">
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2
gap-4"
>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Ngày nhận phòng
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_in_date)}
</p>
</div>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Ngày trả phòng
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_out_date)}
</p>
</div>
</div>
{/* Guest Count */}
<div>
<p className="text-sm text-gray-600 mb-1">
<Users className="w-4 h-4 inline mr-1" />
Số người
</p>
<p className="font-medium text-gray-900">
{booking.guest_count} người
</p>
</div>
{/* Notes */}
{booking.notes && (
<div>
<p className="text-sm text-gray-600 mb-1">
<FileText className="w-4 h-4 inline mr-1" />
Ghi chú
</p>
<p className="font-medium text-gray-900">
{booking.notes}
</p>
</div>
)}
{/* Payment Method */}
<div className="border-t pt-4">
<p className="text-sm text-gray-600 mb-1">
<CreditCard className="w-4 h-4 inline mr-1" />
Phương thức thanh toán
</p>
<p className="font-medium text-gray-900 mb-2">
{booking.payment_method === 'cash'
? '💵 Thanh toán tại chỗ'
: '🏦 Chuyển khoản ngân hàng'}
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
Trạng thái:
</span>
<PaymentStatusBadge
status={booking.payment_status}
size="sm"
/>
</div>
</div>
{/* Total Price */}
<div className="border-t pt-4">
<div className="flex justify-between
items-center"
>
<span className="text-lg font-semibold
text-gray-900"
>
Tổng thanh toán
</span>
<span className="text-2xl font-bold
text-indigo-600"
>
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</div>
</div>
{/* Guest Information */}
{booking.guest_info && (
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Thông tin khách hàng
</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">
<User className="w-4 h-4 inline mr-1" />
Họ tên
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.full_name}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Mail className="w-4 h-4 inline mr-1" />
Email
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.email}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Phone className="w-4 h-4 inline mr-1" />
Số điện thoại
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.phone}
</p>
</div>
</div>
</div>
)}
{/* Bank Transfer Info */}
{booking.payment_method === 'bank_transfer' &&
booking.payment_status === 'unpaid' && (
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-6 mb-6"
>
<div className="flex items-start gap-3">
<Building2
className="w-6 h-6 text-blue-600
mt-1 flex-shrink-0"
/>
<div className="flex-1">
<h3 className="font-bold text-blue-900 mb-2">
Thông tin chuyển khoản
</h3>
<div className="bg-white rounded p-4
space-y-2 text-sm"
>
<p>
<strong>Ngân hàng:</strong>
Vietcombank (VCB)
</p>
<p>
<strong>Số tài khoản:</strong>
0123456789
</p>
<p>
<strong>Chủ tài khoản:</strong>
KHACH SAN ABC
</p>
<p>
<strong>Số tiền:</strong>{' '}
<span className="text-indigo-600
font-bold"
>
{formatPrice(booking.total_price)}
</span>
</p>
<p>
<strong>Nội dung:</strong>{' '}
<span className="font-mono
text-indigo-600"
>
{booking.booking_number}
</span>
</p>
</div>
</div>
</div>
</div>
)}
{/* Important Notes */}
<div
className="bg-yellow-50 border border-yellow-200
rounded-lg p-4 mb-6"
>
<p className="text-sm text-yellow-800 font-medium
mb-2"
>
Important Notice
</p>
<ul className="text-sm text-yellow-700 space-y-1
ml-4 list-disc"
>
<li>
Please bring your ID card when checking in
</li>
<li>
Check-in time: 14:00 /
Check-out time: 12:00
</li>
{canCancelBooking(booking) && (
<li>
If you cancel the booking, 20% of
the total order value will be charged
</li>
)}
</ul>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Payment Button for unpaid bank transfer */}
{booking.payment_method === 'bank_transfer' &&
booking.payment_status === 'unpaid' && (
<Link
to={`/payment/${booking.id}`}
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-green-600 text-white rounded-lg
hover:bg-green-700 transition-colors
font-semibold"
>
<CreditCard className="w-5 h-5" />
Xác nhận thanh toán
</Link>
)}
{canCancelBooking(booking) && (
<button
onClick={handleCancelBooking}
disabled={cancelling}
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-red-600 text-white rounded-lg
hover:bg-red-700 transition-colors
font-semibold disabled:bg-gray-400
disabled:cursor-not-allowed"
>
{cancelling ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
/>
Đang hủy...
</>
) : (
<>
<XCircle className="w-5 h-5" />
Hủy đt phòng
</>
)}
</button>
)}
<Link
to="/bookings"
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-gray-600 text-white rounded-lg
hover:bg-gray-700 transition-colors
font-semibold"
>
Quay lại danh sách
</Link>
</div>
</div>
</div>
);
};
export default BookingDetailPage;

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { Calendar, Clock, DollarSign } from 'lucide-react';
const BookingListPage: React.FC = () => {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Lịch sử đt phòng
</h1>
<p className="text-gray-600">
Quản theo dõi các đt phòng của bạn
</p>
</div>
{/* Booking List */}
<div className="space-y-4">
{[1, 2, 3].map((booking) => (
<div key={booking}
className="bg-white rounded-lg shadow-md
p-6 hover:shadow-lg transition-shadow"
>
<div className="flex flex-col md:flex-row
md:items-center md:justify-between"
>
<div className="flex-1">
<div className="flex items-center
space-x-3 mb-3"
>
<h3 className="text-xl font-semibold
text-gray-800"
>
Phòng {booking}01 - Deluxe
</h3>
<span className="px-3 py-1
bg-green-100 text-green-800
rounded-full text-sm font-medium"
>
Đã xác nhận
</span>
</div>
<div className="grid grid-cols-1
md:grid-cols-3 gap-4 text-sm
text-gray-600"
>
<div className="flex items-center
space-x-2"
>
<Calendar className="w-4 h-4
text-blue-500"
/>
<span>
Nhận phòng: 15/11/2025
</span>
</div>
<div className="flex items-center
space-x-2"
>
<Calendar className="w-4 h-4
text-blue-500"
/>
<span>
Trả phòng: 18/11/2025
</span>
</div>
<div className="flex items-center
space-x-2"
>
<Clock className="w-4 h-4
text-blue-500"
/>
<span>3 đêm</span>
</div>
</div>
</div>
<div className="mt-4 md:mt-0
md:ml-6 flex flex-col items-end
space-y-3"
>
<div className="flex items-center
space-x-2"
>
<DollarSign className="w-5 h-5
text-green-600"
/>
<span className="text-2xl font-bold
text-gray-800"
>
${booking * 150}
</span>
</div>
<button className="px-4 py-2
bg-blue-600 text-white rounded-lg
hover:bg-blue-700 transition-colors
text-sm"
>
Xem chi tiết
</button>
</div>
</div>
</div>
))}
</div>
{/* Empty State */}
{/* Uncomment khi không có booking
<div className="text-center py-12">
<p className="text-gray-500 text-lg">
Bạn chưa có đặt phòng nào
</p>
<button className="mt-4 px-6 py-3
bg-blue-600 text-white rounded-lg
hover:bg-blue-700 transition-colors"
>
Đặt phòng ngay
</button>
</div>
*/}
</div>
);
};
export default BookingListPage;

View File

@@ -0,0 +1,839 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import DatePicker from 'react-datepicker';
import {
Calendar,
Users,
CreditCard,
Building2,
FileText,
ArrowLeft,
AlertCircle,
Loader2,
CheckCircle,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getRoomById, type Room } from
'../../services/api/roomService';
import {
createBooking,
checkRoomAvailability,
type BookingData,
} from '../../services/api/bookingService';
import useAuthStore from '../../store/useAuthStore';
import {
bookingValidationSchema,
type BookingFormData
} from '../../validators/bookingValidator';
import Loading from '../../components/common/Loading';
const BookingPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated, userInfo } = useAuthStore();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Redirect if not authenticated
useEffect(() => {
if (!isAuthenticated) {
toast.error(
'Please login to make a booking'
);
navigate('/login', {
state: { from: `/booking/${id}` }
});
}
}, [isAuthenticated, navigate, id]);
// Fetch room details
useEffect(() => {
if (id && isAuthenticated) {
fetchRoomDetails(Number(id));
}
}, [id, isAuthenticated]);
const fetchRoomDetails = async (roomId: number) => {
try {
setLoading(true);
setError(null);
const response = await getRoomById(roomId);
if (
(response.success ||
(response as any).status === 'success') &&
response.data?.room
) {
setRoom(response.data.room);
} else {
throw new Error('Unable to load room information');
}
} catch (err: any) {
console.error('Error fetching room:', err);
const message =
err.response?.data?.message ||
'Unable to load room information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
// Set up form with default values
const {
control,
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<BookingFormData>({
resolver: yupResolver(bookingValidationSchema),
defaultValues: {
checkInDate: undefined,
checkOutDate: undefined,
guestCount: 1,
notes: '',
paymentMethod: 'cash',
fullName: userInfo?.name || '',
email: userInfo?.email || '',
phone: userInfo?.phone || '',
},
});
// Watch form values for calculations
const checkInDate = watch('checkInDate');
const checkOutDate = watch('checkOutDate');
const paymentMethod = watch('paymentMethod');
// Calculate number of nights and total price
const numberOfNights =
checkInDate && checkOutDate
? Math.ceil(
(checkOutDate.getTime() -
checkInDate.getTime()) /
(1000 * 60 * 60 * 24)
)
: 0;
const roomPrice =
room?.room_type?.base_price || 0;
const totalPrice = numberOfNights * roomPrice;
// Format price
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(price);
};
// Handle form submission
const onSubmit = async (data: BookingFormData) => {
if (!room) return;
try {
setSubmitting(true);
const checkInDateStr = data.checkInDate
.toISOString()
.split('T')[0];
const checkOutDateStr = data.checkOutDate
.toISOString()
.split('T')[0];
// Step 1: Check room availability
const availability = await checkRoomAvailability(
room.id,
checkInDateStr,
checkOutDateStr
);
if (!availability.available) {
toast.error(
availability.message ||
'Room is already booked during this time'
);
return;
}
// Step 2: Prepare booking data
const bookingData: BookingData = {
room_id: room.id,
check_in_date: checkInDateStr,
check_out_date: checkOutDateStr,
guest_count: data.guestCount,
notes: data.notes || '',
payment_method: data.paymentMethod,
total_price: totalPrice,
guest_info: {
full_name: data.fullName,
email: data.email,
phone: data.phone,
},
};
// Step 3: Create booking
const response = await createBooking(bookingData);
if (
response.success &&
response.data?.booking
) {
const bookingId = response.data.booking.id;
toast.success(
'🎉 Booking successful!',
{ icon: <CheckCircle className="text-green-500" /> }
);
// Navigate to success page
navigate(`/booking-success/${bookingId}`);
} else {
throw new Error(
response.message ||
'Unable to create booking'
);
}
} catch (err: any) {
console.error('Error creating booking:', err);
// Handle specific error cases
if (err.response?.status === 409) {
toast.error(
'❌ Room is already booked during this time. ' +
'Please select different dates.'
);
} else if (err.response?.status === 400) {
toast.error(
err.response?.data?.message ||
'Invalid booking information'
);
} else {
const message =
err.response?.data?.message ||
'Unable to book room. Please try again.';
toast.error(message);
}
} finally {
setSubmitting(false);
}
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !room) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error || 'Room not found'}
</p>
<button
onClick={() => navigate('/rooms')}
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
>
Back to Room List
</button>
</div>
</div>
</div>
);
}
const roomType = room.room_type;
if (!roomType) return null;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
{/* Back Button */}
<Link
to={`/rooms/${room.id}`}
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to room details</span>
</Link>
{/* Page Title */}
<h1
className="text-3xl font-bold text-gray-900 mb-8"
>
Book Room
</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Booking Form */}
<div className="lg:col-span-2">
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white rounded-lg shadow-md
p-6 space-y-6"
>
{/* Guest Information */}
<div>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Customer Information
</h2>
<div className="space-y-4">
{/* Full Name */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
>
Full Name
<span className="text-red-500">*</span>
</label>
<input
{...register('fullName')}
type="text"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
placeholder="Nguyễn Văn A"
/>
{errors.fullName && (
<p className="text-sm text-red-600 mt-1">
{errors.fullName.message}
</p>
)}
</div>
{/* Email & Phone */}
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
Email
<span className="text-red-500">*</span>
</label>
<input
{...register('email')}
type="email"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
placeholder="email@example.com"
/>
{errors.email && (
<p className="text-sm text-red-600
mt-1"
>
{errors.email.message}
</p>
)}
</div>
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
Phone Number
<span className="text-red-500">*</span>
</label>
<input
{...register('phone')}
type="tel"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
placeholder="0123456789"
/>
{errors.phone && (
<p className="text-sm text-red-600
mt-1"
>
{errors.phone.message}
</p>
)}
</div>
</div>
</div>
</div>
{/* Booking Details */}
<div className="border-t pt-6">
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Booking Details
</h2>
<div className="space-y-4">
{/* Date Range */}
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
{/* Check-in Date */}
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
<Calendar
className="w-4 h-4 inline mr-1"
/>
Check-in Date
<span className="text-red-500">*</span>
</label>
<Controller
control={control}
name="checkInDate"
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={new Date()}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-in date"
className="w-full px-4 py-2
border border-gray-300
rounded-lg focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
wrapperClassName="w-full"
/>
)}
/>
{errors.checkInDate && (
<p className="text-sm text-red-600
mt-1"
>
{errors.checkInDate.message}
</p>
)}
</div>
{/* Check-out Date */}
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
<Calendar
className="w-4 h-4 inline mr-1"
/>
Check-out Date
<span className="text-red-500">*</span>
</label>
<Controller
control={control}
name="checkOutDate"
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={
checkInDate || new Date()
}
selectsEnd
startDate={checkInDate}
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-out date"
className="w-full px-4 py-2
border border-gray-300
rounded-lg focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
wrapperClassName="w-full"
/>
)}
/>
{errors.checkOutDate && (
<p className="text-sm text-red-600
mt-1"
>
{errors.checkOutDate.message}
</p>
)}
</div>
</div>
{/* Guest Count */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
>
<Users
className="w-4 h-4 inline mr-1"
/>
Number of Guests
<span className="text-red-500">*</span>
</label>
<input
{...register('guestCount')}
type="number"
min="1"
max={roomType.capacity}
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
placeholder="1"
/>
<p className="text-sm text-gray-500 mt-1">
Maximum capacity: {roomType.capacity} guests
</p>
{errors.guestCount && (
<p className="text-sm text-red-600 mt-1">
{errors.guestCount.message}
</p>
)}
</div>
{/* Notes */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
>
<FileText
className="w-4 h-4 inline mr-1"
/>
Notes (optional)
</label>
<textarea
{...register('notes')}
rows={3}
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
placeholder="Special requests..."
/>
{errors.notes && (
<p className="text-sm text-red-600 mt-1">
{errors.notes.message}
</p>
)}
</div>
</div>
</div>
{/* Payment Method */}
<div className="border-t pt-6">
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Payment Method
</h2>
<div className="space-y-3">
{/* Cash */}
<label
className="flex items-start p-4
border-2 border-gray-200
rounded-lg cursor-pointer
hover:border-indigo-500
transition-colors"
>
<input
{...register('paymentMethod')}
type="radio"
value="cash"
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center
gap-2 mb-1"
>
<CreditCard
className="w-5 h-5
text-gray-600"
/>
<span className="font-medium
text-gray-900"
>
Pay on arrival
</span>
<span className="text-xs bg-orange-100
text-orange-700 px-2 py-0.5 rounded"
>
Requires 20% deposit
</span>
</div>
<p className="text-sm text-gray-600 mb-2">
Pay the remaining balance on arrival
</p>
<div className="bg-orange-50 border
border-orange-200 rounded p-2"
>
<p className="text-xs text-orange-800">
<strong>Note:</strong> You need to pay
<strong> 20% deposit</strong> via
bank transfer immediately after booking to
secure the room. Pay the remaining balance
on arrival.
</p>
</div>
</div>
</label>
{/* Bank Transfer */}
<label
className="flex items-start p-4
border-2 border-gray-200
rounded-lg cursor-pointer
hover:border-indigo-500
transition-colors"
>
<input
{...register('paymentMethod')}
type="radio"
value="bank_transfer"
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center
gap-2 mb-1"
>
<Building2
className="w-5 h-5
text-gray-600"
/>
<span className="font-medium
text-gray-900"
>
Bank Transfer
</span>
</div>
<p className="text-sm text-gray-600">
Transfer via QR code or
account number
</p>
</div>
</label>
{errors.paymentMethod && (
<p className="text-sm text-red-600">
{errors.paymentMethod.message}
</p>
)}
{/* Bank Transfer Info */}
{paymentMethod === 'bank_transfer' && (
<div
className="bg-blue-50 border
border-blue-200 rounded-lg
p-4 mt-3"
>
<p className="text-sm text-blue-800
font-medium mb-2"
>
📌 Bank Transfer Information
</p>
<p className="text-sm text-blue-700">
Scan QR code or transfer according to
the information after confirming the booking.
</p>
</div>
)}
</div>
</div>
{/* Submit Button */}
<div className="border-t pt-6">
<button
type="submit"
disabled={submitting}
className="w-full bg-indigo-600
text-white py-4 rounded-lg
hover:bg-indigo-700
transition-colors font-semibold
text-lg disabled:bg-gray-400
disabled:cursor-not-allowed
flex items-center justify-center
gap-2"
>
{submitting ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
/>
Processing...
</>
) : (
'Confirm Booking'
)}
</button>
</div>
</form>
</div>
{/* Booking Summary */}
<div className="lg:col-span-1">
<div
className="bg-white rounded-lg shadow-md
p-6 sticky top-8"
>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Booking Summary
</h2>
{/* Room Info */}
<div className="mb-4">
{roomType.images?.[0] && (
<img
src={roomType.images[0]}
alt={roomType.name}
className="w-full h-48 object-cover
rounded-lg mb-3"
/>
)}
<h3 className="font-bold text-gray-900">
{roomType.name}
</h3>
<p className="text-sm text-gray-600">
Room {room.room_number} - Floor {room.floor}
</p>
</div>
{/* Pricing Breakdown */}
<div className="border-t pt-4 space-y-2">
<div className="flex justify-between
text-sm"
>
<span className="text-gray-600">
Room price/night
</span>
<span className="font-medium">
{formatPrice(roomPrice)}
</span>
</div>
{numberOfNights > 0 && (
<div className="flex justify-between
text-sm"
>
<span className="text-gray-600">
Nights
</span>
<span className="font-medium">
{numberOfNights} night(s)
</span>
</div>
)}
<div
className="border-t pt-2 flex
justify-between text-lg
font-bold"
>
<span>Total</span>
<span className="text-indigo-600">
{numberOfNights > 0
? formatPrice(totalPrice)
: '---'}
</span>
</div>
{/* Deposit amount for cash payment */}
{paymentMethod === 'cash' && numberOfNights > 0 && (
<div className="bg-orange-50 border
border-orange-200 rounded-lg p-3 mt-2"
>
<div className="flex justify-between
items-center mb-1"
>
<span className="text-sm font-medium
text-orange-900"
>
Deposit to pay (20%)
</span>
<span className="text-lg font-bold
text-orange-700"
>
{formatPrice(totalPrice * 0.2)}
</span>
</div>
<p className="text-xs text-orange-700">
Pay via bank transfer to confirm booking
</p>
</div>
)}
</div>
{/* Note */}
<div
className={`border rounded-lg p-3 mt-4 ${
paymentMethod === 'cash'
? 'bg-orange-50 border-orange-200'
: 'bg-yellow-50 border-yellow-200'
}`}
>
{paymentMethod === 'cash' ? (
<p className="text-xs text-orange-800">
🔒 <strong>Required:</strong> Pay 20% deposit
via bank transfer after booking.
Remaining balance ({formatPrice(totalPrice * 0.8)})
to be paid on arrival.
</p>
) : (
<p className="text-xs text-yellow-800">
💡 Scan QR code or transfer according to the information
after confirming the booking.
</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default BookingPage;

View File

@@ -0,0 +1,826 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import {
CheckCircle,
Home,
ListOrdered,
Calendar,
Users,
CreditCard,
MapPin,
Mail,
Phone,
User,
FileText,
Building2,
AlertCircle,
Copy,
Check,
Loader2,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getBookingById,
generateQRCode,
type Booking,
} from '../../services/api/bookingService';
import { confirmBankTransfer } from
'../../services/api/paymentService';
import Loading from '../../components/common/Loading';
const BookingSuccessPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [booking, setBooking] = useState<Booking | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedBookingNumber, setCopiedBookingNumber] =
useState(false);
const [uploadingReceipt, setUploadingReceipt] =
useState(false);
const [receiptUploaded, setReceiptUploaded] =
useState(false);
const [selectedFile, setSelectedFile] =
useState<File | null>(null);
const [previewUrl, setPreviewUrl] =
useState<string | null>(null);
useEffect(() => {
if (id) {
fetchBookingDetails(Number(id));
}
}, [id]);
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
setError(null);
const response = await getBookingById(bookingId);
if (
response.success &&
response.data?.booking
) {
const bookingData = response.data.booking;
setBooking(bookingData);
// Redirect to deposit payment page if required and not yet paid
if (
bookingData.requires_deposit &&
!bookingData.deposit_paid
) {
navigate(`/deposit-payment/${bookingId}`, { replace: true });
return;
}
} else {
throw new Error(
'Không thể tải thông tin đặt phòng'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Không thể tải thông tin đặt phòng';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(price);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'checked_in':
return 'bg-blue-100 text-blue-800';
case 'checked_out':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'confirmed':
return 'Đã xác nhận';
case 'pending':
return 'Chờ xác nhận';
case 'cancelled':
return 'Đã hủy';
case 'checked_in':
return 'Đã nhận phòng';
case 'checked_out':
return 'Đã trả phòng';
default:
return status;
}
};
const copyBookingNumber = async () => {
if (!booking?.booking_number) return;
try {
await navigator.clipboard.writeText(
booking.booking_number
);
setCopiedBookingNumber(true);
toast.success('Đã sao chép mã đặt phòng');
setTimeout(() => setCopiedBookingNumber(false), 2000);
} catch (err) {
toast.error('Không thể sao chép');
}
};
const handleFileSelect = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Vui lòng chọn file ảnh');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('Kích thước ảnh không được vượt quá 5MB');
return;
}
setSelectedFile(file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleUploadReceipt = async () => {
if (!selectedFile || !booking) return;
try {
setUploadingReceipt(true);
// Generate transaction ID based on booking number
const transactionId =
`TXN-${booking.booking_number}-${Date.now()}`;
const response = await confirmBankTransfer(
booking.id,
transactionId,
selectedFile
);
if (response.success) {
toast.success(
'✅ Đã gửi xác nhận thanh toán thành công! ' +
'Chúng tôi sẽ xác nhận trong thời gian sớm nhất.'
);
setReceiptUploaded(true);
// Update booking payment status locally
setBooking((prev) =>
prev
? {
...prev,
payment_status: 'paid',
status: prev.status === 'pending'
? 'confirmed'
: prev.status
}
: null
);
} else {
throw new Error(
response.message ||
'Không thể xác nhận thanh toán'
);
}
} catch (err: any) {
console.error('Error uploading receipt:', err);
const message =
err.response?.data?.message ||
'Không thể gửi xác nhận thanh toán. ' +
'Vui lòng thử lại.';
toast.error(message);
} finally {
setUploadingReceipt(false);
}
};
const qrCodeUrl = booking
? generateQRCode(
booking.booking_number,
booking.total_price
)
: null;
if (loading) {
return <Loading fullScreen text="Đang tải..." />;
}
if (error || !booking) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error || 'Không tìm thấy đặt phòng'}
</p>
<button
onClick={() => navigate('/rooms')}
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
>
Quay lại danh sách phòng
</button>
</div>
</div>
</div>
);
}
const room = booking.room;
const roomType = room?.room_type;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Success Header */}
<div
className="bg-white rounded-lg shadow-md
p-8 mb-6 text-center"
>
<div
className="w-20 h-20 bg-green-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<CheckCircle
className="w-12 h-12 text-green-600"
/>
</div>
<h1
className="text-3xl font-bold text-gray-900
mb-2"
>
Đt phòng thành công!
</h1>
<p className="text-gray-600 mb-4">
Cảm ơn bạn đã đt phòng tại khách sạn của chúng
tôi
</p>
{/* Booking Number */}
<div
className="inline-flex items-center gap-2
bg-indigo-50 px-6 py-3 rounded-lg"
>
<span className="text-sm text-indigo-600
font-medium"
>
đt phòng:
</span>
<span className="text-lg font-bold
text-indigo-900"
>
{booking.booking_number}
</span>
<button
onClick={copyBookingNumber}
className="ml-2 p-1 hover:bg-indigo-100
rounded transition-colors"
title="Sao chép mã"
>
{copiedBookingNumber ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-indigo-600" />
)}
</button>
</div>
{/* Status Badge */}
<div className="mt-4">
<span
className={`inline-block px-4 py-2
rounded-full text-sm font-medium
${getStatusColor(booking.status)}`}
>
{getStatusText(booking.status)}
</span>
</div>
</div>
{/* Booking Details */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Chi tiết đt phòng
</h2>
<div className="space-y-4">
{/* Room Information */}
{roomType && (
<div className="border-b pb-4">
<div className="flex items-start gap-4">
{roomType.images?.[0] && (
<img
src={roomType.images[0]}
alt={roomType.name}
className="w-24 h-24 object-cover
rounded-lg"
/>
)}
<div className="flex-1">
<h3 className="font-bold text-lg
text-gray-900"
>
{roomType.name}
</h3>
{room && (
<p className="text-gray-600 text-sm">
<MapPin className="w-4 h-4
inline mr-1"
/>
Phòng {room.room_number} -
Tầng {room.floor}
</p>
)}
<p className="text-indigo-600
font-semibold mt-1"
>
{formatPrice(roomType.base_price)}/đêm
</p>
</div>
</div>
</div>
)}
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2
gap-4"
>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Ngày nhận phòng
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_in_date)}
</p>
</div>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Ngày trả phòng
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_out_date)}
</p>
</div>
</div>
{/* Guest Count */}
<div>
<p className="text-sm text-gray-600 mb-1">
<Users className="w-4 h-4 inline mr-1" />
Số người
</p>
<p className="font-medium text-gray-900">
{booking.guest_count} người
</p>
</div>
{/* Notes */}
{booking.notes && (
<div>
<p className="text-sm text-gray-600 mb-1">
<FileText className="w-4 h-4 inline mr-1" />
Ghi chú
</p>
<p className="font-medium text-gray-900">
{booking.notes}
</p>
</div>
)}
{/* Payment Method */}
<div className="border-t pt-4">
<p className="text-sm text-gray-600 mb-1">
<CreditCard className="w-4 h-4 inline mr-1" />
Phương thức thanh toán
</p>
<p className="font-medium text-gray-900">
{booking.payment_method === 'cash'
? '💵 Thanh toán tại chỗ'
: '🏦 Chuyển khoản ngân hàng'}
</p>
</div>
{/* Total Price */}
<div className="border-t pt-4">
<div className="flex justify-between
items-center"
>
<span className="text-lg font-semibold
text-gray-900"
>
Tổng thanh toán
</span>
<span className="text-2xl font-bold
text-indigo-600"
>
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</div>
</div>
{/* Guest Information */}
{booking.guest_info && (
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Thông tin khách hàng
</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">
<User className="w-4 h-4 inline mr-1" />
Họ tên
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.full_name}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Mail className="w-4 h-4 inline mr-1" />
Email
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.email}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Phone className="w-4 h-4 inline mr-1" />
Số điện thoại
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.phone}
</p>
</div>
</div>
</div>
)}
{/* Bank Transfer Instructions */}
{booking.payment_method === 'bank_transfer' && (
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-6 mb-6"
>
<div className="flex items-start gap-3 mb-4">
<Building2
className="w-6 h-6 text-blue-600
mt-1 flex-shrink-0"
/>
<div className="flex-1">
<h3 className="font-bold text-blue-900 mb-2">
Hướng dẫn chuyển khoản
</h3>
<div className="space-y-2 text-sm
text-blue-800"
>
<p>
Vui lòng chuyển khoản theo thông tin sau:
</p>
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
{/* Bank Info */}
<div className="bg-white rounded-lg
p-4 space-y-2"
>
<p>
<strong>Ngân hàng:</strong>
Vietcombank (VCB)
</p>
<p>
<strong>Số tài khoản:</strong>
0123456789
</p>
<p>
<strong>Chủ tài khoản:</strong>
KHACH SAN ABC
</p>
<p>
<strong>Số tiền:</strong>{' '}
<span className="text-indigo-600
font-bold"
>
{formatPrice(booking.total_price)}
</span>
</p>
<p>
<strong>Nội dung:</strong>{' '}
<span className="font-mono
text-indigo-600"
>
{booking.booking_number}
</span>
</p>
</div>
{/* QR Code */}
{qrCodeUrl && (
<div className="bg-white rounded-lg
p-4 flex flex-col items-center
justify-center"
>
<p className="text-sm font-medium
text-gray-700 mb-2"
>
Quét QR đ chuyển khoản
</p>
<img
src={qrCodeUrl}
alt="QR Code"
className="w-48 h-48 border-2
border-gray-200 rounded-lg"
/>
<p className="text-xs text-gray-500
mt-2 text-center"
>
QR đã bao gồm đy đ thông tin
</p>
</div>
)}
</div>
<p className="text-xs italic mt-2">
💡 Lưu ý: Vui lòng ghi đúng đt phòng
vào nội dung chuyển khoản đ chúng tôi
thể xác nhận thanh toán của bạn.
</p>
</div>
</div>
</div>
{/* Upload Receipt Section */}
{!receiptUploaded ? (
<div className="border-t border-blue-200
pt-4"
>
<h4 className="font-semibold text-blue-900
mb-3"
>
📎 Xác nhận thanh toán
</h4>
<p className="text-sm text-blue-700 mb-3">
Sau khi chuyển khoản, vui lòng tải lên
nh biên lai đ chúng tôi xác nhận nhanh
hơn.
</p>
<div className="space-y-3">
{/* File Input */}
<div>
<label
htmlFor="receipt-upload"
className="block w-full px-4 py-3
border-2 border-dashed
border-blue-300 rounded-lg
text-center cursor-pointer
hover:border-blue-400
hover:bg-blue-100/50
transition-colors"
>
<input
id="receipt-upload"
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex flex-col
items-center gap-2"
>
{previewUrl ? (
<>
<img
src={previewUrl}
alt="Preview"
className="w-32 h-32
object-cover rounded"
/>
<p className="text-sm
text-blue-600 font-medium"
>
{selectedFile?.name}
</p>
<p className="text-xs
text-gray-500"
>
Click đ chọn nh khác
</p>
</>
) : (
<>
<FileText
className="w-8 h-8
text-blue-400"
/>
<p className="text-sm
text-blue-600 font-medium"
>
Chọn nh biên lai
</p>
<p className="text-xs
text-gray-500"
>
PNG, JPG, JPEG (Tối đa 5MB)
</p>
</>
)}
</div>
</label>
</div>
{/* Upload Button */}
{selectedFile && (
<button
onClick={handleUploadReceipt}
disabled={uploadingReceipt}
className="w-full px-4 py-3
bg-blue-600 text-white
rounded-lg hover:bg-blue-700
transition-colors font-semibold
disabled:bg-gray-400
disabled:cursor-not-allowed
flex items-center
justify-center gap-2"
>
{uploadingReceipt ? (
<>
<Loader2
className="w-5 h-5
animate-spin"
/>
Đang gửi...
</>
) : (
<>
<CheckCircle
className="w-5 h-5"
/>
Xác nhận đã thanh toán
</>
)}
</button>
)}
</div>
</div>
) : (
<div className="border-t border-green-200
pt-4 bg-green-50 rounded-lg p-4"
>
<div className="flex items-center
gap-3"
>
<CheckCircle
className="w-6 h-6 text-green-600
flex-shrink-0"
/>
<div>
<p className="font-semibold
text-green-900"
>
Payment confirmation sent
</p>
<p className="text-sm text-green-700">
We will confirm your order
as soon as possible.
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Important Notice */}
<div
className="bg-yellow-50 border border-yellow-200
rounded-lg p-4 mb-6"
>
<p className="text-sm text-yellow-800">
<strong>Important Notice:</strong>
</p>
<ul className="text-sm text-yellow-700 mt-2
space-y-1 ml-4 list-disc"
>
<li>
Please bring your ID card when checking in
</li>
<li>
Check-in time: 14:00 /
Check-out time: 12:00
</li>
<li>
If you cancel the booking, 20% of
the total order value will be charged
</li>
{booking.payment_method === 'bank_transfer' && (
<li>
Please transfer within 24 hours
to secure your room
</li>
)}
</ul>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4">
<Link
to="/bookings"
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
<ListOrdered className="w-5 h-5" />
Xem đơn của tôi
</Link>
<Link
to="/"
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-gray-600 text-white rounded-lg
hover:bg-gray-700 transition-colors
font-semibold"
>
<Home className="w-5 h-5" />
Về trang chủ
</Link>
</div>
</div>
</div>
);
};
export default BookingSuccessPage;

View File

@@ -0,0 +1,243 @@
import React from 'react';
import {
TrendingUp,
Hotel,
DollarSign,
Calendar,
Activity
} from 'lucide-react';
const DashboardPage: React.FC = () => {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Dashboard
</h1>
<p className="text-gray-600">
Tổng quan hoạt đng của bạn
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-4 gap-6 mb-8"
>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
<div className="p-3 bg-blue-100 rounded-lg">
<Calendar className="w-6 h-6
text-blue-600"
/>
</div>
<span className="text-sm text-green-600
font-medium"
>
+12%
</span>
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
Tổng đt phòng
</h3>
<p className="text-3xl font-bold text-gray-800">
45
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
<div className="p-3 bg-green-100 rounded-lg">
<DollarSign className="w-6 h-6
text-green-600"
/>
</div>
<span className="text-sm text-green-600
font-medium"
>
+8%
</span>
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
Tổng chi tiêu
</h3>
<p className="text-3xl font-bold text-gray-800">
$12,450
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
<div className="p-3 bg-purple-100 rounded-lg">
<Hotel className="w-6 h-6 text-purple-600" />
</div>
<span className="text-sm text-green-600
font-medium"
>
Active
</span>
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
Đang
</h3>
<p className="text-3xl font-bold text-gray-800">
2
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
<div className="p-3 bg-orange-100 rounded-lg">
<TrendingUp className="w-6 h-6
text-orange-600"
/>
</div>
<span className="text-sm text-green-600
font-medium"
>
+15%
</span>
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
Điểm thưởng
</h3>
<p className="text-3xl font-bold text-gray-800">
1,250
</p>
</div>
</div>
{/* Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2
gap-6"
>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<h2 className="text-xl font-semibold
text-gray-800 mb-4"
>
Hoạt đng gần đây
</h2>
<div className="space-y-4">
{[
{
action: 'Đặt phòng',
room: 'Phòng 201',
time: '2 giờ trước'
},
{
action: 'Check-in',
room: 'Phòng 105',
time: '1 ngày trước'
},
{
action: 'Check-out',
room: 'Phòng 302',
time: '3 ngày trước'
},
].map((activity, index) => (
<div key={index}
className="flex items-center space-x-4
pb-4 border-b border-gray-200
last:border-0"
>
<div className="p-2 bg-blue-100
rounded-lg"
>
<Activity className="w-5 h-5
text-blue-600"
/>
</div>
<div className="flex-1">
<p className="font-medium text-gray-800">
{activity.action}
</p>
<p className="text-sm text-gray-500">
{activity.room}
</p>
</div>
<span className="text-sm text-gray-400">
{activity.time}
</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<h2 className="text-xl font-semibold
text-gray-800 mb-4"
>
Đt phòng sắp tới
</h2>
<div className="space-y-4">
{[
{
room: 'Phòng 401',
date: '20/11/2025',
status: 'Đã xác nhận'
},
{
room: 'Phòng 203',
date: '25/11/2025',
status: 'Chờ xác nhận'
},
].map((booking, index) => (
<div key={index}
className="flex items-center
justify-between pb-4 border-b
border-gray-200 last:border-0"
>
<div>
<p className="font-medium text-gray-800">
{booking.room}
</p>
<p className="text-sm text-gray-500">
{booking.date}
</p>
</div>
<span className={`px-3 py-1 rounded-full
text-xs font-medium
${booking.status === 'Đã xác nhận'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{booking.status}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,546 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
AlertCircle,
CreditCard,
Building2,
Copy,
Check,
Loader2,
ArrowLeft,
Download,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, type Booking } from
'../../services/api/bookingService';
import {
getPaymentsByBookingId,
getBankTransferInfo,
notifyPaymentCompletion,
type Payment,
type BankInfo,
} from '../../services/api/paymentService';
import Loading from '../../components/common/Loading';
const DepositPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate();
const [booking, setBooking] = useState<Booking | null>(null);
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
const [bankInfo, setBankInfo] = useState<BankInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notifying, setNotifying] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<
'bank_transfer' | null
>('bank_transfer');
useEffect(() => {
if (bookingId) {
fetchData(Number(bookingId));
}
}, [bookingId]);
const fetchData = async (id: number) => {
try {
setLoading(true);
setError(null);
// Fetch booking details
const bookingResponse = await getBookingById(id);
if (!bookingResponse.success || !bookingResponse.data?.booking) {
throw new Error('Không tìm thấy booking');
}
const bookingData = bookingResponse.data.booking;
setBooking(bookingData);
// Check if booking requires deposit
if (!bookingData.requires_deposit) {
toast.info('Booking này không yêu cầu đặt cọc');
navigate(`/bookings/${id}`);
return;
}
// Fetch payments
const paymentsResponse = await getPaymentsByBookingId(id);
if (paymentsResponse.success) {
const deposit = paymentsResponse.data.payments.find(
(p) => p.payment_type === 'deposit'
);
if (deposit) {
setDepositPayment(deposit);
// If payment is pending, fetch bank info
if (deposit.payment_status === 'pending') {
const bankInfoResponse = await getBankTransferInfo(deposit.id);
if (bankInfoResponse.success && bankInfoResponse.data.bank_info) {
setBankInfo(bankInfoResponse.data.bank_info);
}
}
}
}
} catch (err: any) {
console.error('Error fetching data:', err);
const message =
err.response?.data?.message || 'Không thể tải thông tin thanh toán';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(price);
};
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedText(label);
toast.success(`Copied ${label}`);
setTimeout(() => setCopiedText(null), 2000);
} catch (err) {
toast.error('Unable to copy');
}
};
// No auto-redirect payment methods. Default to bank transfer.
const handleNotifyPayment = async () => {
if (!depositPayment) return;
try {
setNotifying(true);
const response = await notifyPaymentCompletion(
depositPayment.id,
'Customer has transferred deposit'
);
if (response.success) {
toast.success(
'✅ Payment notification sent! ' +
'We will confirm within 24 hours.'
);
navigate(`/bookings/${bookingId}`);
} else {
throw new Error(response.message || 'Unable to send notification');
}
} catch (err: any) {
console.error('Error notifying payment:', err);
const message =
err.response?.data?.message ||
'Unable to send notification. Please try again.';
toast.error(message);
} finally {
setNotifying(false);
}
};
// VNPay removed: no online redirect handler
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !booking || !depositPayment) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
<p className="text-red-700 font-medium mb-4">
{error || 'Không tìm thấy thông tin thanh toán'}
</p>
<Link
to="/bookings"
className="inline-flex items-center gap-2 px-6 py-2
bg-red-600 text-white rounded-lg hover:bg-red-700
transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Quay lại danh sách booking
</Link>
</div>
</div>
</div>
);
}
const depositAmount = parseFloat(depositPayment.amount.toString());
const remainingAmount = booking.total_price - depositAmount;
const isDepositPaid = depositPayment.payment_status === 'completed';
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to={`/bookings/${bookingId}`}
className="inline-flex items-center gap-2 text-gray-600
hover:text-gray-900 mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Quay lại chi tiết booking</span>
</Link>
{/* Success Header (if paid) */}
{isDepositPaid && (
<div
className="bg-green-50 border-2 border-green-200
rounded-lg p-6 mb-6"
>
<div className="flex items-center gap-4">
<div
className="w-16 h-16 bg-green-100 rounded-full
flex items-center justify-center"
>
<CheckCircle className="w-10 h-10 text-green-600" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-green-900 mb-1">
Đã thanh toán đt cọc thành công!
</h1>
<p className="text-green-700">
Booking của bạn đã đưc xác nhận.
Phần còn lại thanh toán khi nhận phòng.
</p>
</div>
</div>
</div>
)}
{/* Pending Header */}
{!isDepositPaid && (
<div
className="bg-orange-50 border-2 border-orange-200
rounded-lg p-6 mb-6"
>
<div className="flex items-center gap-4">
<div
className="w-16 h-16 bg-orange-100 rounded-full
flex items-center justify-center"
>
<CreditCard className="w-10 h-10 text-orange-600" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-orange-900 mb-1">
Thanh toán tiền đt cọc
</h1>
<p className="text-orange-700">
Vui lòng thanh toán <strong>20% tiền cọc</strong> đ
xác nhận đt phòng
</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Payment Info */}
<div className="lg:col-span-2 space-y-6">
{/* Payment Summary */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Thông tin thanh toán
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Tổng tiền phòng</span>
<span className="font-medium">
{formatPrice(booking.total_price)}
</span>
</div>
<div
className="flex justify-between border-t pt-3
text-orange-600"
>
<span className="font-medium">
Tiền cọc cần thanh toán (20%)
</span>
<span className="text-xl font-bold">
{formatPrice(depositAmount)}
</span>
</div>
<div className="flex justify-between text-sm text-gray-500">
<span>Phần còn lại thanh toán khi nhận phòng</span>
<span>{formatPrice(remainingAmount)}</span>
</div>
</div>
{isDepositPaid && (
<div className="mt-4 bg-green-50 border border-green-200 rounded p-3">
<p className="text-sm text-green-800">
Đã thanh toán tiền cọc vào:{' '}
{depositPayment.payment_date
? new Date(depositPayment.payment_date).toLocaleString('en-US')
: 'N/A'}
</p>
{depositPayment.transaction_id && (
<p className="text-xs text-green-700 mt-1">
giao dịch: {depositPayment.transaction_id}
</p>
)}
</div>
)}
</div>
{/* Payment Method Selection */}
{!isDepositPaid && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6">
Chọn phương thức thanh toán
</h2>
{/* Payment Method Buttons */}
<div className="grid grid-cols-2 gap-4 mb-6">
{/* Bank Transfer Button */}
<button
onClick={() => setSelectedPaymentMethod('bank_transfer')}
className={`p-4 border-2 rounded-lg transition-all
${
selectedPaymentMethod === 'bank_transfer'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 bg-white hover:border-indigo-300'
}`}
>
<Building2
className={`w-8 h-8 mx-auto mb-2 ${
selectedPaymentMethod === 'bank_transfer'
? 'text-indigo-600'
: 'text-gray-600'
}`}
/>
<div
className={`font-bold text-sm ${
selectedPaymentMethod === 'bank_transfer'
? 'text-indigo-900'
: 'text-gray-700'
}`}
>
Chuyển khoản
</div>
<div className="text-xs text-gray-500 mt-1">
Chuyển khoản ngân hàng
</div>
</button>
{/* VNPay removed */}
</div>
</div>
)}
{/* Bank Transfer Instructions or VNPay panel */}
{!isDepositPaid && selectedPaymentMethod === 'bank_transfer' &&
bankInfo && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<Building2 className="w-5 h-5 inline mr-2" />
Thông tin chuyển khoản
</h2>
<div className="space-y-4">
{/* Bank Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Ngân hàng</div>
<div className="font-medium">{bankInfo.bank_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.bank_name, 'tên ngân hàng')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'tên ngân hàng' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Số tài khoản</div>
<div className="font-medium font-mono">
{bankInfo.account_number}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_number, 'số tài khoản')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'số tài khoản' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Chủ tài khoản</div>
<div className="font-medium">{bankInfo.account_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_name, 'chủ tài khoản')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'chủ tài khoản' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-orange-50 border border-orange-200 rounded">
<div>
<div className="text-xs text-orange-700">Số tiền</div>
<div className="text-lg font-bold text-orange-600">
{formatPrice(bankInfo.amount)}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.amount.toString(), 'số tiền')
}
className="p-2 hover:bg-orange-100 rounded transition-colors"
>
{copiedText === 'số tiền' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-orange-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Nội dung chuyển khoản</div>
<div className="font-medium font-mono text-red-600">
{bankInfo.content}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.content, 'nội dung')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'nội dung' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<p className="text-sm text-yellow-800">
<strong> Lưu ý:</strong> Vui lòng nhập đúng nội dung chuyển khoản đ
hệ thống tự đng xác nhận thanh toán.
</p>
</div>
{/* Notify Button */}
<button
onClick={handleNotifyPayment}
disabled={notifying}
className="w-full bg-indigo-600 text-white py-3 rounded-lg
hover:bg-indigo-700 transition-colors font-semibold
disabled:bg-gray-400 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{notifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Đang gửi...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
Tôi đã chuyển khoản
</>
)}
</button>
<p className="text-xs text-center text-gray-500 mt-2">
Sau khi chuyển khoản, nhấn nút trên đ thông báo cho chúng tôi
</p>
</div>
</div>
)}
{/* VNPay removed */}
</div>
{/* QR Code Sidebar */}
{!isDepositPaid &&
bankInfo &&
selectedPaymentMethod === 'bank_transfer' && (
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-md p-6 sticky top-8">
<h3 className="text-lg font-bold text-gray-900 mb-4 text-center">
Quét QR đ thanh toán
</h3>
<div className="bg-gray-50 p-4 rounded-lg mb-4">
<img
src={bankInfo.qr_url}
alt="QR Code"
className="w-full h-auto rounded"
/>
</div>
<div className="text-center space-y-2">
<p className="text-sm text-gray-600">
Quét QR bằng app ngân hàng
</p>
<p className="text-xs text-gray-500">
Thông tin chuyển khoản đã đưc điền tự đng
</p>
</div>
<a
href={bankInfo.qr_url}
download={`deposit-qr-${booking.booking_number}.jpg`}
className="mt-4 w-full inline-flex items-center justify-center
gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200
text-gray-700 rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Tải QR
</a>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default DepositPaymentPage;

View File

@@ -0,0 +1,203 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Heart, AlertCircle, ArrowLeft } from 'lucide-react';
import { RoomCard, RoomCardSkeleton } from
'../../components/rooms';
import useFavoritesStore from
'../../store/useFavoritesStore';
import useAuthStore from '../../store/useAuthStore';
const FavoritesPage: React.FC = () => {
const { isAuthenticated } = useAuthStore();
const {
favorites,
isLoading,
error,
fetchFavorites
} = useFavoritesStore();
useEffect(() => {
if (isAuthenticated) {
fetchFavorites();
}
}, [isAuthenticated, fetchFavorites]);
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div
className="bg-yellow-50 border
border-yellow-200 rounded-lg
p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-yellow-500
mx-auto mb-3"
/>
<h3
className="text-xl font-bold
text-gray-900 mb-2"
>
Vui lòng đăng nhập
</h3>
<p className="text-gray-600 mb-4">
Bạn cần đăng nhập đ xem danh sách yêu thích
</p>
<Link
to="/login"
className="inline-block px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
Đăng nhập
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<Link
to="/"
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Quay lại trang chủ</span>
</Link>
<div className="flex items-center gap-3">
<Heart
className="w-8 h-8 text-red-500"
fill="currentColor"
/>
<div>
<h1
className="text-3xl font-bold
text-gray-900"
>
Danh sách yêu thích
</h1>
<p className="text-gray-600 mt-1">
{favorites.length > 0
? `${favorites.length} phòng`
: 'Chưa có phòng yêu thích'}
</p>
</div>
</div>
</div>
{/* Loading State */}
{isLoading && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{/* Error State */}
{error && !isLoading && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error}
</p>
<button
onClick={fetchFavorites}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Thử lại
</button>
</div>
)}
{/* Empty State */}
{!isLoading &&
!error &&
favorites.length === 0 && (
<div
className="bg-white rounded-lg shadow-sm
p-12 text-center"
>
<div
className="w-24 h-24 bg-gray-100
rounded-full flex items-center
justify-center mx-auto mb-6"
>
<Heart
className="w-12 h-12 text-gray-400"
/>
</div>
<h3
className="text-2xl font-bold
text-gray-900 mb-3"
>
Chưa phòng yêu thích
</h3>
<p
className="text-gray-600 mb-6
max-w-md mx-auto"
>
Bạn chưa thêm phòng nào vào danh sách
yêu thích. Hãy khám phá lưu những
phòng bạn thích!
</p>
<Link
to="/rooms"
className="inline-block px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
Khám phá phòng
</Link>
</div>
)}
{/* Favorites Grid */}
{!isLoading &&
!error &&
favorites.length > 0 && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{favorites.map((favorite) =>
favorite.room ? (
<RoomCard
key={favorite.id}
room={favorite.room}
/>
) : null
)}
</div>
)}
</div>
</div>
);
};
export default FavoritesPage;

View File

@@ -0,0 +1,639 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Calendar,
MapPin,
Users,
CreditCard,
Eye,
XCircle,
AlertCircle,
CheckCircle,
Clock,
DoorOpen,
DoorClosed,
Loader2,
Search,
Filter,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getMyBookings,
cancelBooking,
type Booking,
} from '../../services/api/bookingService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import EmptyState from '../../components/common/EmptyState';
const MyBookingsPage: React.FC = () => {
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const [bookings, setBookings] = useState<Booking[]>([]);
const [filteredBookings, setFilteredBookings] =
useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancellingId, setCancellingId] =
useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] =
useState<string>('all');
// Redirect if not authenticated
useEffect(() => {
if (!isAuthenticated) {
toast.error('Please login to view your bookings');
navigate('/login', {
state: { from: '/bookings' }
});
}
}, [isAuthenticated, navigate]);
// Fetch bookings
useEffect(() => {
if (isAuthenticated) {
fetchBookings();
}
}, [isAuthenticated]);
// Filter bookings
useEffect(() => {
let filtered = [...bookings];
// Filter by status
if (statusFilter !== 'all') {
filtered = filtered.filter(
(b) => b.status === statusFilter
);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(b) =>
b.booking_number.toLowerCase().includes(query) ||
b.room?.room_type?.name
.toLowerCase()
.includes(query) ||
b.room?.room_number
.toString()
.includes(query)
);
}
setFilteredBookings(filtered);
}, [bookings, statusFilter, searchQuery]);
const fetchBookings = async () => {
try {
setLoading(true);
setError(null);
const response = await getMyBookings();
if (
response.success &&
response.data?.bookings
) {
setBookings(response.data.bookings);
} else {
throw new Error(
'Unable to load bookings list'
);
}
} catch (err: any) {
console.error('Error fetching bookings:', err);
const message =
err.response?.data?.message ||
'Không thể tải danh sách đặt phòng';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const handleCancelBooking = async (
bookingId: number,
bookingNumber: string
) => {
const confirmed = window.confirm(
`Are you sure you want to cancel booking ${bookingNumber}?\n\n` +
`⚠️ Note:\n` +
`- You will be charged 20% of the order value\n` +
`- The remaining 80% will be refunded\n` +
`- Room status will be updated to "available"`
);
if (!confirmed) return;
try {
setCancellingId(bookingId);
const response = await cancelBooking(bookingId);
if (response.success) {
toast.success(
`✅ Successfully cancelled booking ${bookingNumber}!`
);
// Update local state
setBookings((prev) =>
prev.map((b) =>
b.id === bookingId
? { ...b, status: 'cancelled' }
: b
)
);
} else {
throw new Error(
response.message ||
'Unable to cancel booking'
);
}
} catch (err: any) {
console.error('Error cancelling booking:', err);
const message =
err.response?.data?.message ||
'Unable to cancel booking. Please try again.';
toast.error(message);
} finally {
setCancellingId(null);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(price);
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
icon: Clock,
color: 'bg-yellow-100 text-yellow-800',
text: 'Pending confirmation',
};
case 'confirmed':
return {
icon: CheckCircle,
color: 'bg-green-100 text-green-800',
text: 'Confirmed',
};
case 'cancelled':
return {
icon: XCircle,
color: 'bg-red-100 text-red-800',
text: 'Cancelled',
};
case 'checked_in':
return {
icon: DoorOpen,
color: 'bg-blue-100 text-blue-800',
text: 'Checked in',
};
case 'checked_out':
return {
icon: DoorClosed,
color: 'bg-gray-100 text-gray-800',
text: 'Checked out',
};
default:
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: status,
};
}
};
const canCancelBooking = (booking: Booking) => {
// Can only cancel pending or confirmed bookings
return (
booking.status === 'pending' ||
booking.status === 'confirmed'
);
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900
mb-2"
>
My Bookings
</h1>
<p className="text-gray-600">
Manage and track your bookings
</p>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-md
p-4 mb-6"
>
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search
className="absolute left-3 top-1/2
-translate-y-1/2 w-5 h-5
text-gray-400"
/>
<input
type="text"
placeholder="Search by booking number, room name..."
value={searchQuery}
onChange={(e) =>
setSearchQuery(e.target.value)
}
className="w-full pl-10 pr-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
/>
</div>
</div>
{/* Status Filter */}
<div className="md:w-64">
<div className="relative">
<Filter
className="absolute left-3 top-1/2
-translate-y-1/2 w-5 h-5
text-gray-400"
/>
<select
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value)
}
className="w-full pl-10 pr-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500
appearance-none bg-white"
>
<option value="all">All statuses</option>
<option value="pending">Pending confirmation</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">
Checked in
</option>
<option value="checked_out">
Checked out
</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
{/* Results count */}
<div className="mt-3 text-sm text-gray-600">
Showing {filteredBookings.length} /
{bookings.length} bookings
</div>
</div>
{/* Error State */}
{error && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-6 mb-6 flex items-start gap-3"
>
<AlertCircle
className="w-6 h-6 text-red-500
flex-shrink-0 mt-0.5"
/>
<div>
<p className="text-red-700 font-medium">
{error}
</p>
<button
onClick={fetchBookings}
className="mt-2 text-sm text-red-600
hover:text-red-800 underline"
>
Try again
</button>
</div>
</div>
)}
{/* Bookings List */}
{filteredBookings.length === 0 ? (
<EmptyState
icon={Calendar}
title={
searchQuery || statusFilter !== 'all'
? 'No bookings found'
: 'No bookings yet'
}
description={
searchQuery || statusFilter !== 'all'
? 'Try changing filters or search keywords'
: 'Start booking to enjoy your vacation'
}
>
{!searchQuery && statusFilter === 'all' ? (
<Link
to="/rooms"
className="inline-flex items-center
gap-2 px-6 py-3 bg-indigo-600
text-white rounded-lg
hover:bg-indigo-700
transition-colors font-semibold"
>
View room list
</Link>
) : (
<button
onClick={() => {
setSearchQuery('');
setStatusFilter('all');
}}
className="px-6 py-3 bg-gray-600
text-white rounded-lg
hover:bg-gray-700 transition-colors
font-semibold"
>
Clear filters
</button>
)}
</EmptyState>
) : (
<div className="space-y-4">
{filteredBookings.map((booking) => {
const statusConfig = getStatusConfig(
booking.status
);
const StatusIcon = statusConfig.icon;
const room = booking.room;
const roomType = room?.room_type;
return (
<div
key={booking.id}
className="bg-white rounded-lg shadow-md
hover:shadow-lg transition-shadow
overflow-hidden"
>
<div className="p-6">
<div className="flex flex-col
lg:flex-row gap-6"
>
{/* Room Image */}
{roomType?.images?.[0] && (
<div className="lg:w-48 flex-shrink-0">
<img
src={roomType.images[0]}
alt={roomType.name}
className="w-full h-48 lg:h-full
object-cover rounded-lg"
/>
</div>
)}
{/* Booking Info */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start
justify-between gap-4 mb-3"
>
<div>
<h3 className="text-xl font-bold
text-gray-900 mb-1"
>
{roomType?.name || 'N/A'}
</h3>
<p className="text-sm
text-gray-600"
>
<MapPin
className="w-4 h-4 inline
mr-1"
/>
Room {room?.room_number} -
Floor {room?.floor}
</p>
</div>
{/* Status Badge */}
<div
className={`flex items-center
gap-2 px-3 py-1.5 rounded-full
text-sm font-medium
${statusConfig.color}`}
>
<StatusIcon
className="w-4 h-4"
/>
{statusConfig.text}
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-1
sm:grid-cols-2 gap-3 mb-4"
>
{/* Booking Number */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
Booking number
</p>
<p className="font-medium
text-gray-900 font-mono"
>
{booking.booking_number}
</p>
</div>
{/* Check-in */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<Calendar
className="w-3 h-3 inline
mr-1"
/>
Check-in date
</p>
<p className="font-medium
text-gray-900"
>
{formatDate(
booking.check_in_date
)}
</p>
</div>
{/* Check-out */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<Calendar
className="w-3 h-3 inline
mr-1"
/>
Check-out date
</p>
<p className="font-medium
text-gray-900"
>
{formatDate(
booking.check_out_date
)}
</p>
</div>
{/* Guest Count */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<Users
className="w-3 h-3 inline
mr-1"
/>
Guests
</p>
<p className="font-medium
text-gray-900"
>
{booking.guest_count} guest(s)
</p>
</div>
{/* Payment Method */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<CreditCard
className="w-3 h-3 inline
mr-1"
/>
Payment
</p>
<p className="font-medium
text-gray-900"
>
{booking.payment_method === 'cash'
? 'On-site'
: 'Bank transfer'}
</p>
</div>
{/* Total Price */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
Total price
</p>
<p className="font-bold
text-indigo-600 text-lg"
>
{formatPrice(booking.total_price)}
</p>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3
pt-4 border-t"
>
{/* View Details */}
<Link
to={`/bookings/${booking.id}`}
className="inline-flex items-center
gap-2 px-4 py-2
bg-indigo-600 text-white
rounded-lg hover:bg-indigo-700
transition-colors font-medium
text-sm"
>
<Eye className="w-4 h-4" />
View details
</Link>
{/* Cancel Booking */}
{canCancelBooking(booking) && (
<button
onClick={() =>
handleCancelBooking(
booking.id,
booking.booking_number
)
}
disabled={
cancellingId === booking.id
}
className="inline-flex
items-center gap-2 px-4 py-2
bg-red-600 text-white
rounded-lg hover:bg-red-700
transition-colors font-medium
text-sm disabled:bg-gray-400
disabled:cursor-not-allowed"
>
{cancellingId === booking.id ? (
<>
<Loader2
className="w-4 h-4
animate-spin"
/>
Cancelling...
</>
) : (
<>
<XCircle
className="w-4 h-4"
/>
Cancel booking
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default MyBookingsPage;

View File

@@ -0,0 +1,536 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import {
ArrowLeft,
Building2,
Upload,
CheckCircle,
AlertCircle,
Loader2,
Copy,
Check,
FileText,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getBookingById,
generateQRCode,
type Booking,
} from '../../services/api/bookingService';
import { confirmBankTransfer } from
'../../services/api/paymentService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
const PaymentConfirmationPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const [booking, setBooking] = useState<Booking | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [selectedFile, setSelectedFile] =
useState<File | null>(null);
const [previewUrl, setPreviewUrl] =
useState<string | null>(null);
const [copiedBookingNumber, setCopiedBookingNumber] =
useState(false);
useEffect(() => {
if (!isAuthenticated) {
toast.error(
'Please login to confirm payment'
);
navigate('/login');
}
}, [isAuthenticated, navigate]);
useEffect(() => {
if (id && isAuthenticated) {
fetchBookingDetails(Number(id));
}
}, [id, isAuthenticated]);
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
setError(null);
const response = await getBookingById(bookingId);
if (
response.success &&
response.data?.booking
) {
const bookingData = response.data.booking;
// Check if already paid
if (bookingData.payment_status === 'paid') {
toast.info('This booking has already been paid');
navigate(`/bookings/${bookingId}`);
return;
}
// Check if payment method is cash
if (bookingData.payment_method === 'cash') {
toast.info(
'This booking uses on-site payment method'
);
navigate(`/bookings/${bookingId}`);
return;
}
setBooking(bookingData);
} else {
throw new Error(
'Unable to load booking information'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Không thể tải thông tin đặt phòng';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'VND',
}).format(price);
};
const copyBookingNumber = async () => {
if (!booking?.booking_number) return;
try {
await navigator.clipboard.writeText(
booking.booking_number
);
setCopiedBookingNumber(true);
toast.success('Booking number copied');
setTimeout(() => setCopiedBookingNumber(false), 2000);
} catch (err) {
toast.error('Unable to copy');
}
};
const handleFileSelect = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error(
'Image size must not exceed 5MB'
);
return;
}
setSelectedFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleConfirmPayment = async () => {
if (!selectedFile || !booking) return;
try {
setUploading(true);
const transactionId =
`TXN-${booking.booking_number}-${Date.now()}`;
const response = await confirmBankTransfer(
booking.id,
transactionId,
selectedFile
);
if (response.success) {
toast.success(
'✅ Payment confirmation sent successfully!'
);
setUploadSuccess(true);
// Redirect after 2 seconds
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
} else {
throw new Error(
response.message ||
'Unable to confirm payment'
);
}
} catch (err: any) {
console.error('Error confirming payment:', err);
const message =
err.response?.data?.message ||
'Unable to send payment confirmation';
toast.error(message);
} finally {
setUploading(false);
}
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !booking) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error || 'Booking not found'}
</p>
<button
onClick={() => navigate('/bookings')}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Back to list
</button>
</div>
</div>
</div>
);
}
const qrCodeUrl = generateQRCode(
booking.booking_number,
booking.total_price
);
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to={`/bookings/${booking.id}`}
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to booking details</span>
</Link>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900
mb-2"
>
Payment Confirmation
</h1>
<p className="text-gray-600">
Complete payment for your booking
</p>
</div>
{/* Booking Info Card */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<div className="flex items-start justify-between
gap-4 mb-4"
>
<div>
<p className="text-sm text-gray-600 mb-1">
Booking Number
</p>
<div className="flex items-center gap-2">
<span className="text-xl font-bold
text-indigo-900 font-mono"
>
{booking.booking_number}
</span>
<button
onClick={copyBookingNumber}
className="p-1 hover:bg-gray-100
rounded transition-colors"
title="Copy"
>
{copiedBookingNumber ? (
<Check className="w-4 h-4
text-green-600"
/>
) : (
<Copy className="w-4 h-4
text-gray-400"
/>
)}
</button>
</div>
</div>
<PaymentStatusBadge
status={booking.payment_status}
size="md"
/>
</div>
<div className="border-t pt-4">
<div className="flex justify-between
items-center"
>
<span className="text-gray-600">
Total Payment
</span>
<span className="text-2xl font-bold
text-indigo-600"
>
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</div>
{!uploadSuccess ? (
<>
{/* Bank Transfer Instructions */}
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-6 mb-6"
>
<div className="flex items-start gap-3 mb-4">
<Building2
className="w-6 h-6 text-blue-600
mt-1 flex-shrink-0"
/>
<div className="flex-1">
<h3 className="font-bold text-blue-900
mb-3"
>
Bank Transfer Information
</h3>
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
{/* Bank Info */}
<div className="bg-white rounded-lg
p-4 space-y-2 text-sm"
>
<p>
<strong>Bank:</strong>
Vietcombank (VCB)
</p>
<p>
<strong>Account Number:</strong>
0123456789
</p>
<p>
<strong>Account Holder:</strong>
KHACH SAN ABC
</p>
<p>
<strong>Amount:</strong>{' '}
<span className="text-indigo-600
font-bold"
>
{formatPrice(booking.total_price)}
</span>
</p>
<p>
<strong>Content:</strong>{' '}
<span className="font-mono
text-indigo-600"
>
{booking.booking_number}
</span>
</p>
</div>
{/* QR Code */}
<div className="bg-white rounded-lg
p-4 flex flex-col items-center"
>
<p className="text-sm font-medium
text-gray-700 mb-2"
>
Scan QR code to transfer
</p>
<img
src={qrCodeUrl}
alt="QR Code"
className="w-48 h-48 border-2
border-gray-200 rounded-lg"
/>
</div>
</div>
</div>
</div>
</div>
{/* Upload Receipt Section */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h3 className="text-xl font-bold
text-gray-900 mb-4"
>
<Upload className="w-5 h-5 inline mr-2" />
Upload Payment Receipt
</h3>
<p className="text-gray-600 mb-4">
After transferring, please upload a receipt image
so we can confirm faster.
</p>
<div className="space-y-4">
{/* File Input */}
<label
htmlFor="receipt-upload"
className="block w-full px-4 py-6
border-2 border-dashed
border-gray-300 rounded-lg
text-center cursor-pointer
hover:border-indigo-400
hover:bg-indigo-50
transition-all"
>
<input
id="receipt-upload"
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
{previewUrl ? (
<div className="flex flex-col
items-center gap-3"
>
<img
src={previewUrl}
alt="Preview"
className="w-48 h-48 object-cover
rounded-lg border-2
border-indigo-200"
/>
<p className="text-sm text-indigo-600
font-medium"
>
{selectedFile?.name}
</p>
<p className="text-xs text-gray-500">
Click to select another image
</p>
</div>
) : (
<div className="flex flex-col
items-center gap-2"
>
<FileText
className="w-12 h-12 text-gray-400"
/>
<p className="text-sm text-gray-600
font-medium"
>
Click to select receipt image
</p>
<p className="text-xs text-gray-500">
PNG, JPG, JPEG (Max 5MB)
</p>
</div>
)}
</label>
{/* Upload Button */}
{selectedFile && (
<button
onClick={handleConfirmPayment}
disabled={uploading}
className="w-full px-6 py-4
bg-indigo-600 text-white
rounded-lg hover:bg-indigo-700
transition-colors font-semibold
text-lg disabled:bg-gray-400
disabled:cursor-not-allowed
flex items-center justify-center
gap-2"
>
{uploading ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
/>
Processing...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
Confirm Payment
</>
)}
</button>
)}
</div>
</div>
</>
) : (
<div className="bg-green-50 border
border-green-200 rounded-lg p-8
text-center"
>
<CheckCircle
className="w-16 h-16 text-green-600
mx-auto mb-4"
/>
<h3 className="text-2xl font-bold
text-green-900 mb-2"
>
Confirmation sent successfully!
</h3>
<p className="text-green-700 mb-4">
We will confirm your payment as soon as possible.
</p>
<p className="text-sm text-green-600">
Redirecting...
</p>
</div>
)}
</div>
</div>
);
};
export default PaymentConfirmationPage;

View File

@@ -0,0 +1,251 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
XCircle,
AlertCircle,
Home,
Receipt,
Loader2,
} from 'lucide-react';
const PaymentResultPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [countdown, setCountdown] = useState(10);
const status = searchParams.get('status');
const bookingId = searchParams.get('bookingId');
const transactionId = searchParams.get('transactionId');
const message = searchParams.get('message');
useEffect(() => {
if (status === 'success' && bookingId) {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
navigate(`/bookings/${bookingId}`);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [status, bookingId, navigate]);
const getStatusContent = () => {
switch (status) {
case 'success':
return {
icon: <CheckCircle className="w-20 h-20 text-green-500" />,
title: 'Payment Successful!',
description:
'Thank you for your payment. Your booking has been confirmed.',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-800',
};
case 'failed':
return {
icon: <XCircle className="w-20 h-20 text-red-500" />,
title: 'Payment Failed',
description: message ||
'Transaction was not successful. Please try again.',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
textColor: 'text-red-800',
};
case 'invalid_signature':
return {
icon: <AlertCircle className="w-20 h-20 text-orange-500" />,
title: 'Authentication Error',
description:
'Unable to verify transaction. Please contact support.',
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
textColor: 'text-orange-800',
};
case 'payment_not_found':
return {
icon: <AlertCircle className="w-20 h-20 text-gray-500" />,
title: 'Payment Not Found',
description:
'Payment information not found. ' +
'Please check again.',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
textColor: 'text-gray-800',
};
default:
return {
icon: <AlertCircle className="w-20 h-20 text-gray-500" />,
title: 'Unknown Error',
description: message ||
'An error occurred. Please try again later.',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
textColor: 'text-gray-800',
};
}
};
const content = getStatusContent();
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-2xl mx-auto px-4">
<div
className={`${content.bgColor} border-2
${content.borderColor} rounded-lg p-8`}
>
{/* Icon */}
<div className="flex justify-center mb-6">
{content.icon}
</div>
{/* Title */}
<h1
className={`text-3xl font-bold text-center
mb-4 ${content.textColor}`}
>
{content.title}
</h1>
{/* Description */}
<p className="text-center text-gray-700 mb-6">
{content.description}
</p>
{/* Transaction Details */}
{status === 'success' && transactionId && (
<div
className="bg-white border border-gray-200
rounded-lg p-4 mb-6"
>
<div className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">
Transaction ID
</span>
<span className="font-medium font-mono">
{transactionId}
</span>
</div>
{bookingId && (
<div className="flex justify-between">
<span className="text-gray-600">
Booking ID
</span>
<span className="font-medium">
#{bookingId}
</span>
</div>
)}
</div>
</div>
)}
{/* Auto redirect notice for success */}
{status === 'success' && bookingId && countdown > 0 && (
<div className="text-center mb-6">
<div className="flex items-center justify-center gap-2
text-gray-600"
>
<Loader2 className="w-4 h-4 animate-spin" />
<span>
Auto redirecting to booking details in {countdown}s...
</span>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3">
{status === 'success' && bookingId ? (
<>
<Link
to={`/bookings/${bookingId}`}
className="flex-1 flex items-center justify-center
gap-2 px-6 py-3 bg-green-600 text-white
rounded-lg hover:bg-green-700
transition-colors font-medium"
>
<Receipt className="w-5 h-5" />
View booking details
</Link>
<Link
to="/"
className="flex-1 flex items-center justify-center
gap-2 px-6 py-3 bg-white text-gray-700
border-2 border-gray-300 rounded-lg
hover:bg-gray-50 transition-colors
font-medium"
>
<Home className="w-5 h-5" />
Go to home
</Link>
</>
) : status === 'failed' && bookingId ? (
<>
<Link
to={`/deposit-payment/${bookingId}`}
className="flex-1 px-6 py-3 bg-indigo-600
text-white rounded-lg hover:bg-indigo-700
transition-colors font-medium text-center"
>
Retry payment
</Link>
<Link
to="/bookings"
className="flex-1 px-6 py-3 bg-white
text-gray-700 border-2 border-gray-300
rounded-lg hover:bg-gray-50
transition-colors font-medium text-center"
>
Booking list
</Link>
</>
) : (
<Link
to="/"
className="w-full flex items-center
justify-center gap-2 px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-medium"
>
<Home className="w-5 h-5" />
Về trang chủ
</Link>
)}
</div>
</div>
{/* Support Notice */}
<div className="mt-6 text-center text-sm text-gray-600">
<p>
If you have any issues, please contact{' '}
<a
href="mailto:support@hotel.com"
className="text-indigo-600 hover:underline"
>
support@hotel.com
</a>{' '}
or call{' '}
<a
href="tel:1900xxxx"
className="text-indigo-600 hover:underline"
>
1900 xxxx
</a>
</p>
</div>
</div>
</div>
);
};
export default PaymentResultPage;

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
Users,
MapPin,
DollarSign,
ArrowLeft,
} from 'lucide-react';
import { getRoomById, type Room } from
'../../services/api/roomService';
import RoomGallery from '../../components/rooms/RoomGallery';
import RoomAmenities from '../../components/rooms/RoomAmenities';
import ReviewSection from '../../components/rooms/ReviewSection';
import RatingStars from '../../components/rooms/RatingStars';
const RoomDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
fetchRoomDetail(Number(id));
}
}, [id]);
const fetchRoomDetail = async (roomId: number) => {
try {
setLoading(true);
setError(null);
const response = await getRoomById(roomId);
// backend uses `status: 'success'` (not `success`), accept both
if ((response as any).success || (response as any).status === 'success') {
if (response.data && response.data.room) {
setRoom(response.data.room);
} else {
throw new Error('Failed to fetch room details');
}
} else {
throw new Error('Failed to fetch room details');
}
} catch (err: any) {
console.error('Error fetching room:', err);
const message =
err.response?.data?.message ||
'Không thể tải thông tin phòng';
setError(message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-96 bg-gray-300 rounded-lg" />
<div className="h-8 bg-gray-300 rounded w-1/3" />
<div className="h-4 bg-gray-300 rounded w-2/3" />
<div className="h-32 bg-gray-300 rounded" />
</div>
</div>
</div>
);
}
if (error || !room) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<p className="text-red-800 font-medium mb-4">
{error || 'Room not found'}
</p>
<button
onClick={() => navigate('/rooms')}
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
>
Back to Room List
</button>
</div>
</div>
</div>
);
}
const roomType = room.room_type;
const formattedPrice = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(roomType?.base_price || 0);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Back Button */}
<Link
to="/rooms"
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Quay lại danh sách phòng</span>
</Link>
{/* Image Gallery */}
<div className="mb-8">
<RoomGallery
images={roomType?.images || []}
roomName={roomType?.name || 'Room'}
/>
</div>
{/* Room Information */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-12">
{/* Main Info */}
<div className="lg:col-span-8 space-y-6">
{/* Title & Basic Info */}
<div>
<h1 className="text-4xl font-bold
text-gray-900 mb-4"
>
{roomType?.name}
</h1>
<div className="flex flex-wrap items-center
gap-6 text-gray-600 mb-4"
>
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
<span>
Phòng {room.room_number} - Tầng {room.floor}
</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-5 h-5" />
<span>
{roomType?.capacity || 0} người
</span>
</div>
{room.average_rating != null && (
<div className="flex items-center gap-2">
<RatingStars
rating={Number(room.average_rating)}
size="sm"
showNumber
/>
<span className="text-sm text-gray-500">
({room.total_reviews || 0} đánh giá)
</span>
</div>
)}
</div>
{/* Status Badge */}
<div
className={`inline-block px-4 py-2
rounded-full text-sm font-semibold
${
room.status === 'available'
? 'bg-green-100 text-green-800'
: room.status === 'occupied'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{room.status === 'available'
? 'Còn phòng'
: room.status === 'occupied'
? 'Đã đặt'
: 'Bảo trì'}
</div>
</div>
{/* Description */}
{roomType?.description && (
<div>
<h2 className="text-2xl font-bold
text-gray-900 mb-4"
>
tả phòng
</h2>
<p className="text-gray-700 leading-relaxed">
{roomType.description}
</p>
</div>
)}
{/* Amenities */}
<div>
<h2 className="text-2xl font-bold
text-gray-900 mb-4"
>
Tiện ích
</h2>
<RoomAmenities
amenities={
(room.amenities && room.amenities.length > 0)
? room.amenities
: (roomType?.amenities || [])
}
/>
</div>
</div>
{/* Booking Card */}
<aside className="lg:col-span-4">
<div className="bg-white rounded-xl shadow-md p-6 sticky top-6">
<div className="flex items-baseline gap-3 mb-4">
<DollarSign className="w-5 h-5 text-gray-600" />
<div>
<div className="text-3xl font-extrabold text-indigo-600">
{formattedPrice}
</div>
<div className="text-sm text-gray-500">/ đêm</div>
</div>
</div>
<div className="mt-4">
<Link
to={`/booking/${room.id}`}
className={`block w-full py-3 text-center font-semibold rounded-md transition-colors ${
room.status === 'available'
? 'bg-indigo-600 text-white hover:bg-indigo-700'
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
}`}
onClick={(e) => {
if (room.status !== 'available') e.preventDefault();
}}
>
{room.status === 'available' ? 'Đặt ngay' : 'Không khả dụng'}
</Link>
</div>
{room.status === 'available' && (
<p className="text-sm text-gray-500 text-center mt-3">
Không bị tính phí ngay thanh toán tại khách sạn
</p>
)}
<hr className="my-4" />
<div className="text-sm text-gray-700 space-y-2">
<div className="flex items-center justify-between">
<span>Loại phòng</span>
<strong>{roomType?.name}</strong>
</div>
<div className="flex items-center justify-between">
<span>Số khách</span>
<span>{roomType?.capacity} người</span>
</div>
<div className="flex items-center justify-between">
<span>Số phòng</span>
<span>1</span>
</div>
</div>
</div>
</aside>
</div>
{/* Reviews Section */}
<div className="mb-12">
<ReviewSection roomId={room.id} />
</div>
</div>
</div>
);
};
export default RoomDetailPage;

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { getRooms } from '../../services/api/roomService';
import type { Room } from '../../services/api/roomService';
import RoomFilter from '../../components/rooms/RoomFilter';
import RoomCard from '../../components/rooms/RoomCard';
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
import Pagination from '../../components/rooms/Pagination';
import { ArrowLeft } from 'lucide-react';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 10,
totalPages: 1,
});
// Fetch rooms based on URL params
useEffect(() => {
const fetchRooms = async () => {
setLoading(true);
setError(null);
try {
const params = {
type: searchParams.get('type') || undefined,
minPrice: searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined,
maxPrice: searchParams.get('maxPrice')
? Number(searchParams.get('maxPrice'))
: undefined,
capacity: searchParams.get('capacity')
? Number(searchParams.get('capacity'))
: undefined,
page: searchParams.get('page')
? Number(searchParams.get('page'))
: 1,
limit: 12,
};
const response = await getRooms(params);
if (response.status === 'success' && response.data) {
setRooms(response.data.rooms || []);
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
throw new Error('Failed to fetch rooms');
}
} catch (err) {
console.error('Error fetching rooms:', err);
setError('Không thể tải danh sách phòng. Vui lòng thử lại.');
} finally {
setLoading(false);
}
};
fetchRooms();
}, [searchParams]);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Quay lại trang chủ</span>
</Link>
<div className="mb-10">
<h1 className="text-3xl text-center font-bold text-gray-900">
Danh sách phòng
</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<aside className="lg:col-span-1">
<RoomFilter />
</aside>
<main className="lg:col-span-3">
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-3 gap-6"
>
{Array.from({ length: 6 }).map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{error && !loading && (
<div className="bg-red-50 border border-red-200
rounded-lg p-6 text-center"
>
<svg
className="w-12 h-12 text-red-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0
9 9 0 0118 0z"
/>
</svg>
<p className="text-red-800 font-medium">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-600
text-white rounded-lg hover:bg-red-700
transition-colors"
>
Thử lại
</button>
</div>
)}
{!loading && !error && rooms.length === 0 && (
<div className="bg-white rounded-lg shadow-md
p-12 text-center"
>
<svg
className="w-24 h-24 text-gray-300 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14
0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1
4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 className="text-xl font-semibold
text-gray-800 mb-2"
>
Không tìm thấy phòng phù hợp
</h3>
<p className="text-gray-600 mb-6">
Vui lòng thử điều chỉnh bộ lọc hoặc tìm kiếm khác
</p>
<button
onClick={() => window.location.href = '/rooms'}
className="px-6 py-2 bg-blue-600 text-white
rounded-lg hover:bg-blue-700 transition-colors"
>
Xóa bộ lọc
</button>
</div>
)}
{!loading && !error && rooms.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-2 gap-6"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</>
)}
</main>
</div>
</div>
</div>
);
};
export default RoomListPage;

View File

@@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import {
useSearchParams,
useNavigate,
Link
} from 'react-router-dom';
import {
Search,
Calendar,
AlertCircle,
ArrowLeft,
Home,
Users,
} from 'lucide-react';
import {
RoomCard,
RoomCardSkeleton,
Pagination,
} from '../../components/rooms';
import { searchAvailableRooms } from
'../../services/api/roomService';
import type { Room } from '../../services/api/roomService';
import { toast } from 'react-toastify';
const SearchResultsPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 12,
totalPages: 1,
});
// Get search params
const from = searchParams.get('from') || '';
const to = searchParams.get('to') || '';
const type = searchParams.get('type') || '';
const capacityParam = searchParams.get('capacity') || '';
const capacity = capacityParam ? Number(capacityParam) : undefined;
const pageParam = searchParams.get('page') || '';
const page = pageParam ? Number(pageParam) : 1;
useEffect(() => {
// Validate required params
if (!from || !to) {
toast.error(
'Missing search information. ' +
'Please select check-in and check-out dates.'
);
navigate('/');
return;
}
fetchAvailableRooms();
}, [from, to, type, capacity, page]);
const fetchAvailableRooms = async () => {
try {
setLoading(true);
setError(null);
const response = await searchAvailableRooms({
from,
to,
type: type || undefined,
capacity: capacity || undefined,
page,
limit: 12,
});
if (
response.success ||
response.status === 'success'
) {
setRooms(response.data.rooms || []);
if (response.data.pagination) {
setPagination(response.data.pagination);
} else {
// Fallback compute
const total = response.data.rooms
? response.data.rooms.length
: 0;
const limit = 12;
setPagination({
total,
page,
limit,
totalPages: Math.max(1, Math.ceil(total / limit)),
});
}
} else {
throw new Error('Unable to search rooms');
}
} catch (err: any) {
console.error('Error searching rooms:', err);
const message =
err.response?.data?.message ||
'Unable to search available rooms';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to home</span>
</Link>
{/* Search Info Header */}
<div
className="bg-white rounded-lg shadow-sm
p-6 mb-8"
>
<div className="flex items-start justify-between
flex-wrap gap-4"
>
<div>
<h1
className="text-3xl font-bold
text-gray-900 mb-4"
>
Search Results
</h1>
<div
className="flex flex-wrap items-center
gap-4 text-gray-700"
>
<div
className="flex items-center gap-2"
>
<Calendar className="w-5 h-5
text-indigo-600"
/>
<span>
<strong>Check-in:</strong>{' '}
{formatDate(from)}
</span>
</div>
<div
className="flex items-center gap-2"
>
<Calendar className="w-5 h-5
text-indigo-600"
/>
<span>
<strong>Check-out:</strong>{' '}
{formatDate(to)}
</span>
</div>
{type && (
<div
className="flex items-center gap-2"
>
<Home className="w-5 h-5
text-indigo-600"
/>
<span>
<strong>Room Type:</strong>{' '}
{type}
</span>
</div>
)}
{capacity && (
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-600" />
<span>
<strong>Guests:</strong>{' '}
{capacity}
</span>
</div>
)}
</div>
</div>
<button
onClick={() => navigate('/')}
className="px-4 py-2 border border-gray-300
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 disabled:bg-gray-400
transition-colors flex items-center gap-2"
>
<Search className="w-4 h-4" />
New Search
</button>
</div>
</div>
{/* Loading State */}
{loading && (
<div>
<p
className="text-gray-600 mb-6
text-center animate-pulse"
>
Searching for available rooms...
</p>
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
</div>
)}
{/* Error State */}
{error && !loading && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error}
</p>
<button
onClick={fetchAvailableRooms}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Try Again
</button>
</div>
)}
{/* Results */}
{!loading && !error && (
<>
{rooms.length > 0 ? (
<>
<div
className="flex items-center
justify-between mb-6"
>
</div>
<div
className="grid grid-cols-1
md:grid-cols-2 lg:grid-cols-3
gap-6"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</>
) : (
// Empty State
<div
className="bg-white rounded-lg
shadow-sm p-12 text-center"
>
<div
className="w-24 h-24 bg-gray-100
rounded-full flex items-center
justify-center mx-auto mb-6"
>
<Search
className="w-12 h-12 text-gray-400"
/>
</div>
<h3
className="text-2xl font-bold
text-gray-900 mb-3"
>
No matching rooms found
</h3>
<p
className="text-gray-600 mb-6
max-w-md mx-auto"
>
Sorry, there are no available rooms
for the selected dates.
Please try searching with different dates
or room types.
</p>
<div
className="flex flex-col sm:flex-row
gap-3 justify-center"
>
<button
onClick={() => navigate('/')}
className="px-6 py-3 bg-indigo-600
text-white rounded-lg
hover:bg-indigo-700
transition-colors font-semibold
inline-flex items-center
justify-center gap-2"
>
<Search className="w-5 h-5" />
Search Again
</button>
<Link
to="/rooms"
className="px-6 py-3 border
border-gray-300 text-gray-700
rounded-lg hover:bg-gray-50
transition-colors font-semibold
inline-flex items-center
justify-center gap-2"
>
View All Rooms
</Link>
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
export default SearchResultsPage;

View File

@@ -0,0 +1,86 @@
import axios from 'axios';
// Base URL từ environment hoặc mặc định. Ensure it points to the
// server API root (append '/api' if not provided) so frontend calls
// like '/bookings/me' resolve to e.g. 'http://localhost:3000/api/bookings/me'.
const rawBase = import.meta.env.VITE_API_URL || 'http://localhost:3000';
// Normalize base and ensure a single /api suffix. If the provided
// VITE_API_URL already points to the API root (contains '/api'),
// don't append another '/api'.
const normalized = String(rawBase).replace(/\/$/, '');
const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
? normalized
: normalized + '/api';
// Tạo axios instance
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
withCredentials: true, // Enable sending cookies
});
// Request interceptor - Thêm token vào header
apiClient.interceptors.request.use(
(config) => {
// Normalize request URL: if a request path accidentally begins
// with '/api', strip that prefix so it will be appended to
// our baseURL exactly once. This prevents double '/api/api'
// when code uses absolute '/api/...' paths.
if (config.url && typeof config.url === 'string') {
if (config.url.startsWith('/api/')) {
config.url = config.url.replace(/^\/api/, '');
}
// Also avoid accidental double slashes after concatenation
config.url = config.url.replace(/\/\/+/, '/');
}
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - Handle common errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// Handle network errors
if (!error.response) {
console.error('Network error:', error);
// You can show a toast notification here
return Promise.reject({
...error,
message: 'Network error. Please check ' +
'your internet connection.',
});
}
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
window.location.href = '/login';
}
// Handle other HTTP errors
if (error.response?.status >= 500) {
console.error('Server error:', error);
return Promise.reject({
...error,
message: 'Server error. Please try again later.',
});
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,137 @@
import apiClient from './apiClient';
// Types
export interface LoginCredentials {
email: string;
password: string;
rememberMe?: boolean;
}
export interface RegisterData {
name: string;
email: string;
password: string;
phone?: string;
}
export interface AuthResponse {
// Server may use `status: 'success'` or boolean `success`
status?: string;
success?: boolean;
message?: string;
data?: {
token?: string;
refreshToken?: string;
user?: {
id: number;
name: string;
email: string;
phone?: string;
avatar?: string;
role: string;
createdAt?: string;
};
};
}
export interface ForgotPasswordData {
email: string;
}
export interface ResetPasswordData {
token: string;
password: string;
confirmPassword: string;
}
/**
* Auth Service - Xử lý các API calls liên quan
* đến authentication
*/
const authService = {
/**
* Đăng nhập
*/
login: async (
credentials: LoginCredentials
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>(
'/api/auth/login',
credentials
);
return response.data;
},
/**
* Đăng ký tài khoản mới
*/
register: async (
data: RegisterData
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>(
'/api/auth/register',
data
);
return response.data;
},
/**
* Đăng xuất
*/
logout: async (): Promise<void> => {
try {
await apiClient.post('/api/auth/logout');
} catch (error) {
console.error('Logout error:', error);
}
},
/**
* Lấy thông tin user hiện tại
*/
getProfile: async (): Promise<AuthResponse> => {
const response = await apiClient.get<AuthResponse>(
'/api/auth/profile'
);
return response.data;
},
/**
* Refresh token
*/
refreshToken: async (): Promise<AuthResponse> => {
// No need to send refreshToken in body - it's in cookie
const response = await apiClient.post<AuthResponse>(
'/api/auth/refresh-token'
);
return response.data;
},
/**
* Quên mật khẩu - Gửi email reset
*/
forgotPassword: async (
data: ForgotPasswordData
): Promise<{ status?: string; success?: boolean; message?: string }> => {
const response = await apiClient.post(
'/api/auth/forgot-password',
data
);
return response.data;
},
/**
* Đặt lại mật khẩu
*/
resetPassword: async (
data: ResetPasswordData
): Promise<{ status?: string; success?: boolean; message?: string }> => {
const response = await apiClient.post(
'/api/auth/reset-password',
data
);
return response.data;
},
};
export default authService;

View File

@@ -0,0 +1,54 @@
import apiClient from './apiClient';
/**
* Banner API Service
*/
export interface Banner {
id: number;
title: string;
image_url: string;
link?: string;
position: string;
display_order: number;
is_active: boolean;
start_date?: string;
end_date?: string;
created_at: string;
updated_at: string;
}
export interface BannerListResponse {
success: boolean;
status?: string;
data: {
banners: Banner[];
};
message?: string;
}
/**
* Get banners by position
*/
export const getBannersByPosition = async (
position: string = 'home'
): Promise<BannerListResponse> => {
const response = await apiClient.get('/banners', {
params: { position },
});
return response.data;
};
/**
* Get all active banners
*/
export const getActiveBanners = async ():
Promise<BannerListResponse> => {
const response = await apiClient.get('/banners');
return response.data;
};
export default {
getBannersByPosition,
getActiveBanners,
};

View File

@@ -0,0 +1,314 @@
import apiClient from './apiClient';
// Types
export interface BookingData {
room_id: number;
check_in_date: string; // YYYY-MM-DD
check_out_date: string; // YYYY-MM-DD
guest_count: number;
notes?: string;
payment_method: 'cash' | 'bank_transfer';
total_price: number;
guest_info: {
full_name: string;
email: string;
phone: string;
};
}
export interface Booking {
id: number;
booking_number: string;
user_id: number;
room_id: number;
check_in_date: string;
check_out_date: string;
guest_count: number;
total_price: number;
status:
| 'pending'
| 'confirmed'
| 'cancelled'
| 'checked_in'
| 'checked_out';
payment_method: 'cash' | 'bank_transfer';
payment_status:
| 'unpaid'
| 'paid'
| 'refunded';
deposit_paid?: boolean;
requires_deposit?: boolean;
notes?: string;
guest_info?: {
full_name: string;
email: string;
phone: string;
};
room?: {
id: number;
room_number: string;
floor: number;
status: string;
room_type: {
id: number;
name: string;
base_price: number;
capacity: number;
images?: string[];
};
};
user?: {
id: number;
name: string;
full_name: string;
email: string;
phone?: string;
phone_number?: string;
};
payments?: Payment[];
createdAt: string;
updatedAt: string;
}
export interface Payment {
id: number;
booking_id: number;
amount: number;
payment_method: string;
payment_type: 'full' | 'deposit' | 'remaining';
deposit_percentage?: number;
payment_status: 'pending' | 'completed' | 'failed' | 'refunded';
transaction_id?: string;
payment_date?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface BookingResponse {
success: boolean;
data: {
booking: Booking;
};
message?: string;
}
export interface BookingsResponse {
success: boolean;
data: {
bookings: Booking[];
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
};
message?: string;
}
export interface CheckBookingResponse {
success: boolean;
data: {
booking: Booking;
};
message?: string;
}
/**
* Create a new booking
* POST /api/bookings
*/
export const createBooking = async (
bookingData: BookingData
): Promise<BookingResponse> => {
const response = await apiClient.post<BookingResponse>(
'/bookings',
bookingData
);
return response.data;
};
/**
* Get all bookings of the current user
* GET /api/bookings/me
*/
export const getMyBookings = async ():
Promise<BookingsResponse> => {
const response = await apiClient.get<BookingsResponse>(
'/bookings/me'
);
return response.data;
};
/**
* Get booking by ID
* GET /api/bookings/:id
*/
export const getBookingById = async (
id: number
): Promise<BookingResponse> => {
const response = await apiClient.get<BookingResponse>(
`/bookings/${id}`
);
return response.data;
};
/**
* Cancel a booking
* PATCH /api/bookings/:id/cancel
*/
export const cancelBooking = async (
id: number
): Promise<BookingResponse> => {
const response = await apiClient.patch<BookingResponse>(
`/bookings/${id}/cancel`
);
return response.data;
};
/**
* Check booking by booking number
* GET /api/bookings/check/:bookingNumber
*/
export const checkBookingByNumber = async (
bookingNumber: string
): Promise<CheckBookingResponse> => {
const response =
await apiClient.get<CheckBookingResponse>(
`/bookings/check/${bookingNumber}`
);
return response.data;
};
/**
* Get all bookings (admin)
* GET /api/bookings
*/
export const getAllBookings = async (
params?: {
status?: string;
search?: string;
page?: number;
limit?: number;
}
): Promise<BookingsResponse> => {
const response = await apiClient.get<BookingsResponse>('/bookings', { params });
return response.data;
};
/**
* Update booking status (admin)
* PUT /api/bookings/:id
*/
export const updateBooking = async (
id: number,
data: Partial<Booking>
): Promise<BookingResponse> => {
const response = await apiClient.put<BookingResponse>(`/bookings/${id}`, data);
return response.data;
};
/**
* Check room availability (helper function)
* GET /api/rooms/available?roomId=...&from=...&to=...
*/
export const checkRoomAvailability = async (
roomId: number,
checkInDate: string,
checkOutDate: string
): Promise<{ available: boolean; message?: string }> => {
try {
const response = await apiClient.get(
'/rooms/available',
{
params: {
roomId,
from: checkInDate,
to: checkOutDate,
},
}
);
return {
available: true,
message: response.data.message,
};
} catch (error: any) {
if (error.response?.status === 409) {
return {
available: false,
message:
error.response.data.message ||
'Phòng đã được đặt trong thời gian này',
};
}
throw error;
}
};
/**
* Notify payment (upload payment receipt)
* POST /api/notify/payment
*/
export const notifyPayment = async (
bookingId: number,
file?: File
): Promise<{ success: boolean; message?: string }> => {
const formData = new FormData();
formData.append('bookingId', bookingId.toString());
if (file) {
formData.append('receipt', file);
}
const response = await apiClient.post(
'/notify/payment',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
};
/**
* Generate QR code URL for bank transfer
*/
export const generateQRCode = (
bookingNumber: string,
amount: number
): string => {
// Using VietQR API format
// Bank: Vietcombank (VCB)
// Account: 0123456789
const bankCode = 'VCB';
const accountNumber = '0123456789';
const accountName = 'KHACH SAN ABC';
const transferContent = bookingNumber;
// VietQR format
const qrUrl =
`https://img.vietqr.io/image/${bankCode}-` +
`${accountNumber}-compact2.jpg?` +
`amount=${amount}&` +
`addInfo=${encodeURIComponent(transferContent)}&` +
`accountName=${encodeURIComponent(accountName)}`;
return qrUrl;
};
export default {
createBooking,
getMyBookings,
getBookingById,
cancelBooking,
checkBookingByNumber,
checkRoomAvailability,
notifyPayment,
generateQRCode,
getAllBookings,
updateBooking,
};

View File

@@ -0,0 +1,95 @@
import apiClient from './apiClient';
import type { Room } from './roomService';
/**
* Favorite API Service
*/
export interface Favorite {
id: number;
user_id: number;
room_id: number;
created_at: string;
updated_at: string;
room?: Room;
}
export interface FavoriteResponse {
success?: boolean;
status: string;
message?: string;
data?: {
favorites: Favorite[];
total: number;
};
}
export interface FavoriteActionResponse {
success?: boolean;
status: string;
message: string;
data?: {
favorite: Favorite;
};
}
export interface CheckFavoriteResponse {
success?: boolean;
status: string;
data: {
isFavorited: boolean;
};
}
/**
* Get user's favorite rooms
*/
export const getFavorites = async (): Promise<
FavoriteResponse
> => {
const response = await apiClient.get('/api/favorites');
return response.data;
};
/**
* Add room to favorites
*/
export const addFavorite = async (
roomId: number
): Promise<FavoriteActionResponse> => {
const response = await apiClient.post(
`/api/favorites/${roomId}`
);
return response.data;
};
/**
* Remove room from favorites
*/
export const removeFavorite = async (
roomId: number
): Promise<FavoriteActionResponse> => {
const response = await apiClient.delete(
`/api/favorites/${roomId}`
);
return response.data;
};
/**
* Check if room is favorited
*/
export const checkFavorite = async (
roomId: number
): Promise<CheckFavoriteResponse> => {
const response = await apiClient.get(
`/api/favorites/check/${roomId}`
);
return response.data;
};
export default {
getFavorites,
addFavorite,
removeFavorite,
checkFavorite,
};

View File

@@ -0,0 +1,33 @@
export { default as apiClient } from './apiClient';
export { default as authService } from './authService';
export type * from './authService';
export { default as roomService } from './roomService';
export type * from './roomService';
export { default as bannerService } from './bannerService';
export type * from './bannerService';
export { default as reviewService } from './reviewService';
export type * from './reviewService';
export { default as favoriteService } from './favoriteService';
export type * from './favoriteService';
export { default as bookingService } from './bookingService';
export type * from './bookingService';
export { default as paymentService } from './paymentService';
export type * from './paymentService';
export { default as userService } from './userService';
export type * from './userService';
export { default as serviceService } from './serviceService';
export type * from './serviceService';
export { default as promotionService } from './promotionService';
export type * from './promotionService';
export { default as reportService } from './reportService';
export type * from './reportService';

View File

@@ -0,0 +1,188 @@
import apiClient from './apiClient';
// Types
export interface PaymentData {
booking_id: number;
amount: number;
payment_method: 'cash' | 'bank_transfer';
transaction_id?: string;
notes?: string;
}
export interface Payment {
id: number;
booking_id: number;
amount: number;
payment_method: 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'e_wallet';
payment_type: 'full' | 'deposit' | 'remaining';
deposit_percentage?: number;
payment_status: 'pending' | 'completed' | 'failed' | 'refunded';
transaction_id?: string;
payment_date?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface BankInfo {
bank_name: string;
bank_code: string;
account_number: string;
account_name: string;
amount: number;
content: string;
qr_url: string;
}
export interface PaymentResponse {
success: boolean;
data: {
payment: Payment;
};
message?: string;
}
/**
* Create a new payment record
* POST /api/payments
*/
export const createPayment = async (
paymentData: PaymentData
): Promise<PaymentResponse> => {
const response = await apiClient.post<PaymentResponse>(
'/payments',
paymentData
);
return response.data;
};
/**
* Get payment details by booking ID
* GET /api/payments/:bookingId
*/
export const getPaymentByBookingId = async (
bookingId: number
): Promise<PaymentResponse> => {
const response = await apiClient.get<PaymentResponse>(
`/payments/${bookingId}`
);
return response.data;
};
/**
* Confirm bank transfer payment (with receipt)
* POST /api/payments/confirm
*/
export const confirmBankTransfer = async (
bookingId: number,
transactionId?: string,
receipt?: File
): Promise<{ success: boolean; message?: string }> => {
const formData = new FormData();
formData.append('booking_id', bookingId.toString());
if (transactionId) {
formData.append('transaction_id', transactionId);
}
if (receipt) {
formData.append('receipt', receipt);
}
const response = await apiClient.post(
'/payments/confirm',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
};
/**
* Get bank transfer info with QR code for deposit
* GET /api/payments/:paymentId/bank-info
*/
export const getBankTransferInfo = async (
paymentId: number
): Promise<{
success: boolean;
data: { payment: Payment; bank_info: BankInfo };
message?: string;
}> => {
const response = await apiClient.get(
`/payments/${paymentId}/bank-info`
);
return response.data;
};
/**
* Confirm deposit payment
* POST /api/payments/confirm-deposit
*/
export const confirmDepositPayment = async (
paymentId: number,
transactionId?: string
): Promise<{
success: boolean;
data: { payment: Payment; booking: any };
message?: string;
}> => {
const response = await apiClient.post(
'/payments/confirm-deposit',
{
payment_id: paymentId,
transaction_id: transactionId,
}
);
return response.data;
};
/**
* Notify payment completion (for admin verification)
* POST /api/payments/notify
*/
export const notifyPaymentCompletion = async (
paymentId: number,
notes?: string
): Promise<{ success: boolean; message?: string }> => {
const response = await apiClient.post(
'/payments/notify',
{
payment_id: paymentId,
notes,
}
);
return response.data;
};
/**
* Get payments for a booking
* GET /api/payments/booking/:bookingId
*/
export const getPaymentsByBookingId = async (
bookingId: number
): Promise<{
success: boolean;
data: { payments: Payment[] };
message?: string;
}> => {
const response = await apiClient.get(
`/payments/booking/${bookingId}`
);
return response.data;
};
export default {
createPayment,
getPaymentByBookingId,
confirmBankTransfer,
getBankTransferInfo,
confirmDepositPayment,
notifyPaymentCompletion,
getPaymentsByBookingId,
};

View File

@@ -0,0 +1,147 @@
import apiClient from './apiClient';
/**
* Promotion API Service
*/
export interface Promotion {
id: number;
code: string;
name: string;
description?: string;
discount_type: 'percentage' | 'fixed';
discount_value: number;
min_booking_amount?: number;
max_discount_amount?: number;
start_date: string;
end_date: string;
usage_limit?: number;
used_count?: number;
status: 'active' | 'inactive' | 'expired';
created_at?: string;
updated_at?: string;
}
export interface PromotionListResponse {
success: boolean;
status?: string;
data: {
promotions: Promotion[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
message?: string;
}
export interface CreatePromotionData {
code: string;
name: string;
description?: string;
discount_type: 'percentage' | 'fixed';
discount_value: number;
min_booking_amount?: number;
max_discount_amount?: number;
start_date: string;
end_date: string;
usage_limit?: number;
status?: 'active' | 'inactive' | 'expired';
}
export interface UpdatePromotionData {
code?: string;
name?: string;
description?: string;
discount_type?: 'percentage' | 'fixed';
discount_value?: number;
min_booking_amount?: number;
max_discount_amount?: number;
start_date?: string;
end_date?: string;
usage_limit?: number;
status?: 'active' | 'inactive' | 'expired';
}
export interface PromotionSearchParams {
status?: string;
search?: string;
page?: number;
limit?: number;
}
/**
* Get all promotions
*/
export const getPromotions = async (
params: PromotionSearchParams = {}
): Promise<PromotionListResponse> => {
const response = await apiClient.get('/promotions', { params });
return response.data;
};
/**
* Get promotion by ID
*/
export const getPromotionById = async (
id: number
): Promise<{ success: boolean; data: { promotion: Promotion } }> => {
const response = await apiClient.get(`/promotions/${id}`);
return response.data;
};
/**
* Create new promotion
*/
export const createPromotion = async (
data: CreatePromotionData
): Promise<{ success: boolean; data: { promotion: Promotion }; message: string }> => {
const response = await apiClient.post('/promotions', data);
return response.data;
};
/**
* Update promotion
*/
export const updatePromotion = async (
id: number,
data: UpdatePromotionData
): Promise<{ success: boolean; data: { promotion: Promotion }; message: string }> => {
const response = await apiClient.put(`/promotions/${id}`, data);
return response.data;
};
/**
* Delete promotion
*/
export const deletePromotion = async (
id: number
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/promotions/${id}`);
return response.data;
};
/**
* Validate promotion code
*/
export const validatePromotion = async (
code: string,
bookingValue: number
): Promise<{ success: boolean; data: { promotion: Promotion; discount: number }; message: string }> => {
const response = await apiClient.post('/promotions/validate', {
code,
booking_value: bookingValue,
});
return response.data;
};
export default {
getPromotions,
getPromotionById,
createPromotion,
updatePromotion,
deletePromotion,
validatePromotion,
};

View File

@@ -0,0 +1,87 @@
import apiClient from './apiClient';
/**
* Report API Service
*/
export interface ReportData {
total_bookings: number;
total_revenue: number;
total_customers: number;
available_rooms: number;
occupied_rooms: number;
revenue_by_date?: Array<{
date: string;
revenue: number;
bookings: number;
}>;
bookings_by_status?: {
pending: number;
confirmed: number;
checked_in: number;
checked_out: number;
cancelled: number;
};
top_rooms?: Array<{
room_id: number;
room_number: string;
bookings: number;
revenue: number;
}>;
service_usage?: Array<{
service_id: number;
service_name: string;
usage_count: number;
total_revenue: number;
}>;
}
export interface ReportResponse {
success: boolean;
status?: string;
data: ReportData;
message?: string;
}
export interface ReportParams {
from?: string;
to?: string;
type?: 'daily' | 'weekly' | 'monthly' | 'yearly';
}
/**
* Get reports
*/
export const getReports = async (
params: ReportParams = {}
): Promise<ReportResponse> => {
const response = await apiClient.get('/reports', { params });
return response.data;
};
/**
* Get dashboard statistics
*/
export const getDashboardStats = async (): Promise<ReportResponse> => {
const response = await apiClient.get('/reports/dashboard');
return response.data;
};
/**
* Export report to CSV
*/
export const exportReport = async (
params: ReportParams = {}
): Promise<Blob> => {
const response = await apiClient.get('/reports/export', {
params,
responseType: 'blob',
});
return response.data;
};
export default {
getReports,
getDashboardStats,
exportReport,
};

View File

@@ -0,0 +1,117 @@
import apiClient from './apiClient';
/**
* Review API Service
*/
export interface Review {
id: number;
user_id: number;
room_id: number;
rating: number;
comment: string;
status: 'pending' | 'approved' | 'rejected';
created_at: string;
updated_at: string;
user?: {
id: number;
name: string;
full_name: string;
email: string;
};
room?: {
id: number;
room_number: string;
room_type?: {
name: string;
};
};
}
export interface ReviewListResponse {
success: boolean;
status?: string;
data: {
reviews: Review[];
average_rating?: number;
total_reviews?: number;
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
message?: string;
}
export interface CreateReviewData {
room_id: number;
rating: number;
comment: string;
}
/**
* Get reviews for a specific room
*/
export const getRoomReviews = async (
roomId: number
): Promise<ReviewListResponse> => {
const response = await apiClient.get(
`/api/rooms/${roomId}/reviews`
);
return response.data;
};
/**
* Create a new review
*/
export const createReview = async (
data: CreateReviewData
): Promise<{ success: boolean; message: string; data?: Review }> => {
const response = await apiClient.post('/api/reviews', data);
return response.data;
};
/**
* Get all reviews (admin)
*/
export const getReviews = async (
params?: {
status?: string;
roomId?: number;
page?: number;
limit?: number;
}
): Promise<ReviewListResponse> => {
const response = await apiClient.get('/reviews', { params });
return response.data;
};
/**
* Approve review (admin)
*/
export const approveReview = async (
id: number
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/reviews/${id}/approve`);
return response.data;
};
/**
* Reject review (admin)
*/
export const rejectReview = async (
id: number
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/reviews/${id}/reject`);
return response.data;
};
export default {
getRoomReviews,
createReview,
getReviews,
approveReview,
rejectReview,
};

View File

@@ -0,0 +1,179 @@
import apiClient from './apiClient';
/**
* Room API Service
*/
export interface Room {
id: number;
room_type_id: number;
room_number: string;
floor: number;
status: 'available' | 'occupied' | 'maintenance';
featured: boolean;
images?: string[];
amenities?: string[];
created_at: string;
updated_at: string;
room_type?: {
id: number;
name: string;
description: string;
base_price: number;
capacity: number;
amenities: string[];
images: string[];
};
average_rating?: number | string | null;
total_reviews?: number | string | null;
}
export interface RoomListResponse {
success: boolean;
status?: string;
data: {
rooms: Room[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
message?: string;
}
export interface FeaturedRoomsParams {
featured?: boolean;
limit?: number;
}
export interface RoomSearchParams {
type?: string;
minPrice?: number;
maxPrice?: number;
capacity?: number;
page?: number;
limit?: number;
sort?: string;
}
/**
* Get featured rooms for homepage
*/
export const getFeaturedRooms = async (
params: FeaturedRoomsParams = {}
): Promise<RoomListResponse> => {
const response = await apiClient.get('/rooms', {
params: {
featured: params.featured ?? true,
limit: params.limit ?? 6,
},
});
return response.data;
};
/**
* Get all rooms with filters
*/
export const getRooms = async (
params: RoomSearchParams = {}
): Promise<RoomListResponse> => {
const response = await apiClient.get('/rooms', {
params,
});
return response.data;
};
/**
* Get room by ID
*/
export const getRoomById = async (
id: number
): Promise<{ success: boolean; data: { room: Room } }> => {
const response = await apiClient.get(`/rooms/${id}`);
return response.data;
};
/**
* Search available rooms
*/
export interface AvailableSearchParams {
from: string;
to: string;
type?: string;
capacity?: number;
page?: number;
limit?: number;
}
export const searchAvailableRooms = async (
params: AvailableSearchParams
): Promise<RoomListResponse> => {
const response = await apiClient.get('/rooms/available', {
params,
});
return response.data;
};
/**
* Get available amenities list (unique)
*/
export const getAmenities = async (): Promise<{
success?: boolean;
status?: string;
data: { amenities: string[] };
}> => {
const response = await apiClient.get('/rooms/amenities');
return response.data;
};
/**
* Create new room
*/
export interface CreateRoomData {
room_number: string;
floor: number;
room_type_id: number;
status: 'available' | 'occupied' | 'maintenance';
featured?: boolean;
}
export const createRoom = async (
data: CreateRoomData
): Promise<{ success: boolean; data: { room: Room }; message: string }> => {
const response = await apiClient.post('/rooms', data);
return response.data;
};
/**
* Update room
*/
export const updateRoom = async (
id: number,
data: Partial<CreateRoomData>
): Promise<{ success: boolean; data: { room: Room }; message: string }> => {
const response = await apiClient.put(`/rooms/${id}`, data);
return response.data;
};
/**
* Delete room
*/
export const deleteRoom = async (
id: number
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/rooms/${id}`);
return response.data;
};
export default {
getFeaturedRooms,
getRooms,
getRoomById,
searchAvailableRooms,
getAmenities,
createRoom,
updateRoom,
deleteRoom,
};

View File

@@ -0,0 +1,126 @@
import apiClient from './apiClient';
/**
* Service API Service
*/
export interface Service {
id: number;
name: string;
description?: string;
price: number;
unit?: string;
status: 'active' | 'inactive';
created_at?: string;
updated_at?: string;
}
export interface ServiceListResponse {
success: boolean;
status?: string;
data: {
services: Service[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
message?: string;
}
export interface CreateServiceData {
name: string;
description?: string;
price: number;
unit?: string;
status?: 'active' | 'inactive';
}
export interface UpdateServiceData {
name?: string;
description?: string;
price?: number;
unit?: string;
status?: 'active' | 'inactive';
}
export interface ServiceSearchParams {
status?: string;
search?: string;
page?: number;
limit?: number;
}
/**
* Get all services
*/
export const getServices = async (
params: ServiceSearchParams = {}
): Promise<ServiceListResponse> => {
const response = await apiClient.get('/services', { params });
return response.data;
};
/**
* Get service by ID
*/
export const getServiceById = async (
id: number
): Promise<{ success: boolean; data: { service: Service } }> => {
const response = await apiClient.get(`/services/${id}`);
return response.data;
};
/**
* Create new service
*/
export const createService = async (
data: CreateServiceData
): Promise<{ success: boolean; data: { service: Service }; message: string }> => {
const response = await apiClient.post('/services', data);
return response.data;
};
/**
* Update service
*/
export const updateService = async (
id: number,
data: UpdateServiceData
): Promise<{ success: boolean; data: { service: Service }; message: string }> => {
const response = await apiClient.put(`/services/${id}`, data);
return response.data;
};
/**
* Delete service
*/
export const deleteService = async (
id: number
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/services/${id}`);
return response.data;
};
/**
* Use service
*/
export const useService = async (data: {
booking_id: number;
service_id: number;
quantity: number;
}): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/services/use', data);
return response.data;
};
export default {
getServices,
getServiceById,
createService,
updateService,
deleteService,
useService,
};

View File

@@ -0,0 +1,117 @@
import apiClient from './apiClient';
/**
* User API Service
*/
export interface User {
id: number;
full_name: string;
email: string;
phone_number?: string;
avatar?: string;
role: string;
status?: string;
created_at?: string;
updated_at?: string;
}
export interface UserListResponse {
success: boolean;
status?: string;
data: {
users: User[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
message?: string;
}
export interface CreateUserData {
full_name: string;
email: string;
password: string;
phone_number?: string;
role: string;
status?: string;
}
export interface UpdateUserData {
full_name?: string;
email?: string;
phone_number?: string;
role?: string;
password?: string;
status?: string;
}
export interface UserSearchParams {
role?: string;
status?: string;
search?: string;
page?: number;
limit?: number;
}
/**
* Get all users
*/
export const getUsers = async (
params: UserSearchParams = {}
): Promise<UserListResponse> => {
const response = await apiClient.get('/users', { params });
return response.data;
};
/**
* Get user by ID
*/
export const getUserById = async (
id: number
): Promise<{ success: boolean; data: { user: User } }> => {
const response = await apiClient.get(`/users/${id}`);
return response.data;
};
/**
* Create new user
*/
export const createUser = async (
data: CreateUserData
): Promise<{ success: boolean; data: { user: User }; message: string }> => {
const response = await apiClient.post('/users', data);
return response.data;
};
/**
* Update user
*/
export const updateUser = async (
id: number,
data: UpdateUserData
): Promise<{ success: boolean; data: { user: User }; message: string }> => {
const response = await apiClient.put(`/users/${id}`, data);
return response.data;
};
/**
* Delete user
*/
export const deleteUser = async (
id: number
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/users/${id}`);
return response.data;
};
export default {
getUsers,
getUserById,
createUser,
updateUser,
deleteUser,
};

View File

@@ -0,0 +1,275 @@
import { create } from 'zustand';
import { toast } from 'react-toastify';
import authService, {
LoginCredentials,
RegisterData,
ForgotPasswordData,
ResetPasswordData
} from '../services/api/authService';
// Types
interface UserInfo {
id: number;
name: string;
email: string;
phone?: string;
avatar?: string;
role: string;
createdAt?: string;
}
interface AuthState {
// State
token: string | null;
userInfo: UserInfo | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// Actions
login: (credentials: LoginCredentials) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
setUser: (user: UserInfo) => void;
refreshAuthToken: () => Promise<void>;
forgotPassword: (
data: ForgotPasswordData
) => Promise<void>;
resetPassword: (
data: ResetPasswordData
) => Promise<void>;
initializeAuth: () => void;
clearError: () => void;
}
/**
* useAuthStore - Zustand store managing
* authentication state
*/
const useAuthStore = create<AuthState>((set, get) => ({
// Initial State
token: localStorage.getItem('token') || null,
userInfo: localStorage.getItem('userInfo')
? JSON.parse(localStorage.getItem('userInfo')!)
: null,
isAuthenticated: !!localStorage.getItem('token'),
isLoading: false,
error: null,
/**
* Login - User login
*/
login: async (credentials: LoginCredentials) => {
set({ isLoading: true, error: null });
try {
const response = await authService.login(credentials);
// Accept either boolean `success` (client) or `status: 'success'` (server)
if (response.success || response.status === 'success') {
const token = response.data?.token;
const user = response.data?.user ?? null;
// If we didn't receive a token or user, treat as failure
if (!token || !user) {
throw new Error(response.message || 'Login failed.');
}
// Save to localStorage (only access token)
localStorage.setItem('token', token);
localStorage.setItem('userInfo', JSON.stringify(user));
// Update state
set({
token,
userInfo: user,
isAuthenticated: true,
isLoading: false,
error: null,
});
toast.success('Login successful!');
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
'Login failed. Please try again.';
set({
isLoading: false,
error: errorMessage,
isAuthenticated: false
});
toast.error(errorMessage);
throw error;
}
},
/**
* Register - Register new account
*/
register: async (data: RegisterData) => {
set({ isLoading: true, error: null });
try {
const response = await authService.register(data);
if (response.success || response.status === 'success') {
set({ isLoading: false, error: null });
toast.success(
'Registration successful! Please login.'
);
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
'Registration failed. Please try again.';
set({ isLoading: false, error: errorMessage });
toast.error(errorMessage);
throw error;
}
},
/**
* Logout - User logout
*/
logout: async () => {
set({ isLoading: true });
try {
await authService.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
// Clear localStorage
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
// Reset state
set({
token: null,
userInfo: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
toast.info('Logged out');
}
},
/**
* SetUser - Update user information
*/
setUser: (user: UserInfo) => {
localStorage.setItem('userInfo', JSON.stringify(user));
set({ userInfo: user });
},
/**
* Refresh Token - Refresh authentication token
*/
refreshAuthToken: async () => {
try {
const response = await authService.refreshToken();
if (response.success || response.status === 'success') {
const token = response.data?.token;
if (!token) {
throw new Error(response.message || 'Unable to refresh token');
}
localStorage.setItem('token', token);
set({
token,
});
}
} catch (error) {
// If refresh token fails, logout
get().logout();
throw error;
}
},
/**
* Forgot Password - Request password reset
*/
forgotPassword: async (data: ForgotPasswordData) => {
set({ isLoading: true, error: null });
try {
const response =
await authService.forgotPassword(data);
if (response.success || response.status === 'success') {
set({ isLoading: false, error: null });
toast.success(
response.message ||
'Please check your email to reset password.'
);
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
'An error occurred. Please try again.';
set({ isLoading: false, error: errorMessage });
toast.error(errorMessage);
throw error;
}
},
/**
* Reset Password - Reset password with token
*/
resetPassword: async (data: ResetPasswordData) => {
set({ isLoading: true, error: null });
try {
const response =
await authService.resetPassword(data);
if (response.success || response.status === 'success') {
set({ isLoading: false, error: null });
toast.success(
response.message ||
'Password reset successful!'
);
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
'Password reset failed. ' +
'Please try again.';
set({ isLoading: false, error: errorMessage });
toast.error(errorMessage);
throw error;
}
},
/**
* Initialize Auth - Initialize auth state
* when app loads
*/
initializeAuth: () => {
const token = localStorage.getItem('token');
const userInfo = localStorage.getItem('userInfo');
if (token && userInfo) {
set({
token,
userInfo: JSON.parse(userInfo),
isAuthenticated: true,
});
}
},
/**
* Clear Error - Clear error message
*/
clearError: () => {
set({ error: null });
},
}));
export default useAuthStore;

View File

@@ -0,0 +1,279 @@
import { create } from 'zustand';
import { toast } from 'react-toastify';
import {
getFavorites,
addFavorite,
removeFavorite,
} from '../services/api/favoriteService';
import type { Favorite } from '../services/api/favoriteService';
interface FavoritesState {
favorites: Favorite[];
favoriteRoomIds: Set<number>;
isLoading: boolean;
error: string | null;
// Actions
fetchFavorites: () => Promise<void>;
addToFavorites: (roomId: number) => Promise<void>;
removeFromFavorites: (roomId: number) => Promise<void>;
isFavorited: (roomId: number) => boolean;
syncGuestFavorites: () => Promise<void>;
clearFavorites: () => void;
// Guest favorites (localStorage)
loadGuestFavorites: () => void;
saveGuestFavorite: (roomId: number) => void;
removeGuestFavorite: (roomId: number) => void;
}
const GUEST_FAVORITES_KEY = 'guestFavorites';
// Helper functions for localStorage
const getGuestFavorites = (): number[] => {
try {
const stored = localStorage.getItem(GUEST_FAVORITES_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Error loading guest favorites:', error);
return [];
}
};
const setGuestFavorites = (roomIds: number[]): void => {
try {
localStorage.setItem(
GUEST_FAVORITES_KEY,
JSON.stringify(roomIds)
);
} catch (error) {
console.error('Error saving guest favorites:', error);
}
};
const useFavoritesStore = create<FavoritesState>(
(set, get) => ({
favorites: [],
favoriteRoomIds: new Set(),
isLoading: false,
error: null,
// Fetch favorites from server (authenticated users)
fetchFavorites: async () => {
set({ isLoading: true, error: null });
try {
const response = await getFavorites();
if (
response.status === 'success' &&
response.data
) {
const favorites = response.data.favorites;
const roomIds = new Set(
favorites.map((f) => f.room_id)
);
set({
favorites,
favoriteRoomIds: roomIds,
isLoading: false,
});
}
} catch (error: any) {
console.error('Error fetching favorites:', error);
// If user is not authenticated, load guest favorites
if (error.response?.status === 401) {
get().loadGuestFavorites();
} else {
set({
error:
error.response?.data?.message ||
'Unable to load favorites list',
isLoading: false,
});
}
}
},
// Add room to favorites
addToFavorites: async (roomId: number) => {
try {
const response = await addFavorite(roomId);
if (response.status === 'success') {
// Update state
set((state) => {
const newFavoriteIds = new Set(
state.favoriteRoomIds
);
newFavoriteIds.add(roomId);
return {
favoriteRoomIds: newFavoriteIds,
};
});
// Re-fetch to get complete data
await get().fetchFavorites();
toast.success(
response.message ||
'Added to favorites'
);
}
} catch (error: any) {
console.error('Error adding favorite:', error);
// If not authenticated, save to guest favorites
if (error.response?.status === 401) {
get().saveGuestFavorite(roomId);
toast.success('Added to favorites');
} else {
const message =
error.response?.data?.message ||
'Unable to add to favorites';
toast.error(message);
}
}
},
// Remove room from favorites
removeFromFavorites: async (roomId: number) => {
try {
const response = await removeFavorite(roomId);
if (response.status === 'success') {
// Update state
set((state) => {
const newFavoriteIds = new Set(
state.favoriteRoomIds
);
newFavoriteIds.delete(roomId);
const newFavorites = state.favorites.filter(
(f) => f.room_id !== roomId
);
return {
favorites: newFavorites,
favoriteRoomIds: newFavoriteIds,
};
});
toast.success(
response.message ||
'Removed from favorites'
);
}
} catch (error: any) {
console.error('Error removing favorite:', error);
// If not authenticated, remove from guest favorites
if (error.response?.status === 401) {
get().removeGuestFavorite(roomId);
toast.success('Removed from favorites');
} else {
const message =
error.response?.data?.message ||
'Unable to remove from favorites';
toast.error(message);
}
}
},
// Check if room is favorited
isFavorited: (roomId: number) => {
return get().favoriteRoomIds.has(roomId);
},
// Sync guest favorites to server after login
syncGuestFavorites: async () => {
const guestFavorites = getGuestFavorites();
if (guestFavorites.length === 0) {
return;
}
try {
// Add each guest favorite to server
await Promise.all(
guestFavorites.map((roomId) =>
addFavorite(roomId).catch(() => {
// Ignore errors (room might already be favorited)
})
)
);
// Clear guest favorites
localStorage.removeItem(GUEST_FAVORITES_KEY);
// Fetch updated favorites
await get().fetchFavorites();
toast.success(
'Favorites list synced'
);
} catch (error) {
console.error(
'Error syncing guest favorites:',
error
);
}
},
// Clear all favorites
clearFavorites: () => {
set({
favorites: [],
favoriteRoomIds: new Set(),
error: null,
});
localStorage.removeItem(GUEST_FAVORITES_KEY);
},
// Guest favorites management
loadGuestFavorites: () => {
const guestFavorites = getGuestFavorites();
set({
favoriteRoomIds: new Set(guestFavorites),
isLoading: false,
});
},
saveGuestFavorite: (roomId: number) => {
const guestFavorites = getGuestFavorites();
if (!guestFavorites.includes(roomId)) {
const updated = [...guestFavorites, roomId];
setGuestFavorites(updated);
set((state) => {
const newFavoriteIds = new Set(
state.favoriteRoomIds
);
newFavoriteIds.add(roomId);
return { favoriteRoomIds: newFavoriteIds };
});
}
},
removeGuestFavorite: (roomId: number) => {
const guestFavorites = getGuestFavorites();
const updated = guestFavorites.filter(
(id) => id !== roomId
);
setGuestFavorites(updated);
set((state) => {
const newFavoriteIds = new Set(
state.favoriteRoomIds
);
newFavoriteIds.delete(roomId);
return { favoriteRoomIds: newFavoriteIds };
});
},
})
);
export default useFavoritesStore;

View File

@@ -0,0 +1,94 @@
/* React DatePicker Custom Styles */
/* Override default datepicker styles to match Tailwind theme */
.react-datepicker-wrapper {
width: 100%;
}
.react-datepicker__input-container {
width: 100%;
}
.react-datepicker {
font-family: inherit;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.react-datepicker__header {
background-color: #4f46e5;
border-bottom: none;
border-radius: 0.5rem 0.5rem 0 0;
padding-top: 0.75rem;
}
.react-datepicker__current-month,
.react-datepicker__day-name {
color: #ffffff;
font-weight: 600;
}
.react-datepicker__day {
color: #374151;
border-radius: 0.375rem;
transition: all 0.2s;
}
.react-datepicker__day:hover {
background-color: #e0e7ff;
color: #4f46e5;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: #4f46e5;
color: #ffffff;
font-weight: 600;
}
.react-datepicker__day--in-range,
.react-datepicker__day--in-selecting-range {
background-color: #e0e7ff;
color: #4f46e5;
}
.react-datepicker__day--disabled {
color: #d1d5db;
cursor: not-allowed;
}
.react-datepicker__day--disabled:hover {
background-color: transparent;
}
.react-datepicker__navigation {
top: 0.75rem;
}
.react-datepicker__navigation--previous {
border-right-color: #ffffff;
}
.react-datepicker__navigation--next {
border-left-color: #ffffff;
}
.react-datepicker__navigation:hover
*::before {
border-color: #e0e7ff;
}
.react-datepicker__month {
margin: 0.75rem;
}
.react-datepicker__day--today {
font-weight: 600;
background-color: #fef3c7;
color: #92400e;
}
.react-datepicker__day--today:hover {
background-color: #fde68a;
}

114
client/src/styles/index.css Normal file
View File

@@ -0,0 +1,114 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Base styles */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas,
'Courier New', monospace;
}
/* Custom utilities */
@layer utilities {
.text-balance {
text-wrap: balance;
}
/* Smooth fade-in animation */
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Slide-in animation */
.animate-slide-in {
animation: slideIn 0.4s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Scale-in animation */
.animate-scale-in {
animation: scaleIn 0.3s ease-out;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Smooth transitions */
.transition-smooth {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
/* Image loading optimization */
img {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
/* Lazy loading optimization */
img[loading="lazy"] {
opacity: 0;
transition: opacity 0.3s;
}
img[loading="lazy"].loaded,
img[loading="lazy"]:not([src]) {
opacity: 1;
}

View File

@@ -0,0 +1,95 @@
import * as yup from 'yup';
/**
* Login Validation Schema
*/
export const loginSchema = yup.object().shape({
email: yup
.string()
.required('Email is required')
.email('Invalid email')
.trim(),
password: yup
.string()
.required('Password is required')
.min(8, 'Password must be at least 8 characters'),
rememberMe: yup
.boolean()
.optional(),
});
/**
* Register Validation Schema
*/
export const registerSchema = yup.object().shape({
name: yup
.string()
.required('Full name is required')
.min(2, 'Full name must be at least 2 characters')
.max(50, 'Full name must not exceed 50 characters')
.trim(),
email: yup
.string()
.required('Email is required')
.email('Invalid email')
.trim(),
password: yup
.string()
.required('Password is required')
.min(8, 'Password must be at least 8 characters')
.matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
'Password must contain uppercase, lowercase, ' +
'number and special character'
),
confirmPassword: yup
.string()
.required('Please confirm password')
.oneOf([yup.ref('password')], 'Passwords do not match'),
phone: yup
.string()
.optional()
.matches(
/^[0-9]{10,11}$/,
'Invalid phone number'
),
});
/**
* Forgot Password Validation Schema
*/
export const forgotPasswordSchema = yup.object().shape({
email: yup
.string()
.required('Email is required')
.email('Invalid email')
.trim(),
});
/**
* Reset Password Validation Schema
*/
export const resetPasswordSchema = yup.object().shape({
password: yup
.string()
.required('New password is required')
.min(8, 'Password must be at least 8 characters')
.matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
'Password must contain uppercase, lowercase, ' +
'number and special character'
),
confirmPassword: yup
.string()
.required('Please confirm password')
.oneOf([yup.ref('password')], 'Passwords do not match'),
});
// Types
export type LoginFormData = yup.InferType<typeof loginSchema>;
export type RegisterFormData =
yup.InferType<typeof registerSchema>;
export type ForgotPasswordFormData =
yup.InferType<typeof forgotPasswordSchema>;
export type ResetPasswordFormData =
yup.InferType<typeof resetPasswordSchema>;

View File

@@ -0,0 +1,65 @@
import * as yup from 'yup';
export const bookingValidationSchema = yup.object().shape({
checkInDate: yup
.date()
.required('Vui lòng chọn ngày nhận phòng')
.min(
new Date(new Date().setHours(0, 0, 0, 0)),
'Ngày nhận phòng không thể là ngày trong quá khứ'
)
.typeError('Ngày nhận phòng không hợp lệ'),
checkOutDate: yup
.date()
.required('Vui lòng chọn ngày trả phòng')
.min(
yup.ref('checkInDate'),
'Ngày trả phòng phải sau ngày nhận phòng'
)
.typeError('Ngày trả phòng không hợp lệ'),
guestCount: yup
.number()
.required('Vui lòng nhập số người')
.min(1, 'Số người tối thiểu là 1')
.max(10, 'Số người tối đa là 10')
.integer('Số người phải là số nguyên')
.typeError('Số người phải là số'),
notes: yup
.string()
.max(500, 'Ghi chú không được quá 500 ký tự')
.optional(),
paymentMethod: yup
.mixed<'cash' | 'bank_transfer'>()
.required('Vui lòng chọn phương thức thanh toán')
.oneOf(
['cash', 'bank_transfer'],
'Phương thức thanh toán không hợp lệ'
),
fullName: yup
.string()
.required('Vui lòng nhập họ tên')
.min(2, 'Họ tên phải có ít nhất 2 ký tự')
.max(100, 'Họ tên không được quá 100 ký tự'),
email: yup
.string()
.required('Vui lòng nhập email')
.email('Email không hợp lệ'),
phone: yup
.string()
.required('Vui lòng nhập số điện thoại')
.matches(
/^[0-9]{10,11}$/,
'Số điện thoại phải có 10-11 chữ số'
),
});
export type BookingFormData = yup.InferType<
typeof bookingValidationSchema
>;

9
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

26
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
},
},
plugins: [],
}

31
client/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
client/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: hotel-booking-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ""
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: hotel_booking_dev
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:

View File

@@ -0,0 +1,488 @@
# Chức năng 6: Quên Mật Khẩu (Forgot Password) - Hoàn Thành ✅
## 📦 Files Đã Tạo/Cập Nhật
### Frontend
1. **`client/src/pages/auth/ForgotPasswordPage.tsx`** - Component form quên mật khẩu
2. **`client/src/pages/auth/index.ts`** - Export ForgotPasswordPage
3. **`client/src/App.tsx`** - Route `/forgot-password`
### Backend
4. **`server/src/controllers/authController.js`** - forgotPassword() & resetPassword()
5. **`server/src/routes/authRoutes.js`** - Routes cho forgot/reset password
## ✨ Tính Năng Chính
### 1. Form State (Initial)
```
┌─────────────────────────────────────┐
│ 🏨 Hotel Icon (Blue) │
│ Quên mật khẩu? │
│ Nhập email để nhận link... │
├─────────────────────────────────────┤
│ ┌───────────────────────────────┐ │
│ │ Email │ │
│ │ [📧 email@example.com ] │ │
│ ├───────────────────────────────┤ │
│ │ [📤 Gửi link đặt lại MK] │ │
│ ├───────────────────────────────┤ │
│ │ ← Quay lại đăng nhập │ │
│ └───────────────────────────────┘ │
│ Chưa có tài khoản? Đăng ký ngay │
└─────────────────────────────────────┘
```
### 2. Success State (After Submit)
```
┌─────────────────────────────────────┐
│ ✅ Success Icon │
│ │
│ Email đã được gửi! │
│ Chúng tôi đã gửi link đến │
│ user@example.com │
├─────────────────────────────────────┤
Lưu ý: │
│ • Link có hiệu lực trong 1 giờ │
│ • Kiểm tra cả thư mục Spam/Junk │
│ • Nếu không nhận được, thử lại │
├─────────────────────────────────────┤
│ [📧 Gửi lại email] │
│ [← Quay lại đăng nhập] │
└─────────────────────────────────────┘
```
### 3. Two-State Design Pattern
**Form State** - Nhập email
**Success State** - Hiển thị xác nhận & hướng dẫn
State management:
```typescript
const [isSuccess, setIsSuccess] = useState(false);
const [submittedEmail, setSubmittedEmail] = useState('');
```
## 🔧 Features Chi Tiết
### 1. Validation (Yup Schema)
```typescript
email:
- Required: "Email là bắt buộc"
- Valid format: "Email không hợp lệ"
- Trim whitespace
```
### 2. Form Field
- **Email input** với Mail icon
- Auto-focus khi load page
- Validation real-time
- Error message inline
### 3. Submit Button States
```tsx
{isLoading ? (
<>
<Loader2 className="animate-spin" />
Đang xử ...
</>
) : (
<>
<Send />
Gửi link đặt lại mật khẩu
</>
)}
```
### 4. Success Features
**Visual Confirmation**
- Green checkmark icon
- Success message
- Display submitted email
**User Instructions**
- Link expires in 1 hour
- Check spam folder
- Can resend email
**Action Buttons**
- "Gửi lại email" - Reset to form state
- "Quay lại đăng nhập" - Navigate to /login
### 5. Help Section
```tsx
<div className="bg-white rounded-lg shadow-sm border">
<h3>Cần trợ giúp?</h3>
<p>
Liên hệ: support@hotel.com hoặc 1900-xxxx
</p>
</div>
```
## 🔗 Backend Integration
### API Endpoint: Forgot Password
```
POST /api/auth/forgot-password
```
**Request Body:**
```json
{
"email": "user@example.com"
}
```
**Response (Success):**
```json
{
"status": "success",
"message": "Password reset link has been sent to your email"
}
```
**Implementation:**
```javascript
const forgotPassword = async (req, res, next) => {
// 1. Find user by email
// 2. Generate crypto reset token (32 bytes)
// 3. Hash token with SHA256
// 4. Save to password_reset_tokens table
// 5. Expires in 1 hour
// 6. TODO: Send email with reset link
// 7. Return success (prevent email enumeration)
};
```
### API Endpoint: Reset Password
```
POST /api/auth/reset-password
```
**Request Body:**
```json
{
"token": "reset_token_from_email",
"password": "NewPassword123@"
}
```
**Response (Success):**
```json
{
"status": "success",
"message": "Password has been reset successfully"
}
```
**Implementation:**
```javascript
const resetPassword = async (req, res, next) => {
// 1. Hash incoming token
// 2. Find valid token in DB (not expired)
// 3. Find user by user_id
// 4. Hash new password with bcrypt
// 5. Update user password
// 6. Delete used token
// 7. TODO: Send confirmation email
// 8. Return success
};
```
## 🔐 Security Features
### 1. Token Generation
```javascript
// Generate random 32-byte token
const resetToken = crypto.randomBytes(32).toString('hex');
// Hash with SHA256 before storing
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
```
### 2. Token Storage
- Stored in `password_reset_tokens` table
- Hashed (SHA256)
- Expires in 1 hour
- One token per user (old tokens deleted)
### 3. Email Enumeration Prevention
```javascript
// Always return success, even if email not found
if (!user) {
return res.status(200).json({
status: 'success',
message: 'If email exists, reset link has been sent'
});
}
```
### 4. Token Validation
```javascript
// Check token exists and not expired
const resetToken = await PasswordResetToken.findOne({
where: {
token: hashedToken,
expires_at: { [Op.gt]: new Date() }
}
});
```
## 📊 Database Schema
### password_reset_tokens Table
```sql
CREATE TABLE password_reset_tokens (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
## 🎨 Design & Styling
**Color Scheme:**
- Primary: blue-600, blue-700
- Background: gradient from-blue-50 to-indigo-100
- Success: green-100, green-600
- Info: blue-50, blue-200
- Text: gray-600, gray-700, gray-900
**Icons:**
- 🏨 Hotel - Main logo
- 📧 Mail - Email input
- 📤 Send - Submit button
- ⏳ Loader2 - Loading spinner
- ✅ CheckCircle - Success state
- ← ArrowLeft - Back navigation
## 🔄 User Flow
```
1. User visits /forgot-password
2. Enter email address
3. Click "Gửi link đặt lại mật khẩu"
4. Frontend validation (Yup)
5. Call useAuthStore.forgotPassword()
6. API POST /api/auth/forgot-password
7. Backend:
- Generate token
- Save to DB
- TODO: Send email
8. Frontend shows success state
9. User receives email (TODO)
10. Click link → /reset-password/:token
11. Enter new password (Chức năng 7)
```
## 🧪 Test Scenarios
### Test Case 1: Valid email
```
Input: email = "user@example.com"
Expected:
- API called successfully
- Success state shown
- Email displayed correctly
- Instructions visible
```
### Test Case 2: Invalid email format
```
Input: email = "notanemail"
Expected:
- Validation error: "Email không hợp lệ"
- Form not submitted
```
### Test Case 3: Empty email
```
Input: email = ""
Expected:
- Validation error: "Email là bắt buộc"
- Form not submitted
```
### Test Case 4: Loading state
```
Action: Submit form
Expected:
- Button disabled
- Spinner shows
- Text: "Đang xử lý..."
```
### Test Case 5: Resend email
```
Action: Click "Gửi lại email" in success state
Expected:
- Return to form state
- Email field cleared
- Error cleared
```
### Test Case 6: Back to login
```
Action: Click "Quay lại đăng nhập"
Expected:
- Navigate to /login
```
### Test Case 7: Email not found (Backend)
```
Input: email = "nonexistent@example.com"
Expected:
- Still return success (security)
- No error shown to user
```
### Test Case 8: Token expiry (Backend)
```
Scenario: Token created 2 hours ago
Expected:
- Reset fails
- Error: "Invalid or expired reset token"
```
## 📝 Code Quality
**TypeScript**: Full type safety
**React Hook Form**: Optimized performance
**Yup Validation**: Schema-based validation
**State Management**: Local state for success toggle
**Error Handling**: Try-catch blocks
**Security**: Token hashing, email enumeration prevention
**UX**: Clear instructions, help section
**Accessibility**: Labels, autocomplete, focus management
**Responsive**: Mobile-friendly
**80 chars/line**: Code formatting
## 🔗 Integration Points
### useAuthStore.forgotPassword()
```typescript
const { forgotPassword, isLoading, error, clearError } =
useAuthStore();
await forgotPassword({ email: data.email });
```
### Success Handling
```typescript
await forgotPassword({ email: data.email });
setIsSuccess(true); // Show success state
```
### Error Handling
```typescript
try {
await forgotPassword({ email });
} catch (error) {
// Error displayed via store.error
console.error('Forgot password error:', error);
}
```
## 🚀 Usage
### Test Frontend
```bash
URL: http://localhost:5173/forgot-password
Test Data:
Email: admin@hotel.com (from seed data)
```
### Test Backend API
```bash
curl -X POST http://localhost:3000/api/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email":"admin@hotel.com"}'
```
Expected response:
```json
{
"status": "success",
"message": "Password reset link has been sent to your email"
}
```
Ensure SMTP is configured in `server/.env` and that the server
is able to send emails. The application must never log raw reset
tokens or reset URLs in production.
## ⚠️ TODO: Email Service
The project must send real emails via SMTP in production and must
never expose raw reset tokens in logs. To enable email sending:
1. Install `nodemailer` in the `server` package and configure
SMTP credentials in `server/.env` (`MAIL_HOST`, `MAIL_PORT`,
`MAIL_USER`, `MAIL_PASS`, `MAIL_FROM`). Do not commit these
credentials to source control.
2. Implement a mail helper (e.g. `server/src/utils/mailer.js`) that
uses the SMTP settings. Ensure it throws or fails when SMTP
credentials are missing so that emails are not silently dropped.
3. Use the mail helper to send the reset email with a link built as
`${process.env.CLIENT_URL}/reset-password/${resetToken}`.
4. Important: never log the raw `resetToken` or the reset URL. If
email sending fails, log a generic error and surface a safe
message to the user.
## ✅ Checklist
- [x] ✅ Create ForgotPasswordPage.tsx
- [x] ✅ Implement React Hook Form
- [x] ✅ Add Yup validation
- [x] ✅ Two-state design (form + success)
- [x] ✅ Loading state
- [x] ✅ Error display
- [x] ✅ Success state with instructions
- [x] ✅ Resend email button
- [x] ✅ Back to login navigation
- [x] ✅ Help section
- [x] ✅ Integration with useAuthStore
- [x] ✅ Add route to App.tsx
- [x] ✅ Backend: forgotPassword() method
- [x] ✅ Backend: resetPassword() method
- [x] ✅ Backend: Routes added
- [x] ✅ Token generation & hashing
- [x] ✅ Token expiry (1 hour)
- [x] ✅ Security: Email enumeration prevention
- [ ] ⏳ TODO: Send actual email (nodemailer)
- [ ] ⏳ TODO: Email templates
## 📚 Related Files
- `client/src/pages/auth/LoginPage.tsx` - Login form
- `client/src/pages/auth/RegisterPage.tsx` - Register form
- `client/src/utils/validationSchemas.ts` - Validation schemas
- `client/src/store/useAuthStore.ts` - Auth state
- `server/src/controllers/authController.js` - Auth logic
- `server/src/routes/authRoutes.js` - Auth routes
- `server/src/databases/models/PasswordResetToken.js` - Token model
---
**Status:** ✅ Chức năng 6 hoàn thành
**Next:** Chức năng 7 - Reset Password (form to change password with token)
**Test URL:** http://localhost:5173/forgot-password
**API:** POST /api/auth/forgot-password

View File

@@ -0,0 +1,175 @@
# Layout Components - Chức năng 1
## Tổng quan
Đã triển khai thành công **Chức năng 1: Layout cơ bản** bao gồm:
### Components đã tạo
#### 1. **Header** (`src/components/layout/Header.tsx`)
- Logo và tên ứng dụng
- Sticky header với shadow
- Responsive design
- Links cơ bản (Trang chủ, Phòng, Đặt phòng)
#### 2. **Footer** (`src/components/layout/Footer.tsx`)
- Thông tin công ty
- Quick links (Liên kết nhanh)
- Support links (Hỗ trợ)
- Contact info (Thông tin liên hệ)
- Social media icons
- Copyright info
- Fully responsive (4 columns → 2 → 1)
#### 3. **Navbar** (`src/components/layout/Navbar.tsx`)
- **Trạng thái chưa đăng nhập**:
- Hiển thị nút "Đăng nhập" và "Đăng ký"
- **Trạng thái đã đăng nhập**:
- Hiển thị avatar/tên user
- Dropdown menu với "Hồ sơ", "Quản trị" (admin), "Đăng xuất"
- Mobile menu với hamburger icon
- Responsive cho desktop và mobile
#### 4. **SidebarAdmin** (`src/components/layout/SidebarAdmin.tsx`)
- Chỉ hiển thị cho role = "admin"
- Collapsible sidebar (mở/đóng)
- Menu items: Dashboard, Users, Rooms, Bookings, Payments, Services, Promotions, Banners, Reports, Settings
- Active state highlighting
- Responsive design
#### 5. **LayoutMain** (`src/components/layout/LayoutMain.tsx`)
- Tích hợp Header, Navbar, Footer
- Sử dụng `<Outlet />` để render nội dung động
- Props: `isAuthenticated`, `userInfo`, `onLogout`
- Min-height 100vh với flex layout
### Cấu trúc thư mục
```
src/
├── components/
│ └── layout/
│ ├── Header.tsx
│ ├── Footer.tsx
│ ├── Navbar.tsx
│ ├── SidebarAdmin.tsx
│ ├── LayoutMain.tsx
│ └── index.ts
├── pages/
│ ├── HomePage.tsx
│ └── AdminLayout.tsx
├── styles/
│ └── index.css
├── App.tsx
└── main.tsx
```
### Cách sử dụng
#### 1. Import Layout vào App
```tsx
import LayoutMain from './components/layout/LayoutMain';
// Trong Routes
<Route
path="/"
element={
<LayoutMain
isAuthenticated={isAuthenticated}
userInfo={userInfo}
onLogout={handleLogout}
/>
}
>
<Route index element={<HomePage />} />
{/* Các route con khác */}
</Route>
```
#### 2. Sử dụng SidebarAdmin cho trang Admin
```tsx
import SidebarAdmin from '../components/layout/SidebarAdmin';
const AdminLayout = () => (
<div className="flex h-screen">
<SidebarAdmin />
<div className="flex-1 overflow-auto">
<Outlet />
</div>
</div>
);
```
### Tính năng đã hoàn thành ✅
- [x] Tạo thư mục `src/components/layout/`
- [x] Header.tsx với logo và navigation
- [x] Footer.tsx với thông tin đầy đủ
- [x] Navbar.tsx với logic đăng nhập/đăng xuất động
- [x] SidebarAdmin.tsx chỉ hiển thị với role admin
- [x] LayoutMain.tsx sử dụng `<Outlet />`
- [x] Navbar thay đổi theo trạng thái đăng nhập
- [x] Giao diện responsive, tương thích desktop/mobile
- [x] Tích hợp TailwindCSS cho styling
- [x] Export tất cả components qua index.ts
### Demo Routes đã tạo
**Public Routes** (với LayoutMain):
- `/` - Trang chủ
- `/rooms` - Danh sách phòng
- `/bookings` - Đặt phòng
- `/about` - Giới thiệu
**Auth Routes** (không có layout):
- `/login` - Đăng nhập
- `/register` - Đăng ký
- `/forgot-password` - Quên mật khẩu
**Admin Routes** (với SidebarAdmin):
- `/admin/dashboard` - Dashboard
- `/admin/users` - Quản lý người dùng
- `/admin/rooms` - Quản lý phòng
- `/admin/bookings` - Quản lý đặt phòng
- `/admin/payments` - Quản lý thanh toán
- `/admin/services` - Quản lý dịch vụ
- `/admin/promotions` - Quản lý khuyến mãi
- `/admin/banners` - Quản lý banner
### Chạy ứng dụng
```bash
# Di chuyển vào thư mục client
cd client
# Cài đặt dependencies (nếu chưa cài)
npm install
# Chạy development server
npm run dev
# Mở trình duyệt tại: http://localhost:5173
```
### Các bước tiếp theo
**Chức năng 2**: Cấu hình Routing (react-router-dom)
- ProtectedRoute component
- AdminRoute component
- Redirect logic
**Chức năng 3**: useAuthStore (Zustand Store)
- Quản lý authentication state
- Login/Logout functions
- Persist state trong localStorage
**Chức năng 4-8**: Auth Forms
- LoginPage
- RegisterPage
- ForgotPasswordPage
- ResetPasswordPage
### Notes
- Layout components được thiết kế để tái sử dụng
- Props-based design cho flexibility
- Sẵn sàng tích hợp với Zustand store
- Tailwind classes tuân thủ 80 ký tự/dòng
- Icons sử dụng lucide-react (đã có trong dependencies)

Some files were not shown because too many files have changed in this diff Show More