update to python fastpi

This commit is contained in:
Iliyan Angelov
2025-11-16 15:59:05 +02:00
parent 93d4c1df80
commit 98ccd5b6ff
4464 changed files with 773233 additions and 13740 deletions

5
Frontend/.env.example Normal file
View File

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

18
Frontend/.eslintrc.cjs Normal file
View File

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

29
Frontend/.gitignore vendored Normal file
View File

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

13
Frontend/index.html Normal file
View File

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

5098
Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
Frontend/package.json Normal file
View File

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

View File

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

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

@@ -0,0 +1,338 @@
import React, { useEffect } from 'react';
import {
BrowserRouter,
Routes,
Route,
Navigate
} from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
// Store
import useAuthStore from './store/useAuthStore';
import useFavoritesStore from './store/useFavoritesStore';
// Layout Components
import { LayoutMain } from './components/layout';
import AdminLayout from './pages/AdminLayout';
// Auth Components
import {
ProtectedRoute,
AdminRoute
} from './components/auth';
// Pages
import HomePage from './pages/HomePage';
import DashboardPage from
'./pages/customer/DashboardPage';
import RoomListPage from
'./pages/customer/RoomListPage';
import RoomDetailPage from
'./pages/customer/RoomDetailPage';
import SearchResultsPage from
'./pages/customer/SearchResultsPage';
import FavoritesPage from
'./pages/customer/FavoritesPage';
import MyBookingsPage from
'./pages/customer/MyBookingsPage';
import BookingPage from
'./pages/customer/BookingPage';
import BookingSuccessPage from
'./pages/customer/BookingSuccessPage';
import BookingDetailPage from
'./pages/customer/BookingDetailPage';
import DepositPaymentPage from
'./pages/customer/DepositPaymentPage';
import PaymentConfirmationPage from
'./pages/customer/PaymentConfirmationPage';
import PaymentResultPage from
'./pages/customer/PaymentResultPage';
import {
LoginPage,
RegisterPage,
ForgotPasswordPage,
ResetPasswordPage
} from './pages/auth';
// Admin Pages
import {
DashboardPage as AdminDashboardPage,
RoomManagementPage,
UserManagementPage,
BookingManagementPage,
PaymentManagementPage,
ServiceManagementPage,
ReviewManagementPage,
PromotionManagementPage,
CheckInPage,
CheckOutPage,
} from './pages/admin';
// Demo component for pages not yet created
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">
This page is under development...
</p>
</div>
);
function App() {
// Use Zustand store
const {
isAuthenticated,
userInfo,
logout,
initializeAuth
} = useAuthStore();
const {
fetchFavorites,
syncGuestFavorites,
loadGuestFavorites,
} = useFavoritesStore();
// Initialize auth state when app loads
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="About" />}
/>
{/* Protected Routes - Requires login */}
<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="Profile" />
</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 - Only admin can access */}
<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="Banner Management" />}
/>
<Route
path="reports"
element={<DemoPage title="Reports" />}
/>
<Route
path="settings"
element={<DemoPage title="Settings" />}
/>
</Route>
{/* 404 Route */}
<Route
path="*"
element={<DemoPage title="404 - Page not found" />}
/>
</Routes>
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
interface AdminRouteProps {
children: React.ReactNode;
}
/**
* AdminRoute - Protects routes that are only for Admin
*
* Checks:
* 1. Is user logged in → if not, redirect to /login
* 2. Does user have admin role → if not, redirect to /
*/
const AdminRoute: React.FC<AdminRouteProps> = ({
children
}) => {
const location = useLocation();
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
// Loading auth state → show 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">
Authenticating...
</p>
</div>
</div>
);
}
// Not logged in → redirect to /login
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location }}
replace
/>
);
}
// Logged in but not admin → redirect to /
const isAdmin = userInfo?.role === 'admin';
if (!isAdmin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
export default AdminRoute;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
interface ProtectedRouteProps {
children: React.ReactNode;
}
/**
* ProtectedRoute - Protects routes that require authentication
*
* If user is not logged in, redirect to /login
* and save current location to redirect back after login
*/
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children
}) => {
const location = useLocation();
const { isAuthenticated, isLoading } = useAuthStore();
// Loading auth state → show 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">
Loading...
</p>
</div>
</div>
);
}
// Not logged in → redirect to /login
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location }}
replace
/>
);
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

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

View File

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

View File

@@ -0,0 +1,143 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-gray-50
flex items-center justify-center p-4"
>
<div className="max-w-md w-full bg-white
rounded-lg shadow-lg p-8"
>
<div className="flex items-center
justify-center w-16 h-16 bg-red-100
rounded-full mx-auto mb-4"
>
<AlertCircle className="w-8 h-8
text-red-600"
/>
</div>
<h1 className="text-2xl font-bold
text-gray-900 text-center mb-2"
>
An Error Occurred
</h1>
<p className="text-gray-600 text-center mb-6">
Sorry, an error has occurred. Please try again
or contact support if the problem persists.
</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"
>
Error Details
</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" />
Reload Page
</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"
>
Go to Home
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,447 @@
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('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(n);
};
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
// Room type select
if (name === 'type') {
setFilters((prev) => ({ ...prev, type: value || '' }));
return;
}
// Capacity input
if (name === 'capacity') {
setFilters((prev) => ({
...prev,
capacity: value === '' ? undefined : Number(value),
}));
return;
}
// Price inputs: allow formatted VN style with dots
if (name === 'minPrice' || name === 'maxPrice') {
const parsed = parseCurrency(value);
setFilters((prev) => ({ ...prev, [name]: parsed }));
return;
}
// Fallback numeric parsing
setFilters((prev) => ({
...prev,
[name]: value === '' ? undefined : Number(value),
}));
};
const formatDate = (d: Date) => d.toISOString().split('T')[0];
// Filters are applied only when user clicks "Áp dụng".
// Debounced values are kept for UX but won't auto-submit.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Build new search params
const newParams = new URLSearchParams(searchParams);
// Reset page to 1 when filters change
newParams.set('page', '1');
// Update search params with filter values
if (filters.type) {
newParams.set('type', filters.type);
} else {
newParams.delete('type');
}
if (filters.minPrice !== undefined && filters.minPrice > 0) {
newParams.set('minPrice', String(filters.minPrice));
} else {
newParams.delete('minPrice');
}
if (filters.maxPrice !== undefined && filters.maxPrice > 0) {
newParams.set('maxPrice', String(filters.maxPrice));
} else {
newParams.delete('maxPrice');
}
if (filters.capacity !== undefined && filters.capacity > 0) {
newParams.set('capacity', String(filters.capacity));
} else {
newParams.delete('capacity');
}
// Dates
if (checkInDate) {
newParams.set('from', formatDate(checkInDate));
} else {
newParams.delete('from');
}
if (checkOutDate) {
newParams.set('to', formatDate(checkOutDate));
} else {
newParams.delete('to');
}
// Amenities
if (selectedAmenities.length > 0) {
newParams.set('amenities', selectedAmenities.join(','));
} else {
newParams.delete('amenities');
}
setSearchParams(newParams);
onFilterChange?.({
...filters,
from: checkInDate ? formatDate(checkInDate) : undefined,
to: checkOutDate ? formatDate(checkOutDate) : undefined,
// include amenities
...(selectedAmenities.length > 0 ? { amenities: selectedAmenities.join(',') } : {}),
});
};
const handleReset = () => {
setFilters({
type: '',
minPrice: undefined,
maxPrice: undefined,
capacity: undefined,
from: undefined,
to: undefined,
});
setCheckInDate(null);
setCheckOutDate(null);
setSelectedAmenities([]);
// Reset URL params but keep the base /rooms path
setSearchParams({});
onFilterChange?.({});
};
const toggleAmenity = (amenity: string) => {
setSelectedAmenities((prev) => {
if (prev.includes(amenity)) return prev.filter((a) => a !== amenity);
return [...prev, amenity];
});
};
return (
<div className="bg-white rounded-lg shadow-md p-4 mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-800">
Room Filters
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Room Type */}
<div>
<label
htmlFor="type"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Room Type
</label>
<select
id="type"
name="type"
value={filters.type || ''}
onChange={handleInputChange}
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-blue-500
focus:border-transparent"
>
<option value="">All</option>
<option value="Standard Room">Standard Room</option>
<option value="Deluxe Room">Deluxe Room</option>
<option value="Luxury Room">Luxury Room</option>
<option value="Family Room">Family Room</option>
<option value="Twin Room">Twin Room</option>
</select>
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="from"
className="block text-sm font-medium text-gray-700 mb-1"
>
Check-in Date
</label>
<DatePicker
selected={checkInDate}
onChange={(date: Date | null) => setCheckInDate(date)}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
minDate={new Date()}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label
htmlFor="to"
className="block text-sm font-medium text-gray-700 mb-1"
>
Check-out Date
</label>
<DatePicker
selected={checkOutDate}
onChange={(date: Date | null) => setCheckOutDate(date)}
selectsEnd
startDate={checkInDate}
endDate={checkOutDate}
minDate={checkInDate || new Date()}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Price Range */}
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="minPrice"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Min Price
</label>
<input
type="text"
id="minPrice"
name="minPrice"
value={
filters.minPrice != null
? formatCurrency(filters.minPrice)
: ''
}
onChange={handleInputChange}
placeholder="0"
inputMode="numeric"
pattern="[0-9.]*"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
/>
</div>
<div>
<label
htmlFor="maxPrice"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Max Price
</label>
<input
type="text"
id="maxPrice"
name="maxPrice"
value={
filters.maxPrice != null
? formatCurrency(filters.maxPrice)
: ''
}
onChange={handleInputChange}
placeholder="10.000.000"
inputMode="numeric"
pattern="[0-9.]*"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
/>
</div>
</div>
{/* Capacity */}
<div>
<label
htmlFor="capacity"
className="block text-sm font-medium
text-gray-700 mb-1"
>
Number of Guests
</label>
<input
type="number"
id="capacity"
name="capacity"
value={filters.capacity || ''}
onChange={handleInputChange}
placeholder="1"
min="1"
max="10"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
/>
</div>
{/* Amenities */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Amenities
</label>
{availableAmenities.length === 0 ? (
<div className="text-sm text-gray-500">Loading amenities...</div>
) : (
<div className="flex flex-col gap-2 max-h-40 overflow-auto pr-2">
{availableAmenities.map((amenity) => (
<label
key={amenity}
className="flex items-center gap-2 text-sm w-full"
>
<input
type="checkbox"
checked={selectedAmenities.includes(amenity)}
onChange={() => toggleAmenity(amenity)}
className="h-4 w-4"
/>
<span className="text-gray-700">{amenity}</span>
</label>
))}
</div>
)}
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
type="submit"
className="flex-1 bg-blue-600 text-white
py-2 px-4 rounded-lg hover:bg-blue-700
transition-colors font-medium"
>
Apply
</button>
<button
type="button"
onClick={handleReset}
className="flex-1 bg-gray-200 text-gray-700
py-2 px-4 rounded-lg hover:bg-gray-300
transition-colors font-medium"
>
Reset
</button>
</div>
</form>
</div>
);
};
export default RoomFilter;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,277 @@
/**
* Example: How to use useAuthStore in components
*
* This file is for reference only, should not be used
* in 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 has been handled in 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 ? 'Processing...' : 'Login'}
</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 ? 'Processing...' : 'Register'}
</button>
);
};
// ============================================
// Example 3: User Profile Display
// ============================================
export const UserProfileExample = () => {
const { userInfo, isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <p>Please login</p>;
}
return (
<div>
<h2>User Information</h2>
<p>Name: {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 ? 'Processing...' : 'Logout'}
</button>
);
};
// ============================================
// Example 5: Forgot Password
// ============================================
export const ForgotPasswordExample = () => {
const { forgotPassword, isLoading } = useAuthStore();
const handleForgotPassword = async (
email: string
) => {
try {
await forgotPassword({ email });
// Toast will display success message
} catch (error) {
console.error('Forgot password failed:', error);
}
};
return (
<button
onClick={() =>
handleForgotPassword('user@example.com')
}
disabled={isLoading}
>
Send password reset email
</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}
>
Reset Password
</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>Loading...</p>;
}
if (!isAuthenticated || !token) {
return <p>You are not logged in</p>;
}
return <p>You are logged in</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}>
Update Information
</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"
>
Close
</button>
</div>
);
};

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,314 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const BookingManagementPage: React.FC = () => {
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchBookings();
}, [filters, currentPage]);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await bookingService.getAllBookings({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setBookings(response.data.bookings);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load bookings list');
} finally {
setLoading(false);
}
};
const handleUpdateStatus = async (id: number, status: string) => {
try {
await bookingService.updateBooking(id, { status } as any);
toast.success('Status updated successfully');
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const handleCancelBooking = async (id: number) => {
if (!window.confirm('Are you sure you want to cancel this booking?')) return;
try {
await bookingService.cancelBooking(id);
toast.success('Booking cancelled successfully');
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to cancel booking');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending confirmation' },
confirmed: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Confirmed' },
checked_in: { bg: 'bg-green-100', text: 'text-green-800', label: 'Checked in' },
checked_out: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Checked out' },
cancelled: { bg: 'bg-red-100', text: 'text-red-800', label: 'Cancelled' },
};
const badge = badges[status] || badges.pending;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).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"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BookingManagementPage;

View File

@@ -0,0 +1,402 @@
import React, { useState } from 'react';
import { Search, User, Hotel, CheckCircle, AlertCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
interface GuestInfo {
name: string;
id_number: string;
phone: string;
}
const CheckInPage: React.FC = () => {
const [bookingNumber, setBookingNumber] = useState('');
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [actualRoomNumber, setActualRoomNumber] = useState('');
const [guests, setGuests] = useState<GuestInfo[]>([{ name: '', id_number: '', phone: '' }]);
const [extraPersons, setExtraPersons] = useState(0);
const [children, setChildren] = useState(0);
const [additionalFee, setAdditionalFee] = useState(0);
const handleSearch = async () => {
if (!bookingNumber.trim()) {
toast.error('Please enter booking number');
return;
}
try {
setSearching(true);
const response = await bookingService.checkBookingByNumber(bookingNumber);
setBooking(response.data.booking);
setActualRoomNumber(response.data.booking.room?.room_number || '');
toast.success('Booking found');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Booking not found');
setBooking(null);
} finally {
setSearching(false);
}
};
const handleAddGuest = () => {
setGuests([...guests, { name: '', id_number: '', phone: '' }]);
};
const handleRemoveGuest = (index: number) => {
if (guests.length > 1) {
setGuests(guests.filter((_, i) => i !== index));
}
};
const handleGuestChange = (index: number, field: keyof GuestInfo, value: string) => {
const newGuests = [...guests];
newGuests[index][field] = value;
setGuests(newGuests);
};
const calculateAdditionalFee = () => {
// Logic to calculate additional fees: children and extra person
const extraPersonFee = extraPersons * 200000; // 200k/person
const childrenFee = children * 100000; // 100k/child
const total = extraPersonFee + childrenFee;
setAdditionalFee(total);
return total;
};
const handleCheckIn = async () => {
if (!booking) return;
// Validate
if (!actualRoomNumber.trim()) {
toast.error('Please enter actual room number');
return;
}
const mainGuest = guests[0];
if (!mainGuest.name || !mainGuest.id_number || !mainGuest.phone) {
toast.error('Please fill in all main guest information');
return;
}
try {
setLoading(true);
// Calculate additional fee
calculateAdditionalFee();
await bookingService.updateBooking(booking.id, {
status: 'checked_in',
// Can send additional data about guests, room_number, additional_fee
} as any);
toast.success('Check-in successful');
// Reset form
setBooking(null);
setBookingNumber('');
setActualRoomNumber('');
setGuests([{ name: '', id_number: '', phone: '' }]);
setExtraPersons(0);
setChildren(0);
setAdditionalFee(0);
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred during check-in');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).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="e.g: 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="John Doe"
/>
</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">50/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">25/child</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Additional Fee
</label>
<div className="px-4 py-2 bg-gray-50 border border-gray-300 rounded-lg text-lg font-semibold text-blue-600">
{formatCurrency(calculateAdditionalFee())}
</div>
</div>
</div>
</div>
{/* Summary & Action */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-200">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900">Confirm Check-in</h3>
<p className="text-sm text-gray-600 mt-1">
Guest: <span className="font-medium">{booking.user?.full_name}</span> |
Room: <span className="font-medium">{actualRoomNumber || 'Not assigned'}</span>
{additionalFee > 0 && (
<> | Additional Fee: <span className="font-medium text-red-600">{formatCurrency(additionalFee)}</span></>
)}
</p>
</div>
<button
onClick={handleCheckIn}
disabled={!actualRoomNumber || !guests[0].name || booking.status !== 'confirmed'}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
>
<CheckCircle className="w-5 h-5" />
Confirm Check-in
</button>
</div>
</div>
</>
)}
{/* Empty State */}
{!booking && !searching && (
<div className="bg-gray-50 rounded-lg p-12 text-center">
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No booking selected
</h3>
<p className="text-gray-600">
Please enter booking number above to start check-in process
</p>
</div>
)}
</div>
);
};
export default CheckInPage;

View File

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

View File

@@ -0,0 +1,290 @@
import React, { useEffect, useState } from 'react';
import {
BarChart3,
Users,
Hotel,
DollarSign,
Calendar,
TrendingUp
} from 'lucide-react';
import { reportService, ReportData } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
const DashboardPage: React.FC = () => {
const [stats, setStats] = useState<ReportData | null>(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
});
useEffect(() => {
fetchDashboardData();
}, [dateRange]);
const fetchDashboardData = async () => {
try {
setLoading(true);
const response = await reportService.getReports({
from: dateRange.from,
to: dateRange.to,
});
setStats(response.data);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load dashboard data');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).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">No data available</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">No data available</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 Booked Rooms</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">Room {room.room_number}</p>
<p className="text-sm text-gray-500">{room.bookings} bookings</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">No data available</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">Services Used</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} times used</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">No data available</p>
)}
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

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

View File

@@ -0,0 +1,488 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
import { promotionService, Promotion } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const PromotionManagementPage: React.FC = () => {
const [promotions, setPromotions] = useState<Promotion[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
type: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
code: '',
name: '',
description: '',
discount_type: 'percentage' as 'percentage' | 'fixed',
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active' as 'active' | 'inactive' | 'expired',
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPromotions();
}, [filters, currentPage]);
const fetchPromotions = async () => {
try {
setLoading(true);
const response = await promotionService.getPromotions({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setPromotions(response.data.promotions);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load promotions list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingPromotion) {
await promotionService.updatePromotion(editingPromotion.id, formData);
toast.success('Promotion updated successfully');
} else {
await promotionService.createPromotion(formData);
toast.success('Promotion added successfully');
}
setShowModal(false);
resetForm();
fetchPromotions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (promotion: Promotion) => {
setEditingPromotion(promotion);
setFormData({
code: promotion.code,
name: promotion.name,
description: promotion.description || '',
discount_type: promotion.discount_type,
discount_value: promotion.discount_value,
min_booking_amount: promotion.min_booking_amount || 0,
max_discount_amount: promotion.max_discount_amount || 0,
start_date: promotion.start_date?.split('T')[0] || '',
end_date: promotion.end_date?.split('T')[0] || '',
usage_limit: promotion.usage_limit || 0,
status: promotion.status,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this promotion?')) return;
try {
await promotionService.deletePromotion(id);
toast.success('Promotion deleted successfully');
fetchPromotions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete promotion');
}
};
const resetForm = () => {
setEditingPromotion(null);
setFormData({
code: '',
name: '',
description: '',
discount_type: 'percentage',
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).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">Promotion Management</h1>
<p className="text-gray-500 mt-1">Manage discount codes and promotion programs</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" />
Add Promotion
</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="Search by code or 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>
</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="">All Types</option>
<option value="percentage">Percentage</option>
<option value="fixed">Fixed Amount</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>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Code
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Program Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Value
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Period
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Used
</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">
{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 ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Code <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="e.g: SUMMER2024"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Program Name <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="e.g: Summer Sale"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</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="Detailed description of the program..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Discount Type <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">Percentage (%)</option>
<option value="fixed">Fixed Amount (EUR)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Discount Value <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">
Minimum Order Value (EUR)
</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">
Maximum Discount (EUR)
</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">
Start Date <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">
End Date <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">
Usage Limit (0 = unlimited)
</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">
Status
</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">Active</option>
<option value="inactive">Inactive</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"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingPromotion ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default PromotionManagementPage;

View File

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

View File

@@ -0,0 +1,512 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon } from 'lucide-react';
import { roomService, Room } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import apiClient from '../../services/api/apiClient';
const RoomManagementPage: React.FC = () => {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
type: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
room_number: '',
floor: 1,
room_type_id: 1,
status: 'available' as 'available' | 'occupied' | 'maintenance',
featured: false,
});
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchRooms();
}, [filters, currentPage]);
const fetchRooms = async () => {
try {
setLoading(true);
const response = await roomService.getRooms({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setRooms(response.data.rooms);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load rooms list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingRoom) {
// Update room
await roomService.updateRoom(editingRoom.id, formData);
toast.success('Room updated successfully');
} else {
// Create room
await roomService.createRoom(formData);
toast.success('Room added successfully');
}
setShowModal(false);
resetForm();
fetchRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (room: Room) => {
setEditingRoom(room);
setFormData({
room_number: room.room_number,
floor: room.floor,
room_type_id: room.room_type_id,
status: room.status,
featured: room.featured,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this room?')) return;
try {
await roomService.deleteRoom(id);
toast.success('Room deleted successfully');
fetchRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete room');
}
};
const resetForm = () => {
setEditingRoom(null);
setFormData({
room_number: '',
floor: 1,
room_type_id: 1,
status: 'available',
featured: false,
});
setSelectedFiles([]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
setSelectedFiles(files);
}
};
const handleUploadImages = async () => {
if (!editingRoom || selectedFiles.length === 0) return;
try {
setUploadingImages(true);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
await apiClient.post(`/rooms/${editingRoom.id}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
toast.success('Images uploaded successfully');
setSelectedFiles([]);
fetchRooms();
// Refresh editing room data
const response = await roomService.getRoomById(editingRoom.id);
setEditingRoom(response.data.room);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to upload images');
} finally {
setUploadingImages(false);
}
};
const handleDeleteImage = async (imageUrl: string) => {
if (!editingRoom) return;
if (!window.confirm('Are you sure you want to delete this image?')) return;
try {
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
data: { imageUrl },
});
toast.success('Image deleted successfully');
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 delete image');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
available: { bg: 'bg-green-100', text: 'text-green-800', label: 'Available' },
occupied: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Occupied' },
maintenance: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Maintenance' },
};
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">Room Management</h1>
<p className="text-gray-500 mt-1">Manage hotel room information</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" />
Add Room
</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="Search rooms..."
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="available">Available</option>
<option value="occupied">Occupied</option>
<option value="maintenance">Maintenance</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="">All Room Types</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">
Room Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Room Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Floor
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Featured
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</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">Floor {room.floor}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).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 ? 'Update Room' : 'Add New Room'}
</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">
Room Number
</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">
Floor
</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">
Room Type
</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">
Status
</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">Available</option>
<option value="occupied">Occupied</option>
<option value="maintenance">Maintenance</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">
Featured Room
</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"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingRoom ? 'Update' : 'Add'}
</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" />
Room Images
</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">Current Images:</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:8000${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">
Add New Images (max 5 images):
</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 ? 'Uploading...' : 'Upload'}
</button>
</div>
{selectedFiles.length > 0 && (
<p className="text-sm text-gray-600 mt-2">
{selectedFiles.length} file(s) selected
</p>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default RoomManagementPage;

View File

@@ -0,0 +1,336 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import { serviceService, Service } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const ServiceManagementPage: React.FC = () => {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
name: '',
description: '',
price: 0,
unit: 'time',
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('de-DE', {
style: 'currency',
currency: 'EUR',
}).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="">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">
Service Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Unit
</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">
{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' ? 'Active' : 'Inactive'}
</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 ? 'Update Service' : 'Add New Service'}
</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">
Service Name
</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">
Description
</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">
Price
</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">
Unit
</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="e.g: time, hour, day..."
/>
</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 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">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"
>
{editingService ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ServiceManagementPage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { useNavigate, useParams, Link } from 'react-router-dom';
import {
Eye,
EyeOff,
Lock,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
KeyRound,
Hotel,
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
resetPasswordSchema,
ResetPasswordFormData,
} from '../../utils/validationSchemas';
const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { token } = useParams<{ token: string }>();
const { resetPassword, isLoading, error, clearError } =
useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// React Hook Form setup
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<ResetPasswordFormData>({
resolver: yupResolver(resetPasswordSchema),
defaultValues: {
password: '',
confirmPassword: '',
},
});
// Watch password to display 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: '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: 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 has been handled in 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 ? 'Complete!' : 'Reset Password'}
</h2>
<p className="mt-2 text-sm text-gray-600">
{isSuccess
? 'Password has been reset successfully'
: 'Enter a new password for your account'}
</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"
>
Password reset successful!
</h3>
<p className="text-sm text-gray-600">
Your password has been updated.
</p>
<p className="text-sm text-gray-600">
You can now login with your new password.
</p>
</div>
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-4"
>
<p className="text-sm text-gray-700">
Redirecting to login page...
</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" />
Login Now
</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
? 'New password must be different from old password'
: error}
</p>
{isTokenError && (
<Link
to="/forgot-password"
className="mt-2 inline-block text-sm
font-medium underline
hover:text-yellow-900"
>
Request new link
</Link>
)}
</div>
</div>
)}
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
>
New 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"
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="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-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"
/>
Processing...
</>
) : (
<>
<KeyRound
className="-ml-1 mr-2 h-5 w-5"
/>
Reset Password
</>
)}
</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"
>
Back to Login
</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" />
Security
</h3>
<ul
className="text-xs text-gray-600 space-y-1
list-disc list-inside"
>
<li>Reset link is valid for 1 hour only</li>
<li>Password is securely encrypted</li>
<li>
If the link expires, please request a new link
</li>
</ul>
</div>
)}
</div>
</div>
);
};
// Helper component for password requirements
const PasswordRequirement: React.FC<{
met: boolean;
text: string;
}> = ({ met, text }) => (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-gray-300" />
)}
<span className={met ? 'text-green-600' : 'text-gray-500'}>
{text}
</span>
</div>
);
export default ResetPasswordPage;

View File

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

View File

@@ -0,0 +1,643 @@
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(
'Please login to view booking details'
);
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(
'Unable to load booking information'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Unable to load booking information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const handleCancelBooking = async () => {
if (!booking) return;
const confirmed = window.confirm(
`Are you sure you want to cancel booking ` +
`${booking.booking_number}?\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 {
setCancelling(true);
const response = await cancelBooking(booking.id);
if (response.success) {
toast.success(
`✅ Booking ${booking.booking_number} cancelled successfully!`
);
// Update local state
setBooking((prev) =>
prev
? { ...prev, status: 'cancelled' }
: null
);
} 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 {
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('de-DE', {
style: 'currency',
currency: 'EUR',
}).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) => {
return (
booking.status === 'pending' ||
booking.status === 'confirmed'
);
};
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 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>Back to list</span>
</Link>
{/* Page Title */}
<div className="flex items-center justify-between
mb-6"
>
<h1 className="text-3xl font-bold text-gray-900">
Booking Details
</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"
>
Booking Number
</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"
>
Room Information
</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" />
Room {room?.room_number} -
Floor {room?.floor}
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">
Capacity
</p>
<p className="font-medium text-gray-900">
Max {roomType.capacity} guests
</p>
</div>
<div>
<p className="text-sm text-gray-500">
Room Price
</p>
<p className="font-medium text-indigo-600">
{formatPrice(roomType.base_price)}/night
</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"
>
Booking Details
</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" />
Check-in Date
</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" />
Check-out Date
</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" />
Number of Guests
</p>
<p className="font-medium text-gray-900">
{booking.guest_count} guest(s)
</p>
</div>
{/* Notes */}
{booking.notes && (
<div>
<p className="text-sm text-gray-600 mb-1">
<FileText className="w-4 h-4 inline mr-1" />
Notes
</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" />
Payment Method
</p>
<p className="font-medium text-gray-900 mb-2">
{booking.payment_method === 'cash'
? '💵 Pay at hotel'
: '🏦 Bank transfer'}
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
Status:
</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"
>
Total Payment
</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"
>
Customer Information
</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">
<User className="w-4 h-4 inline mr-1" />
Full Name
</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" />
Phone Number
</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">
Bank Transfer Information
</h3>
<div className="bg-white rounded 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>
</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" />
Confirm Payment
</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"
/>
Cancelling...
</>
) : (
<>
<XCircle className="w-5 h-5" />
Cancel Booking
</>
)}
</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"
>
Back to list
</Link>
</div>
</div>
</div>
);
};
export default BookingDetailPage;

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { Calendar, Clock, DollarSign } from 'lucide-react';
const BookingListPage: React.FC = () => {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Booking History
</h1>
<p className="text-gray-600">
Manage and track your bookings
</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"
>
Room {booking}01 - Deluxe
</h3>
<span className="px-3 py-1
bg-green-100 text-green-800
rounded-full text-sm font-medium"
>
Confirmed
</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>
Check-in: 15/11/2025
</span>
</div>
<div className="flex items-center
space-x-2"
>
<Calendar className="w-4 h-4
text-blue-500"
/>
<span>
Check-out: 18/11/2025
</span>
</div>
<div className="flex items-center
space-x-2"
>
<Clock className="w-4 h-4
text-blue-500"
/>
<span>3 nights</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"
>
View Details
</button>
</div>
</div>
</div>
))}
</div>
{/* Empty State */}
{/* Uncomment when there are no bookings
<div className="text-center py-12">
<p className="text-gray-500 text-lg">
You don't have any bookings yet
</p>
<button className="mt-4 px-6 py-3
bg-blue-600 text-white rounded-lg
hover:bg-blue-700 transition-colors"
>
Book Now
</button>
</div>
*/}
</div>
);
};
export default BookingListPage;

View File

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

View File

@@ -0,0 +1,823 @@
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(
'Unable to load booking information'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Unable to load booking information';
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('de-DE', {
style: 'currency',
currency: 'EUR',
}).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 'Confirmed';
case 'pending':
return 'Pending confirmation';
case 'cancelled':
return 'Cancelled';
case 'checked_in':
return 'Checked in';
case 'checked_out':
return 'Checked out';
default:
return status;
}
};
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;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('Image size must not exceed 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(
'✅ Payment confirmation sent successfully! ' +
'We will confirm as soon as possible.'
);
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 ||
'Unable to confirm payment'
);
}
} catch (err: any) {
console.error('Error uploading receipt:', err);
const message =
err.response?.data?.message ||
'Unable to send payment confirmation. ' +
'Please try again.';
toast.error(message);
} finally {
setUploadingReceipt(false);
}
};
const qrCodeUrl = booking
? generateQRCode(
booking.booking_number,
booking.total_price
)
: null;
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('/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 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"
>
Booking Successful!
</h1>
<p className="text-gray-600 mb-4">
Thank you for booking with our hotel
</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"
>
Booking Number:
</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="Copy booking number"
>
{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"
>
Booking Details
</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"
/>
Room {room.room_number} -
Floor {room.floor}
</p>
)}
<p className="text-indigo-600
font-semibold mt-1"
>
{formatPrice(roomType.base_price)}/night
</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" />
Check-in Date
</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" />
Check-out Date
</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" />
Number of Guests
</p>
<p className="font-medium text-gray-900">
{booking.guest_count} guest(s)
</p>
</div>
{/* Notes */}
{booking.notes && (
<div>
<p className="text-sm text-gray-600 mb-1">
<FileText className="w-4 h-4 inline mr-1" />
Notes
</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" />
Payment Method
</p>
<p className="font-medium text-gray-900">
{booking.payment_method === 'cash'
? '💵 Pay at hotel'
: '🏦 Bank transfer'}
</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"
>
Total Payment
</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"
>
Customer Information
</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">
<User className="w-4 h-4 inline mr-1" />
Full Name
</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" />
Phone Number
</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">
Bank Transfer Instructions
</h3>
<div className="space-y-2 text-sm
text-blue-800"
>
<p>
Please transfer according to the following information:
</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>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 */}
{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"
>
Scan QR code to transfer
</p>
<img
src={qrCodeUrl}
alt="QR Code"
className="w-48 h-48 border-2
border-gray-200 rounded-lg"
/>
<p className="text-xs text-gray-500
mt-2 text-center"
>
QR code includes all information
</p>
</div>
)}
</div>
<p className="text-xs italic mt-2">
💡 Note: Please enter the correct booking number
in the transfer content so we can confirm your payment.
</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"
>
📎 Payment Confirmation
</h4>
<p className="text-sm text-blue-700 mb-3">
After transferring, please upload
the receipt image so we can confirm faster.
</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 to select another image
</p>
</>
) : (
<>
<FileText
className="w-8 h-8
text-blue-400"
/>
<p className="text-sm
text-blue-600 font-medium"
>
Select receipt image
</p>
<p className="text-xs
text-gray-500"
>
PNG, JPG, JPEG (Max 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"
/>
Sending...
</>
) : (
<>
<CheckCircle
className="w-5 h-5"
/>
Confirm payment completed
</>
)}
</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" />
View My Bookings
</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" />
Go to Home
</Link>
</div>
</div>
</div>
);
};
export default BookingSuccessPage;

View File

@@ -0,0 +1,250 @@
import React from 'react';
import {
TrendingUp,
Hotel,
DollarSign,
Calendar,
Activity
} from 'lucide-react';
const DashboardPage: React.FC = () => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
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">
Overview of your activity
</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"
>
Total Bookings
</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"
>
Total Spending
</h3>
<p className="text-3xl font-bold text-gray-800">
{formatCurrency(12450)}
</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"
>
Currently Staying
</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"
>
Reward Points
</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"
>
Recent Activity
</h2>
<div className="space-y-4">
{[
{
action: 'Booking',
room: 'Room 201',
time: '2 hours ago'
},
{
action: 'Check-in',
room: 'Room 105',
time: '1 day ago'
},
{
action: 'Check-out',
room: 'Room 302',
time: '3 days ago'
},
].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"
>
Upcoming Bookings
</h2>
<div className="space-y-4">
{[
{
room: 'Room 401',
date: '20/11/2025',
status: 'Confirmed'
},
{
room: 'Room 203',
date: '25/11/2025',
status: 'Pending confirmation'
},
].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 === 'Confirmed'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{booking.status}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,546 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
AlertCircle,
CreditCard,
Building2,
Copy,
Check,
Loader2,
ArrowLeft,
Download,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, type Booking } from
'../../services/api/bookingService';
import {
getPaymentsByBookingId,
getBankTransferInfo,
notifyPaymentCompletion,
type Payment,
type BankInfo,
} from '../../services/api/paymentService';
import Loading from '../../components/common/Loading';
const DepositPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate();
const [booking, setBooking] = useState<Booking | null>(null);
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
const [bankInfo, setBankInfo] = useState<BankInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notifying, setNotifying] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<
'bank_transfer' | null
>('bank_transfer');
useEffect(() => {
if (bookingId) {
fetchData(Number(bookingId));
}
}, [bookingId]);
const fetchData = async (id: number) => {
try {
setLoading(true);
setError(null);
// Fetch booking details
const bookingResponse = await getBookingById(id);
if (!bookingResponse.success || !bookingResponse.data?.booking) {
throw new Error('Booking not found');
}
const bookingData = bookingResponse.data.booking;
setBooking(bookingData);
// Check if booking requires deposit
if (!bookingData.requires_deposit) {
toast.info('This booking does not require a deposit');
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 || 'Unable to load payment information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).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 || 'Payment information not found'}
</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" />
Back to booking list
</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>Back to booking details</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">
Deposit payment successful!
</h1>
<p className="text-green-700">
Your booking has been confirmed.
Remaining amount to be paid at check-in.
</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">
Deposit Payment
</h1>
<p className="text-orange-700">
Please pay <strong>20% deposit</strong> to
confirm your booking
</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">
Payment Information
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Total Room Price</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">
Deposit Amount to Pay (20%)
</span>
<span className="text-xl font-bold">
{formatPrice(depositAmount)}
</span>
</div>
<div className="flex justify-between text-sm text-gray-500">
<span>Remaining amount to be paid at check-in</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">
Deposit paid on:{' '}
{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">
Transaction ID: {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">
Select Payment Method
</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'
}`}
>
Bank Transfer
</div>
<div className="text-xs text-gray-500 mt-1">
Bank transfer
</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" />
Bank Transfer Information
</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">Bank</div>
<div className="font-medium">{bankInfo.bank_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.bank_name, 'bank name')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'bank name' ? (
<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">Account Number</div>
<div className="font-medium font-mono">
{bankInfo.account_number}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_number, 'account number')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'account number' ? (
<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">Account Holder</div>
<div className="font-medium">{bankInfo.account_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_name, 'account holder')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'account holder' ? (
<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">Amount</div>
<div className="text-lg font-bold text-orange-600">
{formatPrice(bankInfo.amount)}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.amount.toString(), 'amount')
}
className="p-2 hover:bg-orange-100 rounded transition-colors"
>
{copiedText === 'amount' ? (
<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">Transfer Content</div>
<div className="font-medium font-mono text-red-600">
{bankInfo.content}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.content, 'content')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'content' ? (
<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> Note:</strong> Please enter the correct transfer content so
the system can automatically confirm the payment.
</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" />
Sending...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
I have transferred
</>
)}
</button>
<p className="text-xs text-center text-gray-500 mt-2">
After transferring, click the button above to notify us
</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">
Scan QR Code to Pay
</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">
Scan QR code with your bank app
</p>
<p className="text-xs text-gray-500">
Transfer information has been automatically filled
</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" />
Download QR Code
</a>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default DepositPaymentPage;

View File

@@ -0,0 +1,201 @@
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"
>
Please Login
</h3>
<p className="text-gray-600 mb-4">
You need to login to view your favorites list
</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"
>
Login
</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>Back to home</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"
>
Favorites List
</h1>
<p className="text-gray-600 mt-1">
{favorites.length > 0
? `${favorites.length} room${favorites.length !== 1 ? 's' : ''}`
: 'No favorite rooms yet'}
</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"
>
Try again
</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"
>
No favorite rooms yet
</h3>
<p
className="text-gray-600 mb-6
max-w-md mx-auto"
>
You haven't added any rooms to your favorites list yet. Explore and save the rooms you like!
</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"
>
Explore rooms
</Link>
</div>
)}
{/* Favorites Grid */}
{!isLoading &&
!error &&
favorites.length > 0 && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{favorites.map((favorite) =>
favorite.room ? (
<RoomCard
key={favorite.id}
room={favorite.room}
/>
) : null
)}
</div>
)}
</div>
</div>
);
};
export default FavoritesPage;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
Users,
MapPin,
DollarSign,
ArrowLeft,
} from 'lucide-react';
import { getRoomById, type Room } from
'../../services/api/roomService';
import RoomGallery from '../../components/rooms/RoomGallery';
import RoomAmenities from '../../components/rooms/RoomAmenities';
import ReviewSection from '../../components/rooms/ReviewSection';
import RatingStars from '../../components/rooms/RatingStars';
const RoomDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
fetchRoomDetail(Number(id));
}
}, [id]);
const fetchRoomDetail = async (roomId: number) => {
try {
setLoading(true);
setError(null);
const response = await getRoomById(roomId);
// backend uses `status: 'success'` (not `success`), accept both
if ((response as any).success || (response as any).status === 'success') {
if (response.data && response.data.room) {
setRoom(response.data.room);
} else {
throw new Error('Failed to fetch room details');
}
} else {
throw new Error('Failed to fetch room details');
}
} catch (err: any) {
console.error('Error fetching room:', err);
const message =
err.response?.data?.message ||
'Unable to load room information';
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('de-DE', {
style: 'currency',
currency: 'EUR',
}).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>Back to room list</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>
Room {room.room_number} - Floor {room.floor}
</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-5 h-5" />
<span>
{roomType?.capacity || 0} guests
</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'
? 'Available'
: room.status === 'occupied'
? 'Booked'
: 'Maintenance'}
</div>
</div>
{/* Description */}
{roomType?.description && (
<div>
<h2 className="text-2xl font-bold
text-gray-900 mb-4"
>
Room Description
</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"
>
Amenities
</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">/ night</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' ? 'Book Now' : 'Not Available'}
</Link>
</div>
{room.status === 'available' && (
<p className="text-sm text-gray-500 text-center mt-3">
No immediate charge pay at the hotel
</p>
)}
<hr className="my-4" />
<div className="text-sm text-gray-700 space-y-2">
<div className="flex items-center justify-between">
<span>Room Type</span>
<strong>{roomType?.name}</strong>
</div>
<div className="flex items-center justify-between">
<span>Guests</span>
<span>{roomType?.capacity} guests</span>
</div>
<div className="flex items-center justify-between">
<span>Rooms</span>
<span>1</span>
</div>
</div>
</div>
</aside>
</div>
{/* Reviews Section */}
<div className="mb-12">
<ReviewSection roomId={room.id} />
</div>
</div>
</div>
);
};
export default RoomDetailPage;

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { getRooms } from '../../services/api/roomService';
import type { Room } from '../../services/api/roomService';
import RoomFilter from '../../components/rooms/RoomFilter';
import RoomCard from '../../components/rooms/RoomCard';
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
import Pagination from '../../components/rooms/Pagination';
import { ArrowLeft } from 'lucide-react';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 10,
totalPages: 1,
});
// Fetch rooms based on URL params
useEffect(() => {
const fetchRooms = async () => {
setLoading(true);
setError(null);
try {
const params = {
type: searchParams.get('type') || undefined,
minPrice: searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined,
maxPrice: searchParams.get('maxPrice')
? Number(searchParams.get('maxPrice'))
: undefined,
capacity: searchParams.get('capacity')
? Number(searchParams.get('capacity'))
: undefined,
page: searchParams.get('page')
? Number(searchParams.get('page'))
: 1,
limit: 12,
};
const response = await getRooms(params);
if (response.status === 'success' && response.data) {
setRooms(response.data.rooms || []);
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
throw new Error('Failed to fetch rooms');
}
} catch (err) {
console.error('Error fetching rooms:', err);
setError('Unable to load room list. Please try again.');
} 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>Back to home</span>
</Link>
<div className="mb-10">
<h1 className="text-3xl text-center font-bold text-gray-900">
Room List
</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"
>
Try Again
</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"
>
No matching rooms found
</h3>
<p className="text-gray-600 mb-6">
Please try adjusting the filters or search differently
</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"
>
Clear Filters
</button>
</div>
)}
{!loading && !error && rooms.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-2 gap-6"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</>
)}
</main>
</div>
</div>
</div>
);
};
export default RoomListPage;

View File

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

View File

@@ -0,0 +1,86 @@
import axios from 'axios';
// Base URL from environment or default. 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:8000/api/bookings/me'.
const rawBase = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// 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';
// Create axios instance
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
withCredentials: true, // Enable sending cookies
});
// Request interceptor - Add token to header
apiClient.interceptors.request.use(
(config) => {
// Normalize request URL: if a request path accidentally begins
// with '/api', strip that prefix so it will be appended to
// our baseURL exactly once. This prevents double '/api/api'
// when code uses absolute '/api/...' paths.
if (config.url && typeof config.url === 'string') {
if (config.url.startsWith('/api/')) {
config.url = config.url.replace(/^\/api/, '');
}
// Also avoid accidental double slashes after concatenation
config.url = config.url.replace(/\/\/+/, '/');
}
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - Handle common errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// Handle network errors
if (!error.response) {
console.error('Network error:', error);
// You can show a toast notification here
return Promise.reject({
...error,
message: 'Network error. Please check ' +
'your internet connection.',
});
}
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
window.location.href = '/login';
}
// Handle other HTTP errors
if (error.response?.status >= 500) {
console.error('Server error:', error);
return Promise.reject({
...error,
message: 'Server error. Please try again later.',
});
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,137 @@
import apiClient from './apiClient';
// Types
export interface LoginCredentials {
email: string;
password: string;
rememberMe?: boolean;
}
export interface RegisterData {
name: string;
email: string;
password: string;
phone?: string;
}
export interface AuthResponse {
// Server may use `status: 'success'` or boolean `success`
status?: string;
success?: boolean;
message?: string;
data?: {
token?: string;
refreshToken?: string;
user?: {
id: number;
name: string;
email: string;
phone?: string;
avatar?: string;
role: string;
createdAt?: string;
};
};
}
export interface ForgotPasswordData {
email: string;
}
export interface ResetPasswordData {
token: string;
password: string;
confirmPassword: string;
}
/**
* Auth Service - Handles API calls related
* to authentication
*/
const authService = {
/**
* Login
*/
login: async (
credentials: LoginCredentials
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>(
'/api/auth/login',
credentials
);
return response.data;
},
/**
* Register new account
*/
register: async (
data: RegisterData
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>(
'/api/auth/register',
data
);
return response.data;
},
/**
* Logout
*/
logout: async (): Promise<void> => {
try {
await apiClient.post('/api/auth/logout');
} catch (error) {
console.error('Logout error:', error);
}
},
/**
* Get current user information
*/
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;
},
/**
* Forgot password - Send reset email
*/
forgotPassword: async (
data: ForgotPasswordData
): Promise<{ status?: string; success?: boolean; message?: string }> => {
const response = await apiClient.post(
'/api/auth/forgot-password',
data
);
return response.data;
},
/**
* Reset password
*/
resetPassword: async (
data: ResetPasswordData
): Promise<{ status?: string; success?: boolean; message?: string }> => {
const response = await apiClient.post(
'/api/auth/reset-password',
data
);
return response.data;
},
};
export default authService;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
import * as yup from 'yup';
export const bookingValidationSchema = yup.object().shape({
checkInDate: yup
.date()
.required('Please select check-in date')
.min(
new Date(new Date().setHours(0, 0, 0, 0)),
'Check-in date cannot be in the past'
)
.typeError('Invalid check-in date'),
checkOutDate: yup
.date()
.required('Please select check-out date')
.min(
yup.ref('checkInDate'),
'Check-out date must be after check-in date'
)
.typeError('Invalid check-out date'),
guestCount: yup
.number()
.required('Please enter number of guests')
.min(1, 'Minimum number of guests is 1')
.max(10, 'Maximum number of guests is 10')
.integer('Number of guests must be an integer')
.typeError('Number of guests must be a number'),
notes: yup
.string()
.max(500, 'Notes cannot exceed 500 characters')
.optional(),
paymentMethod: yup
.mixed<'cash' | 'bank_transfer'>()
.required('Please select payment method')
.oneOf(
['cash', 'bank_transfer'],
'Invalid payment method'
),
fullName: yup
.string()
.required('Please enter full name')
.min(2, 'Full name must be at least 2 characters')
.max(100, 'Full name cannot exceed 100 characters'),
email: yup
.string()
.required('Please enter email')
.email('Invalid email'),
phone: yup
.string()
.required('Please enter phone number')
.matches(
/^[0-9]{10,11}$/,
'Phone number must have 10-11 digits'
),
});
export type BookingFormData = yup.InferType<
typeof bookingValidationSchema
>;

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

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

View File

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

31
Frontend/tsconfig.json Normal file
View File

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

View File

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

17
Frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
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,
// Direct requests to FastAPI backend - no proxy needed
},
})