Hotel Booking
This commit is contained in:
5
client/.env.example
Normal file
5
client/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# API Configuration
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
VITE_ENV=development
|
||||||
18
client/.eslintrc.cjs
Normal file
18
client/.eslintrc.cjs
Normal 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
29
client/.gitignore
vendored
Normal 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
160
client/ROUTING_GUIDE.md
Normal 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
|
||||||
|
```
|
||||||
305
client/ZUSTAND_AUTH_GUIDE.md
Normal file
305
client/ZUSTAND_AUTH_GUIDE.md
Normal 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
13
client/index.html
Normal 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
5098
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
client/package.json
Normal file
43
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
338
client/src/App.tsx
Normal file
338
client/src/App.tsx
Normal 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;
|
||||||
62
client/src/components/auth/AdminRoute.tsx
Normal file
62
client/src/components/auth/AdminRoute.tsx
Normal 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;
|
||||||
55
client/src/components/auth/ProtectedRoute.tsx
Normal file
55
client/src/components/auth/ProtectedRoute.tsx
Normal 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;
|
||||||
2
client/src/components/auth/index.ts
Normal file
2
client/src/components/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ProtectedRoute } from './ProtectedRoute';
|
||||||
|
export { default as AdminRoute } from './AdminRoute';
|
||||||
100
client/src/components/common/EmptyState.tsx
Normal file
100
client/src/components/common/EmptyState.tsx
Normal 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;
|
||||||
143
client/src/components/common/ErrorBoundary.tsx
Normal file
143
client/src/components/common/ErrorBoundary.tsx
Normal 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, đã có 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;
|
||||||
60
client/src/components/common/Loading.tsx
Normal file
60
client/src/components/common/Loading.tsx
Normal 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;
|
||||||
86
client/src/components/common/OptimizedImage.tsx
Normal file
86
client/src/components/common/OptimizedImage.tsx
Normal 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;
|
||||||
162
client/src/components/common/Pagination.tsx
Normal file
162
client/src/components/common/Pagination.tsx
Normal 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;
|
||||||
137
client/src/components/common/PaymentMethodSelector.tsx
Normal file
137
client/src/components/common/PaymentMethodSelector.tsx
Normal 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;
|
||||||
98
client/src/components/common/PaymentStatusBadge.tsx
Normal file
98
client/src/components/common/PaymentStatusBadge.tsx
Normal 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;
|
||||||
197
client/src/components/layout/Footer.tsx
Normal file
197
client/src/components/layout/Footer.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} Hotel Booking.
|
||||||
|
All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
370
client/src/components/layout/Header.tsx
Normal file
370
client/src/components/layout/Header.tsx
Normal 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;
|
||||||
44
client/src/components/layout/LayoutMain.tsx
Normal file
44
client/src/components/layout/LayoutMain.tsx
Normal 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;
|
||||||
205
client/src/components/layout/SidebarAdmin.tsx
Normal file
205
client/src/components/layout/SidebarAdmin.tsx
Normal 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;
|
||||||
4
client/src/components/layout/index.ts
Normal file
4
client/src/components/layout/index.ts
Normal 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';
|
||||||
152
client/src/components/rooms/BannerCarousel.tsx
Normal file
152
client/src/components/rooms/BannerCarousel.tsx
Normal 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;
|
||||||
19
client/src/components/rooms/BannerSkeleton.tsx
Normal file
19
client/src/components/rooms/BannerSkeleton.tsx
Normal 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;
|
||||||
144
client/src/components/rooms/FavoriteButton.tsx
Normal file
144
client/src/components/rooms/FavoriteButton.tsx
Normal 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;
|
||||||
139
client/src/components/rooms/Pagination.tsx
Normal file
139
client/src/components/rooms/Pagination.tsx
Normal 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;
|
||||||
90
client/src/components/rooms/RatingStars.tsx
Normal file
90
client/src/components/rooms/RatingStars.tsx
Normal 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;
|
||||||
312
client/src/components/rooms/ReviewSection.tsx
Normal file
312
client/src/components/rooms/ReviewSection.tsx
Normal 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;
|
||||||
217
client/src/components/rooms/RoomAmenities.tsx
Normal file
217
client/src/components/rooms/RoomAmenities.tsx
Normal 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: 'Wi‑Fi',
|
||||||
|
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;
|
||||||
222
client/src/components/rooms/RoomCard.tsx
Normal file
222
client/src/components/rooms/RoomCard.tsx
Normal 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;
|
||||||
56
client/src/components/rooms/RoomCardSkeleton.tsx
Normal file
56
client/src/components/rooms/RoomCardSkeleton.tsx
Normal 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;
|
||||||
444
client/src/components/rooms/RoomFilter.tsx
Normal file
444
client/src/components/rooms/RoomFilter.tsx
Normal 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;
|
||||||
180
client/src/components/rooms/RoomGallery.tsx
Normal file
180
client/src/components/rooms/RoomGallery.tsx
Normal 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;
|
||||||
178
client/src/components/rooms/SearchRoomForm.tsx
Normal file
178
client/src/components/rooms/SearchRoomForm.tsx
Normal 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;
|
||||||
12
client/src/components/rooms/index.ts
Normal file
12
client/src/components/rooms/index.ts
Normal 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';
|
||||||
277
client/src/examples/useAuthStoreExamples.tsx
Normal file
277
client/src/examples/useAuthStoreExamples.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
client/src/hooks/useDebounce.ts
Normal file
28
client/src/hooks/useDebounce.ts
Normal 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;
|
||||||
34
client/src/hooks/usePagePerformance.ts
Normal file
34
client/src/hooks/usePagePerformance.ts
Normal 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
17
client/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
21
client/src/pages/AdminLayout.tsx
Normal file
21
client/src/pages/AdminLayout.tsx
Normal 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;
|
||||||
410
client/src/pages/HomePage.tsx
Normal file
410
client/src/pages/HomePage.tsx
Normal 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;
|
||||||
314
client/src/pages/admin/BookingManagementPage.tsx
Normal file
314
client/src/pages/admin/BookingManagementPage.tsx
Normal 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;
|
||||||
402
client/src/pages/admin/CheckInPage.tsx
Normal file
402
client/src/pages/admin/CheckInPage.tsx
Normal 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;
|
||||||
448
client/src/pages/admin/CheckOutPage.tsx
Normal file
448
client/src/pages/admin/CheckOutPage.tsx
Normal 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;
|
||||||
290
client/src/pages/admin/DashboardPage.tsx
Normal file
290
client/src/pages/admin/DashboardPage.tsx
Normal 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 có 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 có 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 có 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 có dữ liệu</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
195
client/src/pages/admin/PaymentManagementPage.tsx
Normal file
195
client/src/pages/admin/PaymentManagementPage.tsx
Normal 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;
|
||||||
488
client/src/pages/admin/PromotionManagementPage.tsx
Normal file
488
client/src/pages/admin/PromotionManagementPage.tsx
Normal 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 lý khuyến mãi</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Quản lý mã giảm giá và 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">
|
||||||
|
Mã 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">
|
||||||
|
Mã 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">
|
||||||
|
Mô 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;
|
||||||
206
client/src/pages/admin/ReviewManagementPage.tsx
Normal file
206
client/src/pages/admin/ReviewManagementPage.tsx
Normal 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;
|
||||||
512
client/src/pages/admin/RoomManagementPage.tsx
Normal file
512
client/src/pages/admin/RoomManagementPage.tsx
Normal 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 lý phòng</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Quản lý 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;
|
||||||
336
client/src/pages/admin/ServiceManagementPage.tsx
Normal file
336
client/src/pages/admin/ServiceManagementPage.tsx
Normal 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">
|
||||||
|
Mô 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">
|
||||||
|
Mô 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;
|
||||||
412
client/src/pages/admin/UserManagementPage.tsx
Normal file
412
client/src/pages/admin/UserManagementPage.tsx
Normal 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;
|
||||||
10
client/src/pages/admin/index.ts
Normal file
10
client/src/pages/admin/index.ts
Normal 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';
|
||||||
328
client/src/pages/auth/ForgotPasswordPage.tsx
Normal file
328
client/src/pages/auth/ForgotPasswordPage.tsx
Normal 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;
|
||||||
287
client/src/pages/auth/LoginPage.tsx
Normal file
287
client/src/pages/auth/LoginPage.tsx
Normal 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;
|
||||||
517
client/src/pages/auth/RegisterPage.tsx
Normal file
517
client/src/pages/auth/RegisterPage.tsx
Normal 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;
|
||||||
531
client/src/pages/auth/ResetPasswordPage.tsx
Normal file
531
client/src/pages/auth/ResetPasswordPage.tsx
Normal 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 có 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ử lý...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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ỉ có hiệu lực trong 1 giờ</li>
|
||||||
|
<li>Mật khẩu được mã 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;
|
||||||
4
client/src/pages/auth/index.ts
Normal file
4
client/src/pages/auth/index.ts
Normal 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';
|
||||||
644
client/src/pages/customer/BookingDetailPage.tsx
Normal file
644
client/src/pages/customer/BookingDetailPage.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
Mã đặ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ọ và 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;
|
||||||
125
client/src/pages/customer/BookingListPage.tsx
Normal file
125
client/src/pages/customer/BookingListPage.tsx
Normal 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 lý và 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;
|
||||||
839
client/src/pages/customer/BookingPage.tsx
Normal file
839
client/src/pages/customer/BookingPage.tsx
Normal 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;
|
||||||
826
client/src/pages/customer/BookingSuccessPage.tsx
Normal file
826
client/src/pages/customer/BookingSuccessPage.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
Mã đặ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ọ và 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 mã 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"
|
||||||
|
>
|
||||||
|
Mã 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 mã đặt phòng
|
||||||
|
vào nội dung chuyển khoản để chúng tôi
|
||||||
|
có 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;
|
||||||
243
client/src/pages/customer/DashboardPage.tsx
Normal file
243
client/src/pages/customer/DashboardPage.tsx
Normal 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;
|
||||||
546
client/src/pages/customer/DepositPaymentPage.tsx
Normal file
546
client/src/pages/customer/DepositPaymentPage.tsx
Normal 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">
|
||||||
|
Mã 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 mã 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 mã 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 mã QR
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DepositPaymentPage;
|
||||||
203
client/src/pages/customer/FavoritesPage.tsx
Normal file
203
client/src/pages/customer/FavoritesPage.tsx
Normal 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 có 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á và 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;
|
||||||
639
client/src/pages/customer/MyBookingsPage.tsx
Normal file
639
client/src/pages/customer/MyBookingsPage.tsx
Normal 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;
|
||||||
536
client/src/pages/customer/PaymentConfirmationPage.tsx
Normal file
536
client/src/pages/customer/PaymentConfirmationPage.tsx
Normal 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;
|
||||||
251
client/src/pages/customer/PaymentResultPage.tsx
Normal file
251
client/src/pages/customer/PaymentResultPage.tsx
Normal 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;
|
||||||
281
client/src/pages/customer/RoomDetailPage.tsx
Normal file
281
client/src/pages/customer/RoomDetailPage.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
Mô 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;
|
||||||
194
client/src/pages/customer/RoomListPage.tsx
Normal file
194
client/src/pages/customer/RoomListPage.tsx
Normal 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;
|
||||||
357
client/src/pages/customer/SearchResultsPage.tsx
Normal file
357
client/src/pages/customer/SearchResultsPage.tsx
Normal 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;
|
||||||
86
client/src/services/api/apiClient.ts
Normal file
86
client/src/services/api/apiClient.ts
Normal 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;
|
||||||
137
client/src/services/api/authService.ts
Normal file
137
client/src/services/api/authService.ts
Normal 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;
|
||||||
54
client/src/services/api/bannerService.ts
Normal file
54
client/src/services/api/bannerService.ts
Normal 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,
|
||||||
|
};
|
||||||
314
client/src/services/api/bookingService.ts
Normal file
314
client/src/services/api/bookingService.ts
Normal 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,
|
||||||
|
};
|
||||||
95
client/src/services/api/favoriteService.ts
Normal file
95
client/src/services/api/favoriteService.ts
Normal 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,
|
||||||
|
};
|
||||||
33
client/src/services/api/index.ts
Normal file
33
client/src/services/api/index.ts
Normal 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';
|
||||||
188
client/src/services/api/paymentService.ts
Normal file
188
client/src/services/api/paymentService.ts
Normal 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,
|
||||||
|
};
|
||||||
147
client/src/services/api/promotionService.ts
Normal file
147
client/src/services/api/promotionService.ts
Normal 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,
|
||||||
|
};
|
||||||
87
client/src/services/api/reportService.ts
Normal file
87
client/src/services/api/reportService.ts
Normal 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,
|
||||||
|
};
|
||||||
117
client/src/services/api/reviewService.ts
Normal file
117
client/src/services/api/reviewService.ts
Normal 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,
|
||||||
|
};
|
||||||
179
client/src/services/api/roomService.ts
Normal file
179
client/src/services/api/roomService.ts
Normal 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,
|
||||||
|
};
|
||||||
126
client/src/services/api/serviceService.ts
Normal file
126
client/src/services/api/serviceService.ts
Normal 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,
|
||||||
|
};
|
||||||
117
client/src/services/api/userService.ts
Normal file
117
client/src/services/api/userService.ts
Normal 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,
|
||||||
|
};
|
||||||
275
client/src/store/useAuthStore.ts
Normal file
275
client/src/store/useAuthStore.ts
Normal 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;
|
||||||
279
client/src/store/useFavoritesStore.ts
Normal file
279
client/src/store/useFavoritesStore.ts
Normal 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;
|
||||||
94
client/src/styles/datepicker.css
Normal file
94
client/src/styles/datepicker.css
Normal 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
114
client/src/styles/index.css
Normal 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;
|
||||||
|
}
|
||||||
95
client/src/utils/validationSchemas.ts
Normal file
95
client/src/utils/validationSchemas.ts
Normal 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>;
|
||||||
65
client/src/validators/bookingValidator.ts
Normal file
65
client/src/validators/bookingValidator.ts
Normal 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
9
client/src/vite-env.d.ts
vendored
Normal 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
26
client/tailwind.config.js
Normal 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
31
client/tsconfig.json
Normal 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
10
client/tsconfig.node.json
Normal 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
22
client/vite.config.ts
Normal 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
25
docker-compose.yml
Normal 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:
|
||||||
|
|
||||||
488
docs/FORGOT_PASSWORD_COMPLETE.md
Normal file
488
docs/FORGOT_PASSWORD_COMPLETE.md
Normal 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ử lý...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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
|
||||||
175
docs/LAYOUT_IMPLEMENTATION.md
Normal file
175
docs/LAYOUT_IMPLEMENTATION.md
Normal 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
Reference in New Issue
Block a user