update
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
<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>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||
<title>Luxury Hotel - Excellence Redefined</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, lazy, Suspense } from 'react';
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { GlobalLoadingProvider } from './contexts/GlobalLoadingContext';
|
||||
import { CookieConsentProvider } from './contexts/CookieConsentContext';
|
||||
import OfflineIndicator from './components/common/OfflineIndicator';
|
||||
import CookieConsentBanner from './components/common/CookieConsentBanner';
|
||||
import AnalyticsLoader from './components/common/AnalyticsLoader';
|
||||
import Loading from './components/common/Loading';
|
||||
|
||||
// Store
|
||||
import useAuthStore from './store/useAuthStore';
|
||||
@@ -22,52 +28,42 @@ import {
|
||||
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';
|
||||
// Lazy load pages for code splitting
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const DashboardPage = lazy(() => import('./pages/customer/DashboardPage'));
|
||||
const RoomListPage = lazy(() => import('./pages/customer/RoomListPage'));
|
||||
const RoomDetailPage = lazy(() => import('./pages/customer/RoomDetailPage'));
|
||||
const SearchResultsPage = lazy(() => import('./pages/customer/SearchResultsPage'));
|
||||
const FavoritesPage = lazy(() => import('./pages/customer/FavoritesPage'));
|
||||
const MyBookingsPage = lazy(() => import('./pages/customer/MyBookingsPage'));
|
||||
const BookingPage = lazy(() => import('./pages/customer/BookingPage'));
|
||||
const BookingSuccessPage = lazy(() => import('./pages/customer/BookingSuccessPage'));
|
||||
const BookingDetailPage = lazy(() => import('./pages/customer/BookingDetailPage'));
|
||||
const DepositPaymentPage = lazy(() => import('./pages/customer/DepositPaymentPage'));
|
||||
const PaymentConfirmationPage = lazy(() => import('./pages/customer/PaymentConfirmationPage'));
|
||||
const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'));
|
||||
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
|
||||
const AboutPage = lazy(() => import('./pages/AboutPage'));
|
||||
const LoginPage = lazy(() => import('./pages/auth/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('./pages/auth/RegisterPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage'));
|
||||
|
||||
// Admin Pages
|
||||
import {
|
||||
DashboardPage as AdminDashboardPage,
|
||||
RoomManagementPage,
|
||||
UserManagementPage,
|
||||
BookingManagementPage,
|
||||
PaymentManagementPage,
|
||||
ServiceManagementPage,
|
||||
ReviewManagementPage,
|
||||
PromotionManagementPage,
|
||||
CheckInPage,
|
||||
CheckOutPage,
|
||||
} from './pages/admin';
|
||||
// Lazy load admin pages
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
|
||||
const RoomManagementPage = lazy(() => import('./pages/admin/RoomManagementPage'));
|
||||
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
|
||||
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
|
||||
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
|
||||
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage'));
|
||||
const ReviewManagementPage = lazy(() => import('./pages/admin/ReviewManagementPage'));
|
||||
const PromotionManagementPage = lazy(() => import('./pages/admin/PromotionManagementPage'));
|
||||
const BannerManagementPage = lazy(() => import('./pages/admin/BannerManagementPage'));
|
||||
const ReportsPage = lazy(() => import('./pages/admin/ReportsPage'));
|
||||
const CookieSettingsPage = lazy(() => import('./pages/admin/CookieSettingsPage'));
|
||||
const AuditLogsPage = lazy(() => import('./pages/admin/AuditLogsPage'));
|
||||
const CheckInPage = lazy(() => import('./pages/admin/CheckInPage'));
|
||||
const CheckOutPage = lazy(() => import('./pages/admin/CheckOutPage'));
|
||||
|
||||
// Demo component for pages not yet created
|
||||
const DemoPage: React.FC<{ title: string }> = ({ title }) => (
|
||||
@@ -125,8 +121,16 @@ function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<GlobalLoadingProvider>
|
||||
<CookieConsentProvider>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<Loading fullScreen text="Loading page..." />}>
|
||||
<Routes>
|
||||
{/* Public Routes with Main Layout */}
|
||||
<Route
|
||||
path="/"
|
||||
@@ -161,7 +165,7 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
path="about"
|
||||
element={<DemoPage title="About" />}
|
||||
element={<AboutPage />}
|
||||
/>
|
||||
|
||||
{/* Protected Routes - Requires login */}
|
||||
@@ -225,7 +229,7 @@ function App() {
|
||||
path="profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DemoPage title="Profile" />
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -301,15 +305,19 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
path="banners"
|
||||
element={<DemoPage title="Banner Management" />}
|
||||
element={<BannerManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
<Route
|
||||
path="reports"
|
||||
element={<DemoPage title="Reports" />}
|
||||
element={<ReportsPage />}
|
||||
/>
|
||||
<Route
|
||||
<Route
|
||||
path="audit-logs"
|
||||
element={<AuditLogsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={<DemoPage title="Settings" />}
|
||||
element={<CookieSettingsPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@@ -320,18 +328,27 @@ function App() {
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
/>
|
||||
</BrowserRouter>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
toastClassName="rounded-lg shadow-lg"
|
||||
bodyClassName="text-sm font-medium"
|
||||
/>
|
||||
<OfflineIndicator />
|
||||
<CookieConsentBanner />
|
||||
<AnalyticsLoader />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</CookieConsentProvider>
|
||||
</GlobalLoadingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
117
Frontend/src/components/common/AnalyticsLoader.tsx
Normal file
117
Frontend/src/components/common/AnalyticsLoader.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import privacyService, {
|
||||
PublicPrivacyConfig,
|
||||
} from '../../services/api/privacyService';
|
||||
import { useCookieConsent } from '../../contexts/CookieConsentContext';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer: any[];
|
||||
gtag: (...args: any[]) => void;
|
||||
fbq: (...args: any[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const AnalyticsLoader: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const { consent } = useCookieConsent();
|
||||
const [config, setConfig] = useState<PublicPrivacyConfig | null>(null);
|
||||
const gaLoadedRef = useRef(false);
|
||||
const fbLoadedRef = useRef(false);
|
||||
|
||||
// Load public privacy config once
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const cfg = await privacyService.getPublicConfig();
|
||||
if (!mounted) return;
|
||||
setConfig(cfg);
|
||||
} catch {
|
||||
// Fail silently in production; analytics are non-critical
|
||||
}
|
||||
};
|
||||
void loadConfig();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load Google Analytics when allowed
|
||||
useEffect(() => {
|
||||
if (!config || !consent) return;
|
||||
const measurementId = config.integrations.ga_measurement_id;
|
||||
const analyticsAllowed =
|
||||
config.policy.analytics_enabled && consent.categories.analytics;
|
||||
if (!measurementId || !analyticsAllowed || gaLoadedRef.current) return;
|
||||
|
||||
// Inject GA script
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(
|
||||
measurementId
|
||||
)}`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(...args: any[]) {
|
||||
window.dataLayer.push(args);
|
||||
}
|
||||
window.gtag = gtag;
|
||||
gtag('js', new Date());
|
||||
gtag('config', measurementId, { anonymize_ip: true });
|
||||
|
||||
gaLoadedRef.current = true;
|
||||
|
||||
return () => {
|
||||
// We don't remove GA script on unmount; typical SPA behaviour is to keep it.
|
||||
};
|
||||
}, [config, consent]);
|
||||
|
||||
// Track GA page views on route change
|
||||
useEffect(() => {
|
||||
if (!gaLoadedRef.current || !config?.integrations.ga_measurement_id) return;
|
||||
if (typeof window.gtag !== 'function') return;
|
||||
window.gtag('config', config.integrations.ga_measurement_id, {
|
||||
page_path: location.pathname + location.search,
|
||||
});
|
||||
}, [location, config]);
|
||||
|
||||
// Load Meta Pixel when allowed
|
||||
useEffect(() => {
|
||||
if (!config || !consent) return;
|
||||
const pixelId = config.integrations.fb_pixel_id;
|
||||
const marketingAllowed =
|
||||
config.policy.marketing_enabled && consent.categories.marketing;
|
||||
if (!pixelId || !marketingAllowed || fbLoadedRef.current) return;
|
||||
|
||||
// Meta Pixel base code
|
||||
!(function (f: any, b, e, v, n?, t?, s?) {
|
||||
if (f.fbq) return;
|
||||
n = f.fbq = function () {
|
||||
(n.callMethod ? n.callMethod : n.queue.push).apply(n, arguments);
|
||||
};
|
||||
if (!f._fbq) f._fbq = n;
|
||||
(n as any).push = n;
|
||||
(n as any).loaded = true;
|
||||
(n as any).version = '2.0';
|
||||
(n as any).queue = [];
|
||||
t = b.createElement(e);
|
||||
t.async = true;
|
||||
t.src = 'https://connect.facebook.net/en_US/fbevents.js';
|
||||
s = b.getElementsByTagName(e)[0];
|
||||
s.parentNode?.insertBefore(t, s);
|
||||
})(window, document, 'script');
|
||||
|
||||
window.fbq('init', pixelId);
|
||||
window.fbq('track', 'PageView');
|
||||
fbLoadedRef.current = true;
|
||||
}, [config, consent]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AnalyticsLoader;
|
||||
|
||||
|
||||
164
Frontend/src/components/common/ConfirmationDialog.tsx
Normal file
164
Frontend/src/components/common/ConfirmationDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'info',
|
||||
isLoading = false,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const variantStyles = {
|
||||
danger: {
|
||||
icon: 'text-red-600',
|
||||
button: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
|
||||
},
|
||||
warning: {
|
||||
icon: 'text-yellow-600',
|
||||
button: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
|
||||
},
|
||||
info: {
|
||||
icon: 'text-blue-600',
|
||||
button: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
|
||||
},
|
||||
};
|
||||
|
||||
const styles = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-sm bg-gradient-to-b from-white to-gray-50 text-left shadow-2xl border border-[#d4af37]/20 transition-all sm:my-8 sm:w-full sm:max-w-lg animate-fade-in">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-[#d4af37] focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 rounded-sm p-1 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="bg-transparent px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div
|
||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
variant === 'danger'
|
||||
? 'bg-red-100 border-2 border-red-200'
|
||||
: variant === 'warning'
|
||||
? 'bg-[#d4af37]/20 border-2 border-[#d4af37]/40'
|
||||
: 'bg-[#d4af37]/20 border-2 border-[#d4af37]/40'
|
||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
||||
>
|
||||
<AlertTriangle
|
||||
className={`h-6 w-6 ${
|
||||
variant === 'danger'
|
||||
? 'text-red-600'
|
||||
: variant === 'warning'
|
||||
? 'text-[#d4af37]'
|
||||
: 'text-[#d4af37]'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3
|
||||
className="text-lg font-serif font-semibold leading-6 text-gray-900 tracking-tight"
|
||||
id="modal-title"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-600 font-light leading-relaxed">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50/50 backdrop-blur-sm px-4 py-4 sm:flex sm:flex-row-reverse sm:px-6 gap-3 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex w-full justify-center rounded-sm px-4 py-2.5 text-sm font-medium tracking-wide text-white shadow-lg sm:ml-3 sm:w-auto focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all ${
|
||||
variant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: variant === 'warning'
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] hover:from-[#f5d76e] hover:to-[#d4af37] focus:ring-[#d4af37]'
|
||||
: 'btn-luxury-primary focus:ring-[#d4af37]'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
confirmText
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-sm bg-white/80 backdrop-blur-sm px-4 py-2.5 text-sm font-medium tracking-wide text-gray-700 shadow-sm border border-gray-300 hover:bg-white hover:border-[#d4af37]/30 hover:text-[#d4af37] sm:mt-0 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
|
||||
200
Frontend/src/components/common/CookieConsentBanner.tsx
Normal file
200
Frontend/src/components/common/CookieConsentBanner.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useCookieConsent } from '../../contexts/CookieConsentContext';
|
||||
|
||||
const CookieConsentBanner: React.FC = () => {
|
||||
const { consent, isLoading, hasDecided, updateConsent } = useCookieConsent();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [analyticsChecked, setAnalyticsChecked] = useState(false);
|
||||
const [marketingChecked, setMarketingChecked] = useState(false);
|
||||
const [preferencesChecked, setPreferencesChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (consent) {
|
||||
setAnalyticsChecked(consent.categories.analytics);
|
||||
setMarketingChecked(consent.categories.marketing);
|
||||
setPreferencesChecked(consent.categories.preferences);
|
||||
}
|
||||
}, [consent]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenPreferences = () => {
|
||||
setShowDetails(true);
|
||||
};
|
||||
|
||||
window.addEventListener('open-cookie-preferences', handleOpenPreferences);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'open-cookie-preferences',
|
||||
handleOpenPreferences
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading || hasDecided) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAcceptAll = async () => {
|
||||
await updateConsent({
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
preferences: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRejectNonEssential = async () => {
|
||||
await updateConsent({
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
preferences: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveSelection = async () => {
|
||||
await updateConsent({
|
||||
analytics: analyticsChecked,
|
||||
marketing: marketingChecked,
|
||||
preferences: preferencesChecked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<div className="pointer-events-auto relative w-full max-w-4xl overflow-hidden rounded-2xl bg-gradient-to-r from-black/85 via-zinc-900/90 to-black/85 p-[1px] shadow-[0_24px_60px_rgba(0,0,0,0.8)]">
|
||||
{/* Gold inner border */}
|
||||
<div className="absolute inset-0 rounded-2xl border border-[#d4af37]/40" />
|
||||
|
||||
{/* Subtle glow */}
|
||||
<div className="pointer-events-none absolute -inset-8 bg-[radial-gradient(circle_at_top,_rgba(212,175,55,0.18),_transparent_55%),radial-gradient(circle_at_bottom,_rgba(0,0,0,0.8),_transparent_60%)] opacity-80" />
|
||||
|
||||
<div className="relative flex flex-col gap-4 bg-gradient-to-br from-zinc-950/80 via-zinc-900/90 to-black/90 px-4 py-4 sm:px-6 sm:py-5 lg:px-8 lg:py-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
{/* Left: copy + details */}
|
||||
<div className="space-y-3 sm:max-w-xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-black/60 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[#d4af37]/90 ring-1 ring-[#d4af37]/30">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#d4af37]" />
|
||||
Privacy Suite
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-lg font-semibold tracking-wide text-white sm:text-xl">
|
||||
A tailored privacy experience
|
||||
</h2>
|
||||
<p className="text-xs leading-relaxed text-zinc-300 sm:text-sm">
|
||||
We use cookies to ensure a seamless booking journey, enhance performance,
|
||||
and offer curated experiences. Choose a level of personalization that
|
||||
matches your comfort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
|
||||
onClick={() => setShowDetails((prev) => !prev)}
|
||||
>
|
||||
{showDetails ? 'Hide detailed preferences' : 'Fine-tune preferences'}
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className="mt-1.5 space-y-3 rounded-xl bg-black/40 p-3 ring-1 ring-zinc-800/80 backdrop-blur-md sm:p-4">
|
||||
<div className="flex flex-col gap-2 text-xs text-zinc-200 sm:text-[13px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 h-4 w-4 rounded border border-[#d4af37]/50 bg-[#d4af37]/20" />
|
||||
<div>
|
||||
<p className="font-semibold text-zinc-50">Strictly necessary</p>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
Essential for security, authentication, and core booking flows.
|
||||
These are always enabled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="cookie-analytics"
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37]"
|
||||
checked={analyticsChecked}
|
||||
onChange={(e) => setAnalyticsChecked(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="cookie-analytics" className="cursor-pointer">
|
||||
<p className="font-semibold text-zinc-50">Analytics</p>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
Anonymous insights that help us refine performance and guest
|
||||
experience throughout the site.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="cookie-marketing"
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37]"
|
||||
checked={marketingChecked}
|
||||
onChange={(e) => setMarketingChecked(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="cookie-marketing" className="cursor-pointer">
|
||||
<p className="font-semibold text-zinc-50">Tailored offers</p>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
Allow us to present bespoke promotions and experiences aligned
|
||||
with your interests.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="cookie-preferences"
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37]"
|
||||
checked={preferencesChecked}
|
||||
onChange={(e) => setPreferencesChecked(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="cookie-preferences" className="cursor-pointer">
|
||||
<p className="font-semibold text-zinc-50">Comfort preferences</p>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
Remember your choices such as language, currency, and layout for
|
||||
a smoother return visit.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="mt-2 flex flex-col gap-2 sm:mt-0 sm:w-56">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center rounded-full bg-gradient-to-r from-[#d4af37] via-[#f2cf74] to-[#d4af37] px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-black shadow-[0_10px_30px_rgba(0,0,0,0.6)] transition hover:from-[#f8e4a6] hover:via-[#ffe6a3] hover:to-[#f2cf74] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d4af37]/70"
|
||||
onClick={handleAcceptAll}
|
||||
>
|
||||
Accept all & continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center rounded-full border border-zinc-600/80 bg-black/40 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-zinc-100 shadow-[0_10px_25px_rgba(0,0,0,0.65)] transition hover:border-zinc-400 hover:bg-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
|
||||
onClick={handleRejectNonEssential}
|
||||
>
|
||||
Essential only
|
||||
</button>
|
||||
{showDetails && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-zinc-100 shadow-[0_8px_22px_rgba(0,0,0,0.6)] transition hover:border-[#d4af37]/60 hover:text-[#f5e9c6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d4af37]/70"
|
||||
onClick={handleSaveSelection}
|
||||
>
|
||||
Save my selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieConsentBanner;
|
||||
|
||||
|
||||
29
Frontend/src/components/common/CookiePreferencesLink.tsx
Normal file
29
Frontend/src/components/common/CookiePreferencesLink.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { useCookieConsent } from '../../contexts/CookieConsentContext';
|
||||
|
||||
const CookiePreferencesLink: React.FC = () => {
|
||||
const { hasDecided } = useCookieConsent();
|
||||
|
||||
if (!hasDecided) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// Dispatch a custom event listened by the banner to reopen details.
|
||||
window.dispatchEvent(new CustomEvent('open-cookie-preferences'));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="text-xs font-medium text-gray-500 underline hover:text-gray-700"
|
||||
>
|
||||
Cookie preferences
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookiePreferencesLink;
|
||||
|
||||
|
||||
30
Frontend/src/components/common/GlobalLoading.tsx
Normal file
30
Frontend/src/components/common/GlobalLoading.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface GlobalLoadingProps {
|
||||
isLoading: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const GlobalLoading: React.FC<GlobalLoadingProps> = ({
|
||||
isLoading,
|
||||
text = 'Loading...',
|
||||
}) => {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-white bg-opacity-75 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
||||
<p className="text-sm font-medium text-gray-700">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalLoading;
|
||||
|
||||
25
Frontend/src/components/common/OfflineIndicator.tsx
Normal file
25
Frontend/src/components/common/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { WifiOff } from 'lucide-react';
|
||||
import { useOffline } from '../../hooks/useOffline';
|
||||
|
||||
const OfflineIndicator: React.FC = () => {
|
||||
const isOffline = useOffline();
|
||||
|
||||
if (!isOffline) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-50 bg-yellow-500 text-white px-4 py-3 shadow-lg flex items-center justify-center gap-2 animate-slide-up"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<WifiOff className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">
|
||||
You're currently offline. Some features may be unavailable.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OfflineIndicator;
|
||||
|
||||
47
Frontend/src/components/common/Skeleton.tsx
Normal file
47
Frontend/src/components/common/Skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
variant?: 'text' | 'circular' | 'rectangular';
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
const Skeleton: React.FC<SkeletonProps> = ({
|
||||
width,
|
||||
height,
|
||||
className = '',
|
||||
variant = 'rectangular',
|
||||
animation = 'pulse',
|
||||
}) => {
|
||||
const baseClasses = 'bg-gray-200';
|
||||
|
||||
const variantClasses = {
|
||||
text: 'h-4 rounded',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded',
|
||||
};
|
||||
|
||||
const animationClasses = {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'animate-shimmer',
|
||||
none: '',
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
|
||||
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${animationClasses[animation]} ${className}`}
|
||||
style={style}
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Skeleton;
|
||||
|
||||
12
Frontend/src/components/common/index.ts
Normal file
12
Frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as Loading } from './Loading';
|
||||
export { default as OptimizedImage } from './OptimizedImage';
|
||||
export { default as Pagination } from './Pagination';
|
||||
export { default as PaymentMethodSelector } from './PaymentMethodSelector';
|
||||
export { default as PaymentStatusBadge } from './PaymentStatusBadge';
|
||||
export { default as ConfirmationDialog } from './ConfirmationDialog';
|
||||
export { default as GlobalLoading } from './GlobalLoading';
|
||||
export { default as OfflineIndicator } from './OfflineIndicator';
|
||||
export { default as Skeleton } from './Skeleton';
|
||||
|
||||
@@ -7,189 +7,277 @@ import {
|
||||
Instagram,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin
|
||||
MapPin,
|
||||
Linkedin,
|
||||
Youtube,
|
||||
Award,
|
||||
Shield,
|
||||
Star
|
||||
} from 'lucide-react';
|
||||
import CookiePreferencesLink from '../common/CookiePreferencesLink';
|
||||
|
||||
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>
|
||||
<footer className="relative bg-gradient-to-b from-[#1a1a1a] via-[#0f0f0f] to-black text-gray-300 overflow-hidden">
|
||||
{/* Elegant top border with gradient */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
|
||||
{/* Subtle background pattern */}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="relative container mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||
{/* Main Footer Content */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 lg:gap-16 mb-16">
|
||||
{/* Company Info - Enhanced */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<div className="relative">
|
||||
<Hotel className="w-10 h-10 text-[#d4af37]" />
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl"></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-2xl font-serif font-semibold text-white tracking-wide">
|
||||
Luxury Hotel
|
||||
</span>
|
||||
<p className="text-xs text-[#d4af37]/80 font-light tracking-widest uppercase mt-0.5">
|
||||
Excellence Redefined
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Leading online hotel management and
|
||||
booking system.
|
||||
<p className="text-sm text-gray-400 mb-6 leading-relaxed max-w-md">
|
||||
Experience unparalleled luxury and world-class hospitality.
|
||||
Your journey to exceptional comfort begins here.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
|
||||
{/* Premium Certifications */}
|
||||
<div className="flex items-center space-x-6 mb-8">
|
||||
<div className="flex items-center space-x-2 text-[#d4af37]/90">
|
||||
<Award className="w-5 h-5" />
|
||||
<span className="text-xs font-medium tracking-wide">5-Star Rated</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-[#d4af37]/90">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="text-xs font-medium tracking-wide">Award Winning</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Media - Premium Style */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-blue-500
|
||||
transition-colors"
|
||||
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
|
||||
aria-label="Facebook"
|
||||
>
|
||||
<Facebook className="w-5 h-5" />
|
||||
<Facebook className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
|
||||
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-blue-500
|
||||
transition-colors"
|
||||
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<Twitter className="w-5 h-5" />
|
||||
<Twitter className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
|
||||
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-blue-500
|
||||
transition-colors"
|
||||
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<Instagram className="w-5 h-5" />
|
||||
<Instagram className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
|
||||
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<Linkedin className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
|
||||
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
|
||||
aria-label="YouTube"
|
||||
>
|
||||
<Youtube className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
|
||||
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
{/* Quick Links - Enhanced */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">
|
||||
Quick Links
|
||||
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
|
||||
<span className="relative z-10">Quick Links</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link
|
||||
to="/"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
Home
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Home</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
Rooms
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Rooms & Suites</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
Bookings
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">My Bookings</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/about"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
About
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">About Us</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
{/* Support - Enhanced */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">
|
||||
Support
|
||||
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
|
||||
<span className="relative z-10">Guest Services</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link
|
||||
to="/faq"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
FAQ
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">FAQ</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
Terms of Service
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Terms of Service</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
Privacy Policy
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Privacy Policy</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="hover:text-blue-500
|
||||
transition-colors text-sm"
|
||||
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
|
||||
>
|
||||
Contact
|
||||
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
|
||||
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Contact Us</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
{/* Contact Info - Premium Style */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">
|
||||
Contact
|
||||
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
|
||||
<span className="relative z-10">Contact</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
|
||||
</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
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start space-x-4 group">
|
||||
<div className="relative mt-0.5">
|
||||
<MapPin className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed font-light">
|
||||
123 ABC Street, District 1<br />
|
||||
Ho Chi Minh City, Vietnam
|
||||
</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">
|
||||
<li className="flex items-center space-x-4 group">
|
||||
<div className="relative">
|
||||
<Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
</div>
|
||||
<a href="tel:+842812345678" className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
|
||||
(028) 1234 5678
|
||||
</span>
|
||||
</a>
|
||||
</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 className="flex items-center space-x-4 group">
|
||||
<div className="relative">
|
||||
<Mail className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
</div>
|
||||
<a href="mailto:info@luxuryhotel.com" className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
|
||||
info@luxuryhotel.com
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Star Rating Display */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-800/50">
|
||||
<div className="flex items-center space-x-1 mb-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-[#d4af37] text-[#d4af37]" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 font-light">Rated 5.0 by 10,000+ guests</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<div className="border-t border-gray-800 mt-8
|
||||
pt-4 -mb-8 text-center"
|
||||
>
|
||||
<p className="text-sm text-gray-400">
|
||||
© {new Date().getFullYear()} Hotel Booking.
|
||||
All rights reserved.
|
||||
</p>
|
||||
{/* Divider with Elegance */}
|
||||
<div className="relative my-12">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-800"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div className="bg-gray-900 px-4">
|
||||
<div className="w-16 h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright - Enhanced */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<div className="text-sm text-gray-500 font-light tracking-wide">
|
||||
© {new Date().getFullYear()} Luxury Hotel. All rights reserved.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 text-xs text-gray-600">
|
||||
<span className="hover:text-[#d4af37]/80 transition-colors cursor-pointer font-light tracking-wide">Privacy</span>
|
||||
<span className="text-gray-700">•</span>
|
||||
<span className="hover:text-[#d4af37]/80 transition-colors cursor-pointer font-light tracking-wide">Terms</span>
|
||||
<span className="text-gray-700">•</span>
|
||||
<CookiePreferencesLink />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Elegant bottom accent */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Hotel,
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
LogIn,
|
||||
UserPlus,
|
||||
Heart,
|
||||
Phone,
|
||||
Mail,
|
||||
} from 'lucide-react';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
|
||||
interface HeaderProps {
|
||||
isAuthenticated?: boolean;
|
||||
@@ -30,6 +33,14 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] =
|
||||
useState(false);
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close user menu when clicking outside
|
||||
useClickOutside(userMenuRef, () => {
|
||||
if (isUserMenuOpen) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
@@ -48,112 +59,151 @@ const Header: React.FC<HeaderProps> = ({
|
||||
};
|
||||
|
||||
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">
|
||||
<header className="bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] sticky top-0 z-50 border-b border-[#d4af37]/20 shadow-2xl">
|
||||
{/* Top Bar - Contact Info */}
|
||||
<div className="hidden lg:block bg-[#0a0a0a]/50 border-b border-[#d4af37]/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
||||
<div className="flex items-center justify-end space-x-6 text-sm">
|
||||
<a href="tel:+1234567890" className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
|
||||
<Phone className="w-3.5 h-3.5" />
|
||||
<span className="tracking-wide">+1 (234) 567-890</span>
|
||||
</a>
|
||||
<a href="mailto:info@luxuryhotel.com" className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<span className="tracking-wide">info@luxuryhotel.com</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Header */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center space-x-2
|
||||
hover:opacity-80 transition-opacity"
|
||||
className="flex items-center space-x-3
|
||||
group transition-all duration-300 hover:opacity-90"
|
||||
>
|
||||
<Hotel className="w-8 h-8 text-blue-600" />
|
||||
<span className="text-2xl font-bold text-gray-800">
|
||||
Hotel Booking
|
||||
</span>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-md opacity-30 group-hover:opacity-50 transition-opacity duration-300"></div>
|
||||
<Hotel className="relative w-10 h-10 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-serif font-semibold text-white tracking-tight leading-tight">
|
||||
Luxury Hotel
|
||||
</span>
|
||||
<span className="text-[10px] text-[#d4af37] tracking-[0.2em] uppercase font-light">
|
||||
Excellence Redefined
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center
|
||||
space-x-6"
|
||||
space-x-1"
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-gray-700 hover:text-blue-600
|
||||
transition-colors font-medium"
|
||||
className="text-white/90 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light px-4 py-2
|
||||
relative group tracking-wide"
|
||||
>
|
||||
Home
|
||||
<span className="relative z-10">Home</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="text-gray-700 hover:text-blue-600
|
||||
transition-colors font-medium"
|
||||
className="text-white/90 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light px-4 py-2
|
||||
relative group tracking-wide"
|
||||
>
|
||||
Rooms
|
||||
<span className="relative z-10">Rooms</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="text-gray-700 hover:text-blue-600
|
||||
transition-colors font-medium"
|
||||
className="text-white/90 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light px-4 py-2
|
||||
relative group tracking-wide"
|
||||
>
|
||||
Bookings
|
||||
<span className="relative z-10">Bookings</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
className="text-gray-700 hover:text-blue-600
|
||||
transition-colors font-medium flex
|
||||
items-center gap-1"
|
||||
className="text-white/90 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light px-4 py-2
|
||||
relative group tracking-wide flex items-center gap-2"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
Favorites
|
||||
<Heart className="w-4 h-4 relative z-10" />
|
||||
<span className="relative z-10">Favorites</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-gray-700 hover:text-blue-600
|
||||
transition-colors font-medium"
|
||||
className="text-white/90 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light px-4 py-2
|
||||
relative group tracking-wide"
|
||||
>
|
||||
About
|
||||
<span className="relative z-10">About</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Auth Section */}
|
||||
<div className="hidden md:flex items-center
|
||||
space-x-4"
|
||||
space-x-3"
|
||||
>
|
||||
{!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"
|
||||
px-5 py-2 text-white/90
|
||||
hover:text-[#d4af37] transition-all duration-300
|
||||
font-light tracking-wide relative group"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
<span>Login</span>
|
||||
<LogIn className="w-4 h-4 relative z-10" />
|
||||
<span className="relative z-10">Login</span>
|
||||
<span className="absolute inset-0 border border-[#d4af37]/30 rounded-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300"></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"
|
||||
px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] rounded-sm hover:from-[#f5d76e]
|
||||
hover:to-[#d4af37] transition-all duration-300
|
||||
font-medium tracking-wide shadow-lg shadow-[#d4af37]/20
|
||||
hover:shadow-[#d4af37]/30 relative overflow-hidden group"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>Register</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
<UserPlus className="w-4 h-4 relative z-10" />
|
||||
<span className="relative z-10">Register</span>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={toggleUserMenu}
|
||||
className="flex items-center space-x-3
|
||||
px-3 py-2 rounded-lg hover:bg-gray-100
|
||||
transition-colors"
|
||||
px-3 py-2 rounded-sm hover:bg-white/10
|
||||
transition-all duration-300 border border-transparent
|
||||
hover:border-[#d4af37]/30"
|
||||
>
|
||||
{userInfo?.avatar ? (
|
||||
<img
|
||||
src={userInfo.avatar}
|
||||
alt={userInfo.name}
|
||||
className="w-8 h-8 rounded-full
|
||||
object-cover"
|
||||
className="w-9 h-9 rounded-full
|
||||
object-cover ring-2 ring-[#d4af37]/50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-blue-500
|
||||
<div className="w-9 h-9 bg-gradient-to-br from-[#d4af37] to-[#c9a227]
|
||||
rounded-full flex items-center
|
||||
justify-center"
|
||||
justify-center ring-2 ring-[#d4af37]/50 shadow-lg"
|
||||
>
|
||||
<span className="text-white
|
||||
<span className="text-[#0f0f0f]
|
||||
font-semibold text-sm"
|
||||
>
|
||||
{userInfo?.name?.charAt(0)
|
||||
@@ -161,7 +211,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-gray-700">
|
||||
<span className="font-light text-white/90 tracking-wide">
|
||||
{userInfo?.name}
|
||||
</span>
|
||||
</button>
|
||||
@@ -169,18 +219,21 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{/* 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"
|
||||
w-52 bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f]
|
||||
rounded-sm shadow-2xl py-2 border border-[#d4af37]/20
|
||||
z-50 backdrop-blur-xl animate-fade-in"
|
||||
>
|
||||
<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"
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>Profile</span>
|
||||
<span className="font-light tracking-wide">Profile</span>
|
||||
</Link>
|
||||
{userInfo?.role === 'admin' && (
|
||||
<Link
|
||||
@@ -189,22 +242,26 @@ const Header: React.FC<HeaderProps> = ({
|
||||
setIsUserMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-2 text-gray-700
|
||||
hover:bg-gray-100 transition-colors"
|
||||
space-x-3 px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>Admin</span>
|
||||
<span className="font-light tracking-wide">Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="border-t border-[#d4af37]/20 my-1"></div>
|
||||
<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"
|
||||
space-x-3 px-4 py-2.5 text-red-400/90
|
||||
hover:bg-red-500/10 hover:text-red-400
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-red-500/50 text-left"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Logout</span>
|
||||
<span className="font-light tracking-wide">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -215,13 +272,14 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={toggleMobileMenu}
|
||||
className="md:hidden p-2 rounded-lg
|
||||
hover:bg-gray-100 transition-colors"
|
||||
className="md:hidden p-2 rounded-sm
|
||||
hover:bg-white/10 border border-transparent
|
||||
hover:border-[#d4af37]/30 transition-all duration-300"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
<X className="w-6 h-6 text-[#d4af37]" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
<Menu className="w-6 h-6 text-white/90" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -229,42 +287,52 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t
|
||||
border-gray-200 mt-4"
|
||||
border-[#d4af37]/20 mt-4 bg-[#0a0a0a]/50
|
||||
backdrop-blur-xl animate-fade-in rounded-b-sm"
|
||||
>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="px-4 py-2 text-gray-700
|
||||
hover:bg-gray-100 rounded-lg
|
||||
transition-colors"
|
||||
className="px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/rooms"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="px-4 py-2 text-gray-700
|
||||
hover:bg-gray-100 rounded-lg
|
||||
transition-colors"
|
||||
className="px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
Rooms
|
||||
</Link>
|
||||
<Link
|
||||
to="/bookings"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="px-4 py-2 text-gray-700
|
||||
hover:bg-gray-100 rounded-lg
|
||||
transition-colors"
|
||||
className="px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
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"
|
||||
className="px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide
|
||||
flex items-center gap-2"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
Favorites
|
||||
@@ -272,15 +340,17 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<Link
|
||||
to="/about"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="px-4 py-2 text-gray-700
|
||||
hover:bg-gray-100 rounded-lg
|
||||
transition-colors"
|
||||
className="px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
|
||||
<div className="border-t border-gray-200
|
||||
pt-2 mt-2"
|
||||
<div className="border-t border-[#d4af37]/20
|
||||
pt-3 mt-3"
|
||||
>
|
||||
{!isAuthenticated ? (
|
||||
<>
|
||||
@@ -290,9 +360,11 @@ const Header: React.FC<HeaderProps> = ({
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-2 text-blue-600
|
||||
hover:bg-gray-100 rounded-lg
|
||||
transition-colors"
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
<span>Login</span>
|
||||
@@ -303,9 +375,12 @@ const Header: React.FC<HeaderProps> = ({
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-2 text-blue-600
|
||||
hover:bg-gray-100 rounded-lg
|
||||
transition-colors"
|
||||
space-x-2 px-4 py-3 bg-gradient-to-r
|
||||
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
|
||||
rounded-sm hover:from-[#f5d76e]
|
||||
hover:to-[#d4af37] transition-all
|
||||
duration-300 font-medium tracking-wide
|
||||
mt-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>Register</span>
|
||||
@@ -314,7 +389,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-2 text-sm
|
||||
text-gray-500"
|
||||
text-[#d4af37]/70 font-light tracking-wide"
|
||||
>
|
||||
Hello, {userInfo?.name}
|
||||
</div>
|
||||
@@ -324,9 +399,11 @@ const Header: React.FC<HeaderProps> = ({
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-2 text-gray-700
|
||||
hover:bg-gray-100 rounded-lg
|
||||
transition-colors"
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>Profile</span>
|
||||
@@ -338,9 +415,11 @@ const Header: React.FC<HeaderProps> = ({
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-2
|
||||
text-gray-700 hover:bg-gray-100
|
||||
rounded-lg transition-colors"
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>Admin</span>
|
||||
@@ -349,9 +428,12 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<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"
|
||||
space-x-2 px-4 py-3 text-red-400/90
|
||||
hover:bg-red-500/10 hover:text-red-400
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-red-500/50 text-left
|
||||
font-light tracking-wide"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Logout</span>
|
||||
|
||||
@@ -29,7 +29,7 @@ const LayoutMain: React.FC<LayoutMainProps> = ({
|
||||
/>
|
||||
|
||||
{/* Main Content Area - Outlet renders child routes */}
|
||||
<main className="flex-1 bg-gray-50">
|
||||
<main className="flex-1 bg-gradient-to-b from-gray-50 to-gray-100/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
ChevronRight,
|
||||
Star,
|
||||
LogIn,
|
||||
LogOut
|
||||
LogOut,
|
||||
ClipboardList
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarAdminProps {
|
||||
@@ -105,6 +106,11 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
icon: BarChart3,
|
||||
label: 'Reports'
|
||||
},
|
||||
{
|
||||
path: '/admin/audit-logs',
|
||||
icon: ClipboardList,
|
||||
label: 'Audit Logs'
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
icon: FileText,
|
||||
|
||||
@@ -4,12 +4,15 @@ import type { Banner } from '../../services/api/bannerService';
|
||||
|
||||
interface BannerCarouselProps {
|
||||
banners: Banner[];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
banners
|
||||
banners,
|
||||
children
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Auto-slide every 5 seconds
|
||||
useEffect(() => {
|
||||
@@ -25,19 +28,28 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
}, [banners.length]);
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex((prev) =>
|
||||
prev === 0 ? banners.length - 1 : prev - 1
|
||||
);
|
||||
setTimeout(() => setIsAnimating(false), 800);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex((prev) =>
|
||||
prev === banners.length - 1 ? 0 : prev + 1
|
||||
);
|
||||
setTimeout(() => setIsAnimating(false), 800);
|
||||
};
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
if (isAnimating || index === currentIndex) return;
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex(index);
|
||||
setTimeout(() => setIsAnimating(false), 800);
|
||||
};
|
||||
|
||||
// Default fallback banner if no banners provided
|
||||
@@ -59,92 +71,341 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-[500px] md:h-[640px] \
|
||||
overflow-hidden rounded-xl shadow-lg"
|
||||
className="relative w-full h-[400px] sm:h-[500px] md:h-[600px] lg:h-[700px] xl:h-[800px] overflow-hidden"
|
||||
>
|
||||
{/* Banner Image */}
|
||||
{/* Banner Image with smooth transitions */}
|
||||
<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';
|
||||
{displayBanners.map((banner, index) => (
|
||||
<div
|
||||
key={banner.id || index}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||
index === currentIndex ? 'opacity-100 z-0 pointer-events-auto' : 'opacity-0 z-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{banner.link ? (
|
||||
<a
|
||||
href={banner.link}
|
||||
target={banner.link.startsWith('http') ? '_blank' : '_self'}
|
||||
rel={banner.link.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={banner.image_url}
|
||||
alt={banner.title}
|
||||
className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/images/default-banner.jpg';
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={banner.image_url}
|
||||
alt={banner.title}
|
||||
className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/images/default-banner.jpg';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Overlay - Enhanced for luxury text readability */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t
|
||||
from-black/70 via-black/30 via-black/15 to-black/5
|
||||
transition-opacity duration-1000 ease-in-out"
|
||||
/>
|
||||
|
||||
{/* Animated gradient overlay for luxury effect */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br
|
||||
from-transparent via-transparent to-black/10
|
||||
animate-pulse"
|
||||
style={{
|
||||
animation: 'luxuryGlow 8s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t
|
||||
from-black/60 via-black/20 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
{/* Title - Positioned at top when search form is present */}
|
||||
{currentBanner.title && (
|
||||
<div
|
||||
className="absolute bottom-8 left-8 right-8
|
||||
text-white"
|
||||
key={currentIndex}
|
||||
className={`absolute ${children ? 'top-12 sm:top-16 md:top-20 lg:top-24' : 'bottom-16 sm:bottom-20 md:bottom-24'}
|
||||
left-1/2 -translate-x-1/2
|
||||
text-white z-10 flex flex-col items-center justify-center
|
||||
w-full max-w-5xl px-4 sm:px-6 md:px-8 lg:px-12
|
||||
animate-fadeInUp`}
|
||||
style={{
|
||||
animation: 'luxuryFadeInUp 1s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
className="text-3xl md:text-5xl font-bold
|
||||
mb-2 drop-shadow-lg"
|
||||
>
|
||||
{currentBanner.title}
|
||||
</h2>
|
||||
|
||||
{/* Animated border glow */}
|
||||
<div
|
||||
className="absolute inset-0
|
||||
rounded-2xl
|
||||
-mx-2 sm:-mx-4 md:-mx-6 lg:-mx-8
|
||||
pointer-events-none
|
||||
opacity-0 animate-borderGlow"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.3), transparent)',
|
||||
animation: 'borderGlow 3s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative w-full flex flex-col items-center">
|
||||
{/* Animated decorative line above title */}
|
||||
<div
|
||||
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mb-4 sm:mb-6 opacity-90
|
||||
animate-lineExpand"
|
||||
style={{
|
||||
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards',
|
||||
maxWidth: '120px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<h2
|
||||
className={`${children ? 'text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl' : 'text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl'}
|
||||
font-serif font-light tracking-[0.02em] sm:tracking-[0.03em] md:tracking-[0.04em] lg:tracking-[0.05em]
|
||||
text-center leading-[1.1] sm:leading-[1.15] md:leading-[1.2]
|
||||
drop-shadow-[0_2px_8px_rgba(0,0,0,0.5)]
|
||||
[text-shadow:_0_2px_20px_rgba(0,0,0,0.8),_0_4px_40px_rgba(0,0,0,0.6)]
|
||||
mb-3 sm:mb-4
|
||||
transform transition-all duration-700 ease-out
|
||||
px-2 sm:px-4 md:px-6
|
||||
opacity-0 animate-textReveal`}
|
||||
style={{
|
||||
letterSpacing: '0.08em',
|
||||
fontWeight: 300,
|
||||
animation: 'textReveal 1s cubic-bezier(0.4, 0, 0.2, 1) 0.5s forwards',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="bg-gradient-to-b from-white via-white via-[#f5d76e] to-[#d4af37] bg-clip-text text-transparent
|
||||
animate-gradientShift"
|
||||
style={{
|
||||
backgroundSize: '200% 200%',
|
||||
animation: 'gradientShift 5s ease infinite',
|
||||
}}
|
||||
>
|
||||
{currentBanner.title}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* Animated decorative line below title */}
|
||||
<div
|
||||
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mt-3 sm:mt-4 opacity-90
|
||||
animate-lineExpand"
|
||||
style={{
|
||||
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.6s forwards',
|
||||
maxWidth: '120px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enhanced luxury gold accent glow with animation */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
|
||||
w-full max-w-5xl h-32 sm:h-40 md:h-48 lg:h-56
|
||||
blur-3xl opacity-0
|
||||
animate-glowPulse"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(212, 175, 55, 0.25) 0%, rgba(212, 175, 55, 0.12) 40%, transparent 70%)',
|
||||
animation: 'glowPulse 4s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating particles effect */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-1 h-1 bg-[#d4af37] rounded-full opacity-40"
|
||||
style={{
|
||||
left: `${20 + i * 30}%`,
|
||||
top: `${30 + i * 20}%`,
|
||||
animation: `floatParticle ${3 + i}s ease-in-out infinite`,
|
||||
animationDelay: `${i * 0.5}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Form Overlay - Centered in lower third */}
|
||||
{children && (
|
||||
<div className="absolute inset-0 flex items-end justify-center z-30 px-2 sm:px-4 md:px-6 lg:px-8 pb-2 sm:pb-4 md:pb-8 lg:pb-12 xl:pb-16 pointer-events-none">
|
||||
<div className="w-full max-w-6xl pointer-events-auto">
|
||||
<div className="bg-white/95 rounded-lg shadow-2xl border border-white/20 p-2 sm:p-3 md:p-4 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
{/* Navigation Buttons - Enhanced luxury style */}
|
||||
{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"
|
||||
type="button"
|
||||
className="absolute left-2 sm:left-4 top-1/2
|
||||
-translate-y-1/2
|
||||
bg-white/90
|
||||
hover:bg-white text-gray-800
|
||||
p-2 sm:p-2.5 md:p-3
|
||||
rounded-full
|
||||
shadow-xl border border-white/20
|
||||
transition-all duration-300 z-40
|
||||
hover:scale-110 hover:shadow-2xl
|
||||
active:scale-95 cursor-pointer
|
||||
group"
|
||||
aria-label="Previous banner"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
<ChevronLeft className="w-5 h-5 sm:w-6 sm:h-6 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
</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"
|
||||
type="button"
|
||||
className="absolute right-2 sm:right-4 top-1/2
|
||||
-translate-y-1/2
|
||||
bg-white/90
|
||||
hover:bg-white text-gray-800
|
||||
p-2 sm:p-2.5 md:p-3
|
||||
rounded-full
|
||||
shadow-xl border border-white/20
|
||||
transition-all duration-300 z-40
|
||||
hover:scale-110 hover:shadow-2xl
|
||||
active:scale-95 cursor-pointer
|
||||
group"
|
||||
aria-label="Next banner"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
<ChevronRight className="w-5 h-5 sm:w-6 sm:h-6 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dots Indicator */}
|
||||
{/* Dots Indicator - Enhanced luxury style */}
|
||||
{displayBanners.length > 1 && (
|
||||
<div
|
||||
className="absolute bottom-4 left-1/2
|
||||
-translate-x-1/2 flex gap-2"
|
||||
className="absolute bottom-2 sm:bottom-4 left-1/2
|
||||
-translate-x-1/2 flex gap-2 sm:gap-2.5 z-40 pointer-events-auto
|
||||
bg-black/40 px-3 py-2 rounded-full
|
||||
border border-white/10"
|
||||
>
|
||||
{displayBanners.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => goToSlide(index)}
|
||||
className={`w-2 h-2 rounded-full
|
||||
transition-all
|
||||
className={`h-2 sm:h-2.5 rounded-full
|
||||
transition-all duration-300 cursor-pointer
|
||||
${
|
||||
index === currentIndex
|
||||
? 'bg-white w-8'
|
||||
: 'bg-white/50 hover:bg-white/75'
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#f5d76e] w-8 sm:w-10 shadow-lg shadow-[#d4af37]/50'
|
||||
: 'bg-white/40 hover:bg-white/70 w-2 sm:h-2.5 hover:scale-125'
|
||||
}`}
|
||||
aria-label={`Go to banner ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS Animations */}
|
||||
<style>{`
|
||||
@keyframes luxuryFadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes textReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lineExpand {
|
||||
from {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes borderGlow {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatParticle {
|
||||
0%, 100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-20px) translateX(10px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-10px) translateX(-10px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes luxuryGlow {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,13 +3,12 @@ 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"
|
||||
className="w-full h-[400px] sm:h-[500px] md:h-[600px] lg:h-[700px] xl:h-[800px] bg-gray-300 animate-pulse"
|
||||
>
|
||||
<div className="w-full h-full flex items-end p-8">
|
||||
<div className="w-full h-full flex items-end p-4 sm: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 className="h-8 sm:h-12 bg-gray-400 rounded w-3/4" />
|
||||
<div className="h-6 sm:h-8 bg-gray-400 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,13 +70,13 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md
|
||||
overflow-hidden hover:shadow-xl
|
||||
transition-shadow duration-300 group"
|
||||
className="luxury-card overflow-hidden group
|
||||
border-t-2 border-transparent hover:border-[#d4af37]
|
||||
hover:shadow-luxury-gold"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-48 overflow-hidden
|
||||
bg-gray-200"
|
||||
<div className="relative h-52 overflow-hidden
|
||||
bg-gradient-to-br from-gray-200 to-gray-300"
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
@@ -84,14 +84,21 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover
|
||||
group-hover:scale-110 transition-transform
|
||||
duration-300"
|
||||
duration-500 ease-out"
|
||||
onLoad={(e) =>
|
||||
e.currentTarget.classList.add('loaded')
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Overlay gradient on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t
|
||||
from-black/60 via-transparent to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
duration-300"
|
||||
/>
|
||||
|
||||
{/* Favorite Button */}
|
||||
<div className="absolute top-3 right-3 z-5">
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<FavoriteButton roomId={room.id} size="md" />
|
||||
</div>
|
||||
|
||||
@@ -99,8 +106,10 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
{room.featured && (
|
||||
<div
|
||||
className="absolute top-3 left-3
|
||||
bg-yellow-500 text-white px-3 py-1
|
||||
rounded-full text-xs font-semibold"
|
||||
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-3 py-1.5
|
||||
rounded-sm text-xs font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 backdrop-blur-sm"
|
||||
>
|
||||
Featured
|
||||
</div>
|
||||
@@ -108,14 +117,15 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
|
||||
{/* Status Badge */}
|
||||
<div
|
||||
className={`absolute bottom-3 left-3 px-3 py-1
|
||||
rounded-full text-xs font-semibold
|
||||
className={`absolute bottom-3 left-3 px-3 py-1.5
|
||||
rounded-sm text-xs font-medium tracking-wide
|
||||
backdrop-blur-sm shadow-lg
|
||||
${
|
||||
room.status === 'available'
|
||||
? 'bg-green-500 text-white'
|
||||
? 'bg-green-500/90 text-white border border-green-400/50'
|
||||
: room.status === 'occupied'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-gray-500 text-white'
|
||||
? 'bg-red-500/90 text-white border border-red-400/50'
|
||||
: 'bg-gray-500/90 text-white border border-gray-400/50'
|
||||
}`}
|
||||
>
|
||||
{room.status === 'available'
|
||||
@@ -127,33 +137,34 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
<div className="p-6">
|
||||
{/* Room Type Name */}
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-2 tracking-tight">
|
||||
{roomType.name}
|
||||
</h3>
|
||||
|
||||
{/* Room Number & Floor */}
|
||||
<div
|
||||
className="flex items-center text-sm
|
||||
text-gray-600 mb-3"
|
||||
text-gray-600 mb-3 font-light tracking-wide"
|
||||
>
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
<MapPin className="w-4 h-4 mr-1.5 text-[#d4af37]" />
|
||||
<span>
|
||||
Room {room.room_number} - Floor {room.floor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description (truncated) */}
|
||||
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2
|
||||
leading-relaxed font-light">
|
||||
{roomType.description}
|
||||
</p>
|
||||
|
||||
{/* Capacity & Rating */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
<span className="text-sm">
|
||||
<Users className="w-4 h-4 mr-1.5 text-[#d4af37]" />
|
||||
<span className="text-sm font-light tracking-wide">
|
||||
{roomType.capacity} guests
|
||||
</span>
|
||||
</div>
|
||||
@@ -161,13 +172,13 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
{room.average_rating != null && (
|
||||
<div className="flex items-center">
|
||||
<Star
|
||||
className="w-4 h-4 text-yellow-500 mr-1"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 text-[#d4af37] mr-1"
|
||||
fill="#d4af37"
|
||||
/>
|
||||
<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">
|
||||
<span className="text-xs text-gray-500 ml-1 font-light">
|
||||
({Number(room.total_reviews || 0)})
|
||||
</span>
|
||||
</div>
|
||||
@@ -176,17 +187,21 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
|
||||
{/* Amenities */}
|
||||
{amenities.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
{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"
|
||||
text-gray-700 text-xs bg-[#d4af37]/10
|
||||
border border-[#d4af37]/20
|
||||
px-2.5 py-1.5 rounded-sm
|
||||
font-light tracking-wide"
|
||||
title={amenity}
|
||||
>
|
||||
{amenityIcons[amenity.toLowerCase()] ||
|
||||
<span>•</span>}
|
||||
<span className="text-[#d4af37]">
|
||||
{amenityIcons[amenity.toLowerCase()] ||
|
||||
<span>•</span>}
|
||||
</span>
|
||||
<span className="capitalize">{amenity}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -194,24 +209,24 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
)}
|
||||
|
||||
{/* Price & Action */}
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center justify-between pt-4
|
||||
border-t border-gray-200">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">From</p>
|
||||
<p className="text-xl font-bold text-indigo-600">
|
||||
<p className="text-xs text-gray-500 font-light tracking-wide mb-0.5">From</p>
|
||||
<p className="text-2xl font-serif font-semibold
|
||||
text-gradient-luxury tracking-tight">
|
||||
{formattedPrice}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">/ night</p>
|
||||
<p className="text-xs text-gray-500 font-light tracking-wide mt-0.5">/ 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"
|
||||
className="btn-luxury-primary flex items-center gap-2
|
||||
text-sm px-5 py-2.5 relative"
|
||||
>
|
||||
View Details
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<span className="relative z-10">View Details</span>
|
||||
<ArrowRight className="w-4 h-4 relative z-10" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -234,17 +234,20 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="luxury-card rounded-sm shadow-lg p-6 mb-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-1 h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227]"></div>
|
||||
<h2 className="text-xl font-serif font-semibold mb-0 text-gray-900 tracking-tight">
|
||||
Room Filters
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Room Type */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="type"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-1"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Room Type
|
||||
</label>
|
||||
@@ -253,9 +256,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
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"
|
||||
className="luxury-input"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="Standard Room">Standard Room</option>
|
||||
@@ -271,7 +272,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="from"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
className="block text-sm font-medium text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Check-in Date
|
||||
</label>
|
||||
@@ -284,14 +285,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
minDate={new Date()}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
placeholderText=""
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
className="luxury-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="to"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
className="block text-sm font-medium text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Check-out Date
|
||||
</label>
|
||||
@@ -304,7 +305,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
minDate={checkInDate || new Date()}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
placeholderText=""
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
className="luxury-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +316,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
<label
|
||||
htmlFor="minPrice"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-1"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Min Price
|
||||
</label>
|
||||
@@ -332,17 +333,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
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"
|
||||
className="luxury-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="maxPrice"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-1"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Max Price
|
||||
</label>
|
||||
@@ -359,10 +357,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
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"
|
||||
className="luxury-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,7 +367,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
<label
|
||||
htmlFor="capacity"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-1"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Number of Guests
|
||||
</label>
|
||||
@@ -385,32 +380,29 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
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"
|
||||
className="luxury-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2 tracking-wide">
|
||||
Amenities
|
||||
</label>
|
||||
{availableAmenities.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">Loading amenities...</div>
|
||||
<div className="text-sm text-gray-500 font-light">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"
|
||||
className="flex items-center gap-2 text-sm w-full font-light tracking-wide hover:text-[#d4af37] transition-colors cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAmenities.includes(amenity)}
|
||||
onChange={() => toggleAmenity(amenity)}
|
||||
className="h-4 w-4"
|
||||
className="h-4 w-4 accent-[#d4af37] cursor-pointer"
|
||||
/>
|
||||
<span className="text-gray-700">{amenity}</span>
|
||||
</label>
|
||||
@@ -423,18 +415,17 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
<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"
|
||||
className="btn-luxury-primary flex-1 py-2.5 px-4 relative"
|
||||
>
|
||||
Apply
|
||||
<span className="relative z-10">Apply</span>
|
||||
</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"
|
||||
className="flex-1 bg-white/80 backdrop-blur-sm text-gray-700
|
||||
py-2.5 px-4 rounded-sm border border-gray-300
|
||||
hover:bg-white hover:border-[#d4af37]/30 hover:text-[#d4af37]
|
||||
transition-all font-medium tracking-wide"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
@@ -18,6 +18,17 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
const [roomType, setRoomType] = useState('');
|
||||
const [guestCount, setGuestCount] = useState<number>(1);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Check if mobile on mount and resize
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 640);
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// Set minimum date to today
|
||||
const today = new Date();
|
||||
@@ -89,18 +100,25 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
// setGuestCount(1);
|
||||
// };
|
||||
|
||||
const isOverlay = className.includes('overlay');
|
||||
|
||||
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>
|
||||
<div className={`w-full ${isOverlay ? 'bg-transparent shadow-none border-none p-0' : 'luxury-glass rounded-sm shadow-2xl p-6 border border-[#d4af37]/20'} ${className}`}>
|
||||
{/* Title - Hidden on mobile when in overlay mode */}
|
||||
{(!isOverlay || !isMobile) && (
|
||||
<div className={`flex items-center justify-center gap-2 sm:gap-3 ${isOverlay ? 'mb-2 sm:mb-3 md:mb-4 lg:mb-6' : 'mb-6'}`}>
|
||||
<div className={`${isOverlay ? 'w-0.5 sm:w-1' : 'w-1'} ${isOverlay ? 'h-4 sm:h-6 md:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
|
||||
<h3 className={`${isOverlay ? 'text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl' : 'text-2xl'} font-serif font-semibold text-gray-900 tracking-tight`}>
|
||||
{isOverlay && isMobile ? 'Find Rooms' : 'Find Available Rooms'}
|
||||
</h3>
|
||||
<div className={`${isOverlay ? 'w-0.5 sm:w-1' : 'w-1'} ${isOverlay ? 'h-4 sm:h-6 md:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
|
||||
</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>
|
||||
<div className={`grid ${isOverlay ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-12' : 'grid-cols-1 md:grid-cols-12'} ${isOverlay ? 'gap-2 sm:gap-3 md:gap-4' : 'gap-4'} items-end`}>
|
||||
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
|
||||
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Check-in</label>
|
||||
<DatePicker
|
||||
selected={checkInDate}
|
||||
onChange={(date) => setCheckInDate(date)}
|
||||
@@ -108,14 +126,14 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
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"
|
||||
placeholderText="Check-in"
|
||||
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"}
|
||||
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-3">
|
||||
<label className="sr-only">Check-out Date</label>
|
||||
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
|
||||
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Check-out</label>
|
||||
<DatePicker
|
||||
selected={checkOutDate}
|
||||
onChange={(date) => setCheckOutDate(date)}
|
||||
@@ -123,49 +141,50 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
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"
|
||||
placeholderText="Check-out"
|
||||
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"}
|
||||
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="sr-only">Room Type</label>
|
||||
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}>
|
||||
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>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"
|
||||
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}>
|
||||
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>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"
|
||||
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
|
||||
>
|
||||
{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">
|
||||
|
||||
<div className={`${isOverlay ? 'sm:col-span-2 lg:col-span-2' : 'md:col-span-2'} flex items-end ${isOverlay ? 'mt-1 sm:mt-2 md:mt-0' : '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"
|
||||
className={`btn-luxury-primary w-full flex items-center justify-center gap-1.5 sm:gap-2 ${isOverlay ? 'text-xs sm:text-sm py-1.5 sm:py-2 md:py-3' : 'text-sm'} relative`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 justify-center w-full">
|
||||
<Search className="w-4 h-4" />
|
||||
{isSearching ? 'Searching...' : 'Search Rooms'}
|
||||
<Search className={`${isOverlay ? 'w-3 h-3 sm:w-4 sm:h-4' : 'w-4 h-4'} relative z-10`} />
|
||||
<span className="relative z-10">
|
||||
{isSearching ? 'Searching...' : 'Search'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
112
Frontend/src/contexts/CookieConsentContext.tsx
Normal file
112
Frontend/src/contexts/CookieConsentContext.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import privacyService, {
|
||||
CookieCategoryPreferences,
|
||||
CookieConsent,
|
||||
UpdateCookieConsentRequest,
|
||||
} from '../services/api/privacyService';
|
||||
|
||||
type CookieConsentContextValue = {
|
||||
consent: CookieConsent | null;
|
||||
isLoading: boolean;
|
||||
hasDecided: boolean;
|
||||
updateConsent: (payload: UpdateCookieConsentRequest) => Promise<void>;
|
||||
};
|
||||
|
||||
const CookieConsentContext = createContext<CookieConsentContextValue | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [consent, setConsent] = useState<CookieConsent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [hasDecided, setHasDecided] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadConsent = async () => {
|
||||
try {
|
||||
const data = await privacyService.getCookieConsent();
|
||||
if (!isMounted) return;
|
||||
setConsent(data);
|
||||
|
||||
// Prefer explicit local decision flag, fall back to server flag
|
||||
const localFlag =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem('cookieConsentDecided')
|
||||
: null;
|
||||
const decided =
|
||||
(localFlag === 'true') || Boolean((data as any).has_decided);
|
||||
setHasDecided(decided);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load cookie consent', error);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadConsent();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateConsent = useCallback(
|
||||
async (payload: UpdateCookieConsentRequest) => {
|
||||
const updated = await privacyService.updateCookieConsent(payload);
|
||||
setConsent(updated);
|
||||
setHasDecided(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('cookieConsentDecided', 'true');
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const value: CookieConsentContextValue = {
|
||||
consent,
|
||||
isLoading,
|
||||
hasDecided,
|
||||
updateConsent,
|
||||
};
|
||||
|
||||
return (
|
||||
<CookieConsentContext.Provider value={value}>
|
||||
{children}
|
||||
</CookieConsentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCookieConsent = (): CookieConsentContextValue => {
|
||||
const ctx = useContext(CookieConsentContext);
|
||||
if (!ctx) {
|
||||
// Fallback to a safe default instead of throwing, to avoid crashes
|
||||
// if components are rendered outside the provider (e.g. during hot reload).
|
||||
return {
|
||||
consent: null,
|
||||
isLoading: false,
|
||||
hasDecided: false,
|
||||
updateConsent: async () => {
|
||||
// no-op when context is not yet available
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
|
||||
40
Frontend/src/contexts/GlobalLoadingContext.tsx
Normal file
40
Frontend/src/contexts/GlobalLoadingContext.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import GlobalLoading from '../components/common/GlobalLoading';
|
||||
|
||||
interface GlobalLoadingContextType {
|
||||
setLoading: (loading: boolean, text?: string) => void;
|
||||
isLoading: boolean;
|
||||
loadingText: string;
|
||||
}
|
||||
|
||||
const GlobalLoadingContext = createContext<GlobalLoadingContextType | undefined>(undefined);
|
||||
|
||||
export const useGlobalLoading = () => {
|
||||
const context = useContext(GlobalLoadingContext);
|
||||
if (!context) {
|
||||
throw new Error('useGlobalLoading must be used within GlobalLoadingProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GlobalLoadingProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalLoadingProvider: React.FC<GlobalLoadingProviderProps> = ({ children }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('Loading...');
|
||||
|
||||
const setLoading = (loading: boolean, text: string = 'Loading...') => {
|
||||
setIsLoading(loading);
|
||||
setLoadingText(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<GlobalLoadingContext.Provider value={{ setLoading, isLoading, loadingText }}>
|
||||
{children}
|
||||
<GlobalLoading isLoading={isLoading} text={loadingText} />
|
||||
</GlobalLoadingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
7
Frontend/src/hooks/index.ts
Normal file
7
Frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as useDebounce } from './useDebounce';
|
||||
export { useAsync } from './useAsync';
|
||||
export { useLocalStorage } from './useLocalStorage';
|
||||
export { useOffline } from './useOffline';
|
||||
export { useClickOutside } from './useClickOutside';
|
||||
export { default as usePagePerformance } from './usePagePerformance';
|
||||
|
||||
86
Frontend/src/hooks/useAsync.ts
Normal file
86
Frontend/src/hooks/useAsync.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface UseAsyncOptions<T> {
|
||||
immediate?: boolean;
|
||||
onSuccess?: (data: T) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseAsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
execute: (...args: any[]) => Promise<T | undefined>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling async operations with loading and error states
|
||||
*/
|
||||
export function useAsync<T>(
|
||||
asyncFunction: (...args: any[]) => Promise<T>,
|
||||
options: UseAsyncOptions<T> = {}
|
||||
): UseAsyncState<T> {
|
||||
const { immediate = false, onSuccess, onError } = options;
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(immediate);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(
|
||||
async (...args: any[]): Promise<T | undefined> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await asyncFunction(...args);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[asyncFunction, onSuccess, onError]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setData(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (immediate) {
|
||||
execute();
|
||||
}
|
||||
}, [immediate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { data, loading, error, execute, reset };
|
||||
}
|
||||
|
||||
28
Frontend/src/hooks/useClickOutside.ts
Normal file
28
Frontend/src/hooks/useClickOutside.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect clicks outside of a ref element
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
handler: (event: MouseEvent | TouchEvent) => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
handler(event);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', listener);
|
||||
document.addEventListener('touchstart', listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', listener);
|
||||
document.removeEventListener('touchstart', listener);
|
||||
};
|
||||
}, [ref, handler]);
|
||||
}
|
||||
|
||||
68
Frontend/src/hooks/useLocalStorage.ts
Normal file
68
Frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type SetValue<T> = T | ((val: T) => T);
|
||||
|
||||
/**
|
||||
* Hook for managing localStorage with React state
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, (value: SetValue<T>) => void, () => void] {
|
||||
// State to store our value
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Return a wrapped version of useState's setter function that
|
||||
// persists the new value to localStorage.
|
||||
const setValue = useCallback(
|
||||
(value: SetValue<T>) => {
|
||||
try {
|
||||
// Allow value to be a function so we have the same API as useState
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
},
|
||||
[key, storedValue]
|
||||
);
|
||||
|
||||
const removeValue = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
setStoredValue(initialValue);
|
||||
} catch (error) {
|
||||
console.error(`Error removing localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
// Listen for changes to the key in other tabs/windows
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === key && e.newValue) {
|
||||
try {
|
||||
setStoredValue(JSON.parse(e.newValue));
|
||||
} catch (error) {
|
||||
console.error(`Error parsing localStorage value for key "${key}":`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [key]);
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
|
||||
29
Frontend/src/hooks/useOffline.ts
Normal file
29
Frontend/src/hooks/useOffline.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect if the user is offline
|
||||
*/
|
||||
export function useOffline(): boolean {
|
||||
const [isOffline, setIsOffline] = useState(() => {
|
||||
if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
|
||||
return !navigator.onLine;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOffline;
|
||||
}
|
||||
|
||||
244
Frontend/src/pages/AboutPage.tsx
Normal file
244
Frontend/src/pages/AboutPage.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Hotel,
|
||||
Award,
|
||||
Users,
|
||||
Heart,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Star,
|
||||
Shield,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] text-white py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto text-center animate-fade-in">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-2xl opacity-30"></div>
|
||||
<Hotel className="relative w-20 h-20 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight">
|
||||
About Luxury Hotel
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 font-light leading-relaxed">
|
||||
Where Excellence Meets Unforgettable Experiences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Our Story Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Our Story
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
</div>
|
||||
<div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up">
|
||||
<p>
|
||||
Welcome to Luxury Hotel, where timeless elegance meets modern sophistication.
|
||||
Since our founding, we have been dedicated to providing exceptional hospitality
|
||||
and creating unforgettable memories for our guests.
|
||||
</p>
|
||||
<p>
|
||||
Nestled in the heart of the city, our hotel combines classic architecture with
|
||||
contemporary amenities, offering a perfect blend of comfort and luxury. Every
|
||||
detail has been carefully curated to ensure your stay exceeds expectations.
|
||||
</p>
|
||||
<p>
|
||||
Our commitment to excellence extends beyond our beautiful rooms and facilities.
|
||||
We believe in creating meaningful connections with our guests, understanding
|
||||
their needs, and delivering personalized service that makes each visit special.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Values Section */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Our Values
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'Passion',
|
||||
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
title: 'Excellence',
|
||||
description: 'We strive for excellence in every aspect of our service, from the smallest detail to the grandest gesture.'
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Integrity',
|
||||
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Service',
|
||||
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
|
||||
}
|
||||
].map((value, index) => (
|
||||
<div
|
||||
key={value.title}
|
||||
className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-lg flex items-center justify-center mb-4">
|
||||
<value.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{value.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{value.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Why Choose Us
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: Star,
|
||||
title: 'Premium Accommodations',
|
||||
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: '24/7 Service',
|
||||
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
title: 'Award-Winning',
|
||||
description: 'Recognized for excellence in hospitality and guest satisfaction.'
|
||||
}
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="text-center p-6 animate-slide-up"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<feature.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Get In Touch
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
<p className="text-gray-600 mt-4">
|
||||
We'd love to hear from you. Contact us for reservations or inquiries.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<MapPin className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Address
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
123 Luxury Street<br />
|
||||
City, State 12345<br />
|
||||
Country
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Phone className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Phone
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<a href="tel:+1234567890" className="hover:text-[#d4af37] transition-colors">
|
||||
+1 (234) 567-890
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Mail className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Email
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<a href="mailto:info@luxuryhotel.com" className="hover:text-[#d4af37] transition-colors">
|
||||
info@luxuryhotel.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mt-12 animate-fade-in">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center space-x-2 px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
|
||||
>
|
||||
<span>Explore Our Rooms</span>
|
||||
<Hotel className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
|
||||
@@ -137,44 +137,50 @@ const HomePage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Banner Section */}
|
||||
<section className="container mx-auto px-4 pb-8">
|
||||
<>
|
||||
{/* Banner Section - Full Width, breaks out of container */}
|
||||
<section
|
||||
className="relative w-screen -mt-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)'
|
||||
}}
|
||||
>
|
||||
{isLoadingBanners ? (
|
||||
<BannerSkeleton />
|
||||
) : (
|
||||
<BannerCarousel banners={banners} />
|
||||
<div className="animate-fade-in">
|
||||
<BannerCarousel banners={banners}>
|
||||
<SearchRoomForm className="overlay" />
|
||||
</BannerCarousel>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Search Section */}
|
||||
<section className="container mx-auto px-4 py-8">
|
||||
<SearchRoomForm />
|
||||
</section>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100/50 to-gray-50">
|
||||
|
||||
{/* Featured Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-12">
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2
|
||||
className="text-3xl font-bold
|
||||
text-gray-900"
|
||||
>
|
||||
<h2 className="luxury-section-title">
|
||||
Featured Rooms
|
||||
</h2>
|
||||
<p className="luxury-section-subtitle">
|
||||
Discover our most popular accommodations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
text-indigo-600 hover:text-indigo-700
|
||||
font-semibold transition-colors"
|
||||
btn-luxury-secondary group text-white"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -193,21 +199,25 @@ const HomePage: React.FC = () => {
|
||||
{/* Error State */}
|
||||
{error && !isLoadingRooms && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-6 text-center"
|
||||
className="luxury-card p-8 text-center animate-fade-in
|
||||
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16
|
||||
bg-red-100 rounded-full mb-4">
|
||||
<AlertCircle
|
||||
className="w-8 h-8 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-red-800 font-serif font-semibold text-lg mb-2 tracking-tight">
|
||||
{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"
|
||||
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
|
||||
text-white rounded-sm font-medium tracking-wide
|
||||
hover:from-red-700 hover:to-red-800
|
||||
transition-all duration-300 shadow-lg shadow-red-500/30
|
||||
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
@@ -228,10 +238,9 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-gray-100 rounded-lg
|
||||
p-12 text-center"
|
||||
className="luxury-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
<p className="text-gray-600 text-lg font-light tracking-wide">
|
||||
No featured rooms available
|
||||
</p>
|
||||
</div>
|
||||
@@ -239,16 +248,13 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{featuredRooms.length > 0 && (
|
||||
<div className="mt-8 text-center md:hidden">
|
||||
<div className="mt-10 text-center md:hidden animate-slide-up">
|
||||
<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"
|
||||
className="btn-luxury-primary inline-flex items-center gap-2"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<span className="relative z-10">View All Rooms</span>
|
||||
<ArrowRight className="w-5 h-5 relative z-10" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -257,28 +263,27 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Newest Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-12">
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2
|
||||
className="text-3xl font-bold
|
||||
text-gray-900"
|
||||
>
|
||||
<h2 className="luxury-section-title">
|
||||
Newest Rooms
|
||||
</h2>
|
||||
<p className="luxury-section-subtitle">
|
||||
Explore our latest additions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
text-indigo-600 hover:text-indigo-700
|
||||
font-semibold transition-colors"
|
||||
btn-luxury-secondary group text-white"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -308,10 +313,9 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-gray-100 rounded-lg
|
||||
p-12 text-center"
|
||||
className="luxury-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
<p className="text-gray-600 text-lg font-light tracking-wide">
|
||||
No new rooms available
|
||||
</p>
|
||||
</div>
|
||||
@@ -319,16 +323,13 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{newestRooms.length > 0 && (
|
||||
<div className="mt-8 text-center md:hidden">
|
||||
<div className="mt-10 text-center md:hidden animate-slide-up">
|
||||
<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"
|
||||
className="btn-luxury-primary inline-flex items-center gap-2"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<span className="relative z-10">View All Rooms</span>
|
||||
<ArrowRight className="w-5 h-5 relative z-10" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -337,73 +338,79 @@ const HomePage: React.FC = () => {
|
||||
</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>
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
<div className="luxury-card-gold p-12 animate-fade-in relative overflow-hidden">
|
||||
{/* Decorative gold accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e]"></div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="text-center group">
|
||||
<div
|
||||
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-sm flex items-center justify-center mx-auto mb-6
|
||||
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
|
||||
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
|
||||
transition-all duration-300"
|
||||
>
|
||||
<span className="text-4xl">🏨</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-serif font-semibold mb-3
|
||||
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
|
||||
>
|
||||
Easy Booking
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
Search and book rooms with just a few clicks
|
||||
</p>
|
||||
</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 className="text-center group">
|
||||
<div
|
||||
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-sm flex items-center justify-center mx-auto mb-6
|
||||
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
|
||||
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
|
||||
transition-all duration-300"
|
||||
>
|
||||
<span className="text-4xl">💰</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-serif font-semibold mb-3
|
||||
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
|
||||
>
|
||||
Best Prices
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
Best price guarantee in the market
|
||||
</p>
|
||||
</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 className="text-center group">
|
||||
<div
|
||||
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-sm flex items-center justify-center mx-auto mb-6
|
||||
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
|
||||
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
|
||||
transition-all duration-300"
|
||||
>
|
||||
<span className="text-4xl">🎧</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-serif font-semibold mb-3
|
||||
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
|
||||
>
|
||||
24/7 Support
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
Support team always ready to serve
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
446
Frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
446
Frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Calendar,
|
||||
User,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../../services/api/auditService';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
const AuditLogsPage: React.FC = () => {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
const [filters, setFilters] = useState<AuditLogFilters>({
|
||||
page: 1,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(prev => ({ ...prev, page: currentPage }));
|
||||
}, [currentPage]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await auditService.getAuditLogs({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setLogs(response.data.logs);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit logs:', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load audit logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: keyof AuditLogFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = (searchTerm: string) => {
|
||||
handleFilterChange('search', searchTerm || undefined);
|
||||
};
|
||||
|
||||
const handleViewDetails = (log: AuditLog) => {
|
||||
setSelectedLog(log);
|
||||
setShowDetails(true);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-5 h-5 text-orange-500" />;
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const baseClasses = "px-2 py-1 rounded-full text-xs font-medium";
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
case 'failed':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-orange-100 text-orange-800`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && logs.length === 0) {
|
||||
return <Loading fullScreen text="Loading audit logs..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Audit Logs</h1>
|
||||
<p className="text-gray-600">View all system activity and actions</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actions, types..."
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by action"
|
||||
value={filters.action || ''}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Resource Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by type"
|
||||
value={filters.resource_type || ''}
|
||||
onChange={(e) => handleFilterChange('resource_type', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.start_date || ''}
|
||||
onChange={(e) => handleFilterChange('start_date', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.end_date || ''}
|
||||
onChange={(e) => handleFilterChange('end_date', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Logs</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalItems}</p>
|
||||
</div>
|
||||
<FileText className="w-8 h-8 text-[#d4af37]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Current Page</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{currentPage}</p>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
{logs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No Audit Logs Found"
|
||||
description="No audit logs match your current filters."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</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">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
#{log.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{log.action}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{log.resource_type}</div>
|
||||
{log.resource_id && (
|
||||
<div className="text-xs text-gray-500">ID: {log.resource_id}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{log.user ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{log.user.full_name}</div>
|
||||
<div className="text-xs text-gray-500">{log.user.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">System</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(log.status)}
|
||||
<span className={getStatusBadge(log.status)}>
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{log.ip_address || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleViewDetails(log)}
|
||||
className="text-[#d4af37] hover:text-[#c9a227] flex items-center space-x-1"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>View</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
{showDetails && selectedLog && (
|
||||
<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 max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Audit Log Details</h2>
|
||||
<button
|
||||
onClick={() => setShowDetails(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900">#{selectedLog.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Status</label>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
{getStatusIcon(selectedLog.status)}
|
||||
<span className={getStatusBadge(selectedLog.status)}>
|
||||
{selectedLog.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Action</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.action}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Resource Type</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_type}</p>
|
||||
</div>
|
||||
{selectedLog.resource_id && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Resource ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_id}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Date</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{formatDate(selectedLog.created_at)}</p>
|
||||
</div>
|
||||
{selectedLog.user && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">User</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.user.full_name}</p>
|
||||
<p className="text-xs text-gray-500">{selectedLog.user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">User ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.user_id}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedLog.ip_address && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">IP Address</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.ip_address}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.request_id && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Request ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900 font-mono text-xs">{selectedLog.request_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLog.user_agent && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">User Agent</label>
|
||||
<p className="mt-1 text-sm text-gray-900 break-all">{selectedLog.user_agent}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.error_message && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-red-700">Error Message</label>
|
||||
<p className="mt-1 text-sm text-red-900 bg-red-50 p-3 rounded">{selectedLog.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.details && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Details</label>
|
||||
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-x-auto">
|
||||
{JSON.stringify(selectedLog.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDetails(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLogsPage;
|
||||
|
||||
677
Frontend/src/pages/admin/BannerManagementPage.tsx
Normal file
677
Frontend/src/pages/admin/BannerManagementPage.tsx
Normal file
@@ -0,0 +1,677 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Image as ImageIcon, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { ConfirmationDialog } from '../../components/common';
|
||||
import bannerServiceModule from '../../services/api/bannerService';
|
||||
import type { Banner } from '../../services/api/bannerService';
|
||||
|
||||
// Extract functions from default export - workaround for TypeScript cache issue
|
||||
// All functions are properly exported in bannerService.ts
|
||||
const {
|
||||
getAllBanners,
|
||||
createBanner,
|
||||
updateBanner,
|
||||
deleteBanner,
|
||||
uploadBannerImage,
|
||||
} = bannerServiceModule as any;
|
||||
|
||||
const BannerManagementPage: React.FC = () => {
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; id: number | null }>({
|
||||
show: false,
|
||||
id: null,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
position: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
image_url: '',
|
||||
link: '',
|
||||
position: 'home',
|
||||
display_order: 0,
|
||||
is_active: true,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
});
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [useFileUpload, setUseFileUpload] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBanners();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchBanners = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getAllBanners({
|
||||
position: filters.position || undefined,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
|
||||
let allBanners = response.data?.banners || [];
|
||||
|
||||
// Filter by search if provided
|
||||
if (filters.search) {
|
||||
allBanners = allBanners.filter((banner: Banner) =>
|
||||
banner.title.toLowerCase().includes(filters.search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setBanners(allBanners);
|
||||
setTotalPages(Math.ceil(allBanners.length / itemsPerPage));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load banners');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageFileChange = async (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 be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setImageFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image immediately
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const response = await uploadBannerImage(file);
|
||||
if (response.status === 'success' || response.success) {
|
||||
setFormData({ ...formData, image_url: response.data.image_url });
|
||||
toast.success('Image uploaded successfully');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to upload image');
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate image URL or file
|
||||
if (!formData.image_url && !imageFile) {
|
||||
toast.error('Please upload an image or provide an image URL');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If there's a file but no URL yet, upload it first
|
||||
let imageUrl = formData.image_url;
|
||||
if (imageFile && !imageUrl) {
|
||||
setUploadingImage(true);
|
||||
const uploadResponse = await uploadBannerImage(imageFile);
|
||||
if (uploadResponse.status === 'success' || uploadResponse.success) {
|
||||
imageUrl = uploadResponse.data.image_url;
|
||||
} else {
|
||||
throw new Error('Failed to upload image');
|
||||
}
|
||||
setUploadingImage(false);
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
image_url: imageUrl,
|
||||
start_date: formData.start_date || undefined,
|
||||
end_date: formData.end_date || undefined,
|
||||
};
|
||||
|
||||
if (editingBanner) {
|
||||
await updateBanner(editingBanner.id, submitData);
|
||||
toast.success('Banner updated successfully');
|
||||
} else {
|
||||
await createBanner(submitData);
|
||||
toast.success('Banner created successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchBanners();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (banner: Banner) => {
|
||||
setEditingBanner(banner);
|
||||
setFormData({
|
||||
title: banner.title || '',
|
||||
description: '',
|
||||
image_url: banner.image_url || '',
|
||||
link: banner.link || '',
|
||||
position: banner.position || 'home',
|
||||
display_order: banner.display_order || 0,
|
||||
is_active: banner.is_active ?? true,
|
||||
start_date: banner.start_date ? banner.start_date.split('T')[0] : '',
|
||||
end_date: banner.end_date ? banner.end_date.split('T')[0] : '',
|
||||
});
|
||||
setImageFile(null);
|
||||
// Normalize image URL for preview (handle both relative and absolute URLs)
|
||||
const previewUrl = banner.image_url
|
||||
? (banner.image_url.startsWith('http')
|
||||
? banner.image_url
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${banner.image_url}`)
|
||||
: null;
|
||||
setImagePreview(previewUrl);
|
||||
setUseFileUpload(false); // When editing, show URL by default
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.id) return;
|
||||
|
||||
try {
|
||||
await deleteBanner(deleteConfirm.id);
|
||||
toast.success('Banner deleted successfully');
|
||||
setDeleteConfirm({ show: false, id: null });
|
||||
fetchBanners();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to delete banner');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
image_url: '',
|
||||
link: '',
|
||||
position: 'home',
|
||||
display_order: 0,
|
||||
is_active: true,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
});
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setUseFileUpload(true);
|
||||
setEditingBanner(null);
|
||||
};
|
||||
|
||||
const toggleActive = async (banner: Banner) => {
|
||||
try {
|
||||
await updateBanner(banner.id, {
|
||||
is_active: !banner.is_active,
|
||||
});
|
||||
toast.success(`Banner ${!banner.is_active ? 'activated' : 'deactivated'}`);
|
||||
fetchBanners();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to update banner');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && banners.length === 0) {
|
||||
return <Loading fullScreen text="Loading banners..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Banner Management</h1>
|
||||
<p className="text-gray-600">Manage promotional banners and advertisements</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>Add Banner</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<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 title..."
|
||||
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-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
value={filters.position}
|
||||
onChange={(e) => setFilters({ ...filters, position: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="">All Positions</option>
|
||||
<option value="home">Home</option>
|
||||
<option value="rooms">Rooms</option>
|
||||
<option value="about">About</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banners Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Image
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Position
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Order
|
||||
</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">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{banners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||
No banners found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
banners.map((banner) => (
|
||||
<tr key={banner.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{banner.image_url ? (
|
||||
<img
|
||||
src={banner.image_url}
|
||||
alt={banner.title}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<ImageIcon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">{banner.title}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
||||
{banner.position}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{banner.display_order}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => toggleActive(banner)}
|
||||
className={`flex items-center space-x-1 px-2 py-1 rounded text-xs font-medium ${
|
||||
banner.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{banner.is_active ? (
|
||||
<>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>Active</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
<span>Inactive</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(banner)}
|
||||
className="text-[#d4af37] hover:text-[#c9a227]"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, id: banner.id })}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit 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 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{editingBanner ? 'Edit Banner' : 'Create Banner'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
{/* Image Upload/URL Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Banner Image *
|
||||
</label>
|
||||
|
||||
{/* Toggle between file upload and URL */}
|
||||
<div className="flex space-x-4 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseFileUpload(true);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setFormData({ ...formData, image_url: '' });
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
useFileUpload
|
||||
? 'bg-[#d4af37] text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseFileUpload(false);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
!useFileUpload
|
||||
? 'bg-[#d4af37] text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Use URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useFileUpload ? (
|
||||
<div>
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
{uploadingImage ? (
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<Loader2 className="w-8 h-8 text-[#d4af37] animate-spin mb-2" />
|
||||
<p className="text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : imagePreview ? (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setFormData({ ...formData, image_url: '' });
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<ImageIcon className="w-10 h-10 mb-2 text-gray-400" />
|
||||
<p className="mb-2 text-sm text-gray-500">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileChange}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="url"
|
||||
required={!imageFile}
|
||||
value={formData.image_url}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, image_url: e.target.value });
|
||||
setImagePreview(e.target.value || null);
|
||||
}}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
{imagePreview && (
|
||||
<div className="mt-3 relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover rounded-lg border border-gray-300"
|
||||
onError={() => setImagePreview(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
value={formData.position}
|
||||
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="home">Home</option>
|
||||
<option value="rooms">Rooms</option>
|
||||
<option value="about">About</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Display Order
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.display_order}
|
||||
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Link URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.link}
|
||||
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{editingBanner && (
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded border-gray-300 text-[#d4af37] focus:ring-[#d4af37]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end space-x-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227]"
|
||||
>
|
||||
{editingBanner ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, id: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Banner"
|
||||
message="Are you sure you want to delete this banner? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerManagementPage;
|
||||
|
||||
@@ -97,13 +97,13 @@ const BookingManagementPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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 className="space-y-8">
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="enterprise-section-title">Booking Management</h1>
|
||||
<p className="enterprise-section-subtitle mt-2">Manage and track all hotel bookings</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<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" />
|
||||
@@ -112,13 +112,13 @@ const BookingManagementPage: React.FC = () => {
|
||||
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"
|
||||
className="enterprise-input pl-10"
|
||||
/>
|
||||
</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"
|
||||
className="enterprise-input"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="pending">Pending confirmation</option>
|
||||
@@ -130,34 +130,20 @@ const BookingManagementPage: React.FC = () => {
|
||||
</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">
|
||||
<div className="enterprise-card overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<table className="enterprise-table">
|
||||
<thead>
|
||||
<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>
|
||||
<th>Booking Number</th>
|
||||
<th>Customer</th>
|
||||
<th>Room</th>
|
||||
<th>Check-in/out</th>
|
||||
<th>Total Price</th>
|
||||
<th>Status</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody>
|
||||
{bookings.map((booking) => (
|
||||
<tr key={booking.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -242,11 +228,14 @@ const BookingManagementPage: React.FC = () => {
|
||||
|
||||
{/* 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">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="enterprise-card p-8 w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-scale-in">
|
||||
<div className="flex justify-between items-center mb-6 pb-4 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Booking Details</h2>
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -296,10 +285,10 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
className="btn-enterprise-secondary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
357
Frontend/src/pages/admin/CookieSettingsPage.tsx
Normal file
357
Frontend/src/pages/admin/CookieSettingsPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Shield, SlidersHorizontal, Info, Save, Globe } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import adminPrivacyService, {
|
||||
CookieIntegrationSettings,
|
||||
CookieIntegrationSettingsResponse,
|
||||
CookiePolicySettings,
|
||||
CookiePolicySettingsResponse,
|
||||
} from '../../services/api/adminPrivacyService';
|
||||
import { Loading } from '../../components/common';
|
||||
|
||||
const CookieSettingsPage: React.FC = () => {
|
||||
const [policy, setPolicy] = useState<CookiePolicySettings>({
|
||||
analytics_enabled: true,
|
||||
marketing_enabled: true,
|
||||
preferences_enabled: true,
|
||||
});
|
||||
const [integrations, setIntegrations] = useState<CookieIntegrationSettings>({
|
||||
ga_measurement_id: '',
|
||||
fb_pixel_id: '',
|
||||
});
|
||||
const [policyMeta, setPolicyMeta] = useState<
|
||||
Pick<CookiePolicySettingsResponse, 'updated_at' | 'updated_by'> | null
|
||||
>(null);
|
||||
const [integrationMeta, setIntegrationMeta] = useState<
|
||||
Pick<CookieIntegrationSettingsResponse, 'updated_at' | 'updated_by'> | null
|
||||
>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [policyRes, integrationRes] = await Promise.all([
|
||||
adminPrivacyService.getCookiePolicy(),
|
||||
adminPrivacyService.getIntegrations(),
|
||||
]);
|
||||
setPolicy(policyRes.data);
|
||||
setPolicyMeta({
|
||||
updated_at: policyRes.updated_at,
|
||||
updated_by: policyRes.updated_by,
|
||||
});
|
||||
setIntegrations(integrationRes.data || {});
|
||||
setIntegrationMeta({
|
||||
updated_at: integrationRes.updated_at,
|
||||
updated_by: integrationRes.updated_by,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to load cookie & integration settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleToggle = (key: keyof CookiePolicySettings) => {
|
||||
setPolicy((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const [policyRes, integrationRes] = await Promise.all([
|
||||
adminPrivacyService.updateCookiePolicy(policy),
|
||||
adminPrivacyService.updateIntegrations(integrations),
|
||||
]);
|
||||
setPolicy(policyRes.data);
|
||||
setPolicyMeta({
|
||||
updated_at: policyRes.updated_at,
|
||||
updated_by: policyRes.updated_by,
|
||||
});
|
||||
setIntegrations(integrationRes.data || {});
|
||||
setIntegrationMeta({
|
||||
updated_at: integrationRes.updated_at,
|
||||
updated_by: integrationRes.updated_by,
|
||||
});
|
||||
toast.success('Cookie policy and integrations updated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update cookie settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveIntegrations = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const integrationRes = await adminPrivacyService.updateIntegrations(
|
||||
integrations
|
||||
);
|
||||
setIntegrations(integrationRes.data || {});
|
||||
setIntegrationMeta({
|
||||
updated_at: integrationRes.updated_at,
|
||||
updated_by: integrationRes.updated_by,
|
||||
});
|
||||
toast.success('Integration IDs updated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update integration IDs');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen={false} text="Loading cookie policy..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-amber-500" />
|
||||
<h1 className="enterprise-section-title">Cookie & Privacy Controls</h1>
|
||||
</div>
|
||||
<p className="enterprise-section-subtitle max-w-2xl">
|
||||
Define which cookie categories are allowed in the application. These
|
||||
settings control which types of cookies your users can consent to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-enterprise-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
|
||||
{saving ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info card */}
|
||||
<div className="enterprise-card flex gap-4 p-4 sm:p-5">
|
||||
<div className="mt-1">
|
||||
<Info className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-700">
|
||||
<p className="font-semibold text-gray-900">
|
||||
How these settings affect the guest experience
|
||||
</p>
|
||||
<p>
|
||||
Disabling a category here prevents it from being offered to guests as
|
||||
part of the cookie consent flow. For example, if marketing cookies are
|
||||
disabled, the website should not load marketing pixels even if a guest
|
||||
previously opted in.
|
||||
</p>
|
||||
{policyMeta?.updated_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Last updated on{' '}
|
||||
{new Date(policyMeta.updated_at).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}{' '}
|
||||
{policyMeta.updated_by ? `by ${policyMeta.updated_by}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="enterprise-card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-emerald-500" />
|
||||
Analytics cookies
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Anonymous traffic and performance measurement (e.g. page views,
|
||||
conversion funnels).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle('analytics_enabled')}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
|
||||
policy.analytics_enabled ? 'bg-emerald-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
policy.analytics_enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
When disabled, analytics tracking scripts should not be executed,
|
||||
regardless of user consent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-pink-500" />
|
||||
Marketing cookies
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Personalised offers, remarketing campaigns, and external ad
|
||||
networks.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle('marketing_enabled')}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
|
||||
policy.marketing_enabled ? 'bg-pink-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
policy.marketing_enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
When disabled, do not load any marketing pixels or share data with ad
|
||||
platforms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-indigo-500" />
|
||||
Preference cookies
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Remember guest choices like language, currency, and layout.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle('preferences_enabled')}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
|
||||
policy.preferences_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
policy.preferences_enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
When disabled, the application should avoid persisting non-essential
|
||||
preferences client-side.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integration IDs */}
|
||||
<div className="enterprise-card p-5 space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
Third-party integrations (IDs only)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Configure IDs for supported analytics and marketing platforms. The
|
||||
application will only load these when both the policy and user consent
|
||||
allow it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 md:items-end">
|
||||
{integrationMeta?.updated_at && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Last changed{' '}
|
||||
{new Date(integrationMeta.updated_at).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}{' '}
|
||||
{integrationMeta.updated_by ? `by ${integrationMeta.updated_by}` : ''}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveIntegrations}
|
||||
disabled={saving}
|
||||
className="btn-enterprise-secondary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{saving ? 'Saving IDs...' : 'Save integration IDs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-800">
|
||||
Google Analytics 4 Measurement ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={integrations.ga_measurement_id || ''}
|
||||
onChange={(e) =>
|
||||
setIntegrations((prev) => ({
|
||||
...prev,
|
||||
ga_measurement_id: e.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="G-XXXXXXXXXX"
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Example: <code className="font-mono">G-ABCDE12345</code>. This is used to
|
||||
load GA4 via gtag.js when analytics cookies are allowed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-800">
|
||||
Meta (Facebook) Pixel ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={integrations.fb_pixel_id || ''}
|
||||
onChange={(e) =>
|
||||
setIntegrations((prev) => ({
|
||||
...prev,
|
||||
fb_pixel_id: e.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="123456789012345"
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Numeric ID from your Meta Pixel. The application will only fire pixel
|
||||
events when marketing cookies are allowed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieSettingsPage;
|
||||
|
||||
|
||||
@@ -5,81 +5,108 @@ import {
|
||||
Hotel,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
TrendingUp
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { reportService, ReportData } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { formatCurrency, formatDate } from '../../utils/format';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
|
||||
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 response = await reportService.getReports({
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
const { data: stats, loading, error, execute } = useAsync<ReportData>(
|
||||
fetchDashboardData,
|
||||
{
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load dashboard data');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
execute();
|
||||
}, [dateRange]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
return <Loading fullScreen text="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EmptyState
|
||||
title="Unable to Load Dashboard"
|
||||
description={error?.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: handleRefresh
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Hotel operations overview</p>
|
||||
<h1 className="enterprise-section-title">Dashboard</h1>
|
||||
<p className="enterprise-section-subtitle mt-2">Hotel operations overview and analytics</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 className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
<span className="text-gray-500 font-medium">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="btn-enterprise-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</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="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
|
||||
@@ -91,15 +118,16 @@ const DashboardPage: React.FC = () => {
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Trend indicator - can be enhanced with actual comparison data */}
|
||||
<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>
|
||||
<span className="text-green-600 font-medium">Active</span>
|
||||
<span className="text-gray-500 ml-2">All time revenue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Bookings */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
|
||||
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
|
||||
@@ -112,14 +140,14 @@ const DashboardPage: React.FC = () => {
|
||||
</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>
|
||||
<span className="text-gray-500">
|
||||
{stats.total_bookings > 0 ? 'Total bookings recorded' : 'No bookings yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Rooms */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
|
||||
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
|
||||
@@ -139,7 +167,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Total Customers */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
|
||||
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Customers</p>
|
||||
@@ -152,9 +180,9 @@ const DashboardPage: React.FC = () => {
|
||||
</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>
|
||||
<span className="text-gray-500">
|
||||
Unique customers with bookings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,72 +190,87 @@ const DashboardPage: React.FC = () => {
|
||||
{/* 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 className="enterprise-card p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Daily Revenue</h2>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</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',
|
||||
};
|
||||
{stats.revenue_by_date.slice(0, 7).map((item, index) => {
|
||||
const maxRevenue = Math.max(...stats.revenue_by_date!.map(r => r.revenue));
|
||||
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 key={index} className="flex items-center">
|
||||
<span className="text-sm text-gray-600 w-24">
|
||||
{formatDate(item.date, 'short')}
|
||||
</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 / (maxRevenue || 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{count}</span>
|
||||
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
|
||||
{formatCurrency(item.revenue, 'VND')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
<EmptyState
|
||||
title="No Revenue Data"
|
||||
description="No revenue data available for the selected date range"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Booking Status</h2>
|
||||
</div>
|
||||
{stats?.bookings_by_status && Object.keys(stats.bookings_by_status).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(stats.bookings_by_status)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.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] || 'bg-gray-500'}`} />
|
||||
<span className="text-gray-700">{statusLabels[status] || status}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Booking Data"
|
||||
description="No booking status data available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,51 +278,57 @@ const DashboardPage: React.FC = () => {
|
||||
{/* 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>
|
||||
<div className="enterprise-card p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">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">
|
||||
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-blue-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-blue-200 hover:shadow-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl font-bold shadow-lg shadow-blue-500/30">
|
||||
{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>
|
||||
<p className="text-sm text-gray-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrency(room.revenue)}
|
||||
{formatCurrency(room.revenue, 'VND')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
<EmptyState
|
||||
title="No Room Data"
|
||||
description="No room booking data available for the selected date range"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">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 key={service.service_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-purple-200 hover:shadow-md">
|
||||
<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>
|
||||
<p className="text-sm text-gray-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
|
||||
</div>
|
||||
<span className="font-semibold text-purple-600">
|
||||
{formatCurrency(service.total_revenue)}
|
||||
{formatCurrency(service.total_revenue, 'VND')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
<EmptyState
|
||||
title="No Service Data"
|
||||
description="No service usage data available for the selected date range"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
376
Frontend/src/pages/admin/ReportsPage.tsx
Normal file
376
Frontend/src/pages/admin/ReportsPage.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Users,
|
||||
Hotel,
|
||||
TrendingUp,
|
||||
Download,
|
||||
Filter,
|
||||
BarChart3,
|
||||
PieChart
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { reportService, ReportData } from '../../services/api/reportService';
|
||||
import { formatCurrency, formatDate } from '../../utils/format';
|
||||
|
||||
const ReportsPage: React.FC = () => {
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: '',
|
||||
to: '',
|
||||
});
|
||||
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'monthly' | 'yearly' | ''>('');
|
||||
|
||||
const fetchReports = async (): Promise<ReportData> => {
|
||||
const params: any = {};
|
||||
if (dateRange.from) params.from = dateRange.from;
|
||||
if (dateRange.to) params.to = dateRange.to;
|
||||
if (reportType) params.type = reportType;
|
||||
|
||||
const response = await reportService.getReports(params);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const {
|
||||
data: reportData,
|
||||
loading,
|
||||
error,
|
||||
execute: refetchReports
|
||||
} = useAsync<ReportData>(fetchReports, {
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load reports');
|
||||
}
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (dateRange.from) params.from = dateRange.from;
|
||||
if (dateRange.to) params.to = dateRange.to;
|
||||
if (reportType) params.type = reportType;
|
||||
|
||||
const blob = await reportService.exportReport(params);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `report-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Report exported successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to export report');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilter = () => {
|
||||
refetchReports();
|
||||
};
|
||||
|
||||
if (loading && !reportData) {
|
||||
return <Loading fullScreen text="Loading reports..." />;
|
||||
}
|
||||
|
||||
if (error && !reportData) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<EmptyState
|
||||
title="Unable to Load Reports"
|
||||
description={error.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: refetchReports
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Reports & Analytics</h1>
|
||||
<p className="text-gray-600">View comprehensive reports and statistics</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
From Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
To Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Report Type
|
||||
</label>
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value as any)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleFilter}
|
||||
className="w-full px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reportData && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-blue-500">
|
||||
<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>
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Bookings
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{reportData.total_bookings || 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-green-500">
|
||||
<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>
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Revenue
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{formatCurrency(reportData.total_revenue || 0)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-purple-500">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Users className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Customers
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{reportData.total_customers || 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-orange-500">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<Hotel className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Available Rooms
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{reportData.available_rooms || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{reportData.occupied_rooms || 0} occupied
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
{reportData.bookings_by_status && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
<PieChart className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Bookings by Status</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{Object.entries(reportData.bookings_by_status).map(([status, count]) => (
|
||||
<div key={status} className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-gray-800">{count}</p>
|
||||
<p className="text-sm text-gray-600 capitalize mt-1">{status.replace('_', ' ')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue by Date */}
|
||||
{reportData.revenue_by_date && reportData.revenue_by_date.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
<BarChart3 className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Revenue by Date</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Bookings
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Revenue
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportData.revenue_by_date.map((item, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatDate(new Date(item.date), 'short')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.bookings}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatCurrency(item.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Rooms */}
|
||||
{reportData.top_rooms && reportData.top_rooms.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Top Performing Rooms</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Room Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Bookings
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Revenue
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportData.top_rooms.map((room) => (
|
||||
<tr key={room.room_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{room.room_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{room.bookings}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatCurrency(room.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Usage */}
|
||||
{reportData.service_usage && reportData.service_usage.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Service Usage</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<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">
|
||||
Usage Count
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Total Revenue
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportData.service_usage.map((service) => (
|
||||
<tr key={service.service_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{service.service_name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{service.usage_count}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatCurrency(service.total_revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
|
||||
@@ -8,3 +8,4 @@ export { default as ReviewManagementPage } from './ReviewManagementPage';
|
||||
export { default as PromotionManagementPage } from './PromotionManagementPage';
|
||||
export { default as CheckInPage } from './CheckInPage';
|
||||
export { default as CheckOutPage } from './CheckOutPage';
|
||||
export { default as AuditLogsPage } from './AuditLogsPage';
|
||||
|
||||
@@ -61,35 +61,42 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
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"
|
||||
from-gray-50 via-gray-100 to-gray-50
|
||||
flex items-center justify-center py-12 px-4
|
||||
sm:px-6 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Luxury background pattern */}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8 relative z-10">
|
||||
{/* 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 className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Login
|
||||
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Welcome back to Hotel Booking
|
||||
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
|
||||
Sign in to Luxury Hotel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<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"
|
||||
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
text-sm font-light"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -100,7 +107,7 @@ const LoginPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
@@ -115,18 +122,16 @@ const LoginPage: React.FC = () => {
|
||||
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'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -137,7 +142,7 @@ const LoginPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
@@ -152,34 +157,31 @@ const LoginPage: React.FC = () => {
|
||||
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'
|
||||
}`}
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5
|
||||
text-gray-400 hover:text-gray-600"
|
||||
text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -192,14 +194,14 @@ const LoginPage: React.FC = () => {
|
||||
{...register('rememberMe')}
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600
|
||||
focus:ring-blue-500 border-gray-300
|
||||
rounded cursor-pointer"
|
||||
className="h-4 w-4 text-[#d4af37]
|
||||
focus:ring-[#d4af37]/50 border-gray-300
|
||||
rounded-sm cursor-pointer accent-[#d4af37]"
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="ml-2 block text-sm
|
||||
text-gray-700 cursor-pointer"
|
||||
text-gray-700 cursor-pointer font-light tracking-wide"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
@@ -208,8 +210,8 @@ const LoginPage: React.FC = () => {
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm font-medium
|
||||
text-blue-600 hover:text-blue-500
|
||||
transition-colors"
|
||||
text-[#d4af37] hover:text-[#c9a227]
|
||||
transition-colors tracking-wide"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
@@ -219,27 +221,20 @@ const LoginPage: React.FC = () => {
|
||||
<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"
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-3 px-4 text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
mr-2 h-5 w-5 relative z-10"
|
||||
/>
|
||||
Processing...
|
||||
<span className="relative z-10">Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="-ml-1 mr-2 h-5 w-5" />
|
||||
Login
|
||||
<LogIn className="-ml-1 mr-2 h-5 w-5 relative z-10" />
|
||||
<span className="relative z-10">Sign In</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -247,12 +242,12 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 font-light tracking-wide">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600
|
||||
hover:text-blue-500 transition-colors"
|
||||
className="font-medium text-[#d4af37]
|
||||
hover:text-[#c9a227] transition-colors"
|
||||
>
|
||||
Register now
|
||||
</Link>
|
||||
@@ -261,19 +256,19 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
|
||||
<p>
|
||||
By logging in, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
|
||||
@@ -96,27 +96,33 @@ const RegisterPage: React.FC = () => {
|
||||
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"
|
||||
from-gray-50 via-gray-100 to-gray-50 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Luxury background pattern */}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8 relative z-10">
|
||||
{/* 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 className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
|
||||
Create Account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Create a new account to book hotel rooms
|
||||
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
|
||||
Join Luxury Hotel for exclusive benefits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-5"
|
||||
@@ -124,9 +130,9 @@ const RegisterPage: React.FC = () => {
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-lg
|
||||
text-sm"
|
||||
className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
text-sm font-light"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -137,7 +143,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
@@ -154,20 +160,16 @@ const RegisterPage: React.FC = () => {
|
||||
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'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.name
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -178,7 +180,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
@@ -195,20 +197,16 @@ const RegisterPage: React.FC = () => {
|
||||
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'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -219,7 +217,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
@@ -236,20 +234,16 @@ const RegisterPage: React.FC = () => {
|
||||
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'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.phone
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="0123456789"
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -260,7 +254,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
@@ -277,38 +271,33 @@ const RegisterPage: React.FC = () => {
|
||||
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'
|
||||
}`}
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -322,7 +311,13 @@ const RegisterPage: React.FC = () => {
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all
|
||||
duration-300 ${passwordStrength.color}`}
|
||||
duration-300 ${
|
||||
passwordStrength.strength >= 4
|
||||
? 'bg-[#d4af37]'
|
||||
: passwordStrength.strength >= 3
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{
|
||||
width: `${
|
||||
(passwordStrength.strength / 5) * 100
|
||||
@@ -331,7 +326,7 @@ const RegisterPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium
|
||||
text-gray-600"
|
||||
text-gray-600 tracking-wide"
|
||||
>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
@@ -369,7 +364,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
@@ -388,15 +383,11 @@ const RegisterPage: React.FC = () => {
|
||||
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'
|
||||
}`}
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
@@ -405,23 +396,22 @@ const RegisterPage: React.FC = () => {
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -431,28 +421,20 @@ const RegisterPage: React.FC = () => {
|
||||
<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"
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-3 px-4 text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
mr-2 h-5 w-5 relative z-10"
|
||||
/>
|
||||
Processing...
|
||||
<span className="relative z-10">Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Register
|
||||
<UserPlus className="-ml-1 mr-2 h-5 w-5 relative z-10" />
|
||||
<span className="relative z-10">Register</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -460,12 +442,12 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 font-light tracking-wide">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-purple-600
|
||||
hover:text-purple-500 transition-colors"
|
||||
className="font-medium text-[#d4af37]
|
||||
hover:text-[#c9a227] transition-colors"
|
||||
>
|
||||
Login now
|
||||
</Link>
|
||||
@@ -474,19 +456,19 @@ const RegisterPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
|
||||
<p>
|
||||
By registering, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-purple-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-purple-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
@@ -502,13 +484,13 @@ const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
}> = ({ met, text }) => (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 text-xs font-light">
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<CheckCircle2 className="h-4 w-4 text-[#d4af37]" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-gray-300" />
|
||||
)}
|
||||
<span className={met ? 'text-green-600' : 'text-gray-500'}>
|
||||
<span className={met ? 'text-[#c9a227] font-medium' : 'text-gray-500'}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,243 +4,250 @@ import {
|
||||
Hotel,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Activity
|
||||
Activity,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dashboardService, { CustomerDashboardStats } from '../../services/api/dashboardService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { formatCurrency, formatDate, formatRelativeTime } from '../../utils/format';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchDashboardData = async (): Promise<CustomerDashboardStats> => {
|
||||
const response = await dashboardService.getCustomerDashboardStats();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const { data: stats, loading, error, execute } = useAsync<CustomerDashboardStats>(
|
||||
fetchDashboardData,
|
||||
{
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load dashboard data');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<EmptyState
|
||||
title="Unable to Load Dashboard"
|
||||
description={error?.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: handleRefresh
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="mb-10 animate-fade-in">
|
||||
<h1 className="enterprise-section-title mb-2">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Overview of your activity
|
||||
<p className="enterprise-section-subtitle">
|
||||
Overview of your activity and bookings
|
||||
</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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
{/* Total Bookings */}
|
||||
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<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"
|
||||
/>
|
||||
<Calendar className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+12%
|
||||
</span>
|
||||
{stats.booking_change_percentage !== 0 && (
|
||||
<span className={`text-sm font-medium flex items-center gap-1 ${
|
||||
stats.booking_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stats.booking_change_percentage > 0 ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
{Math.abs(stats.booking_change_percentage).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Bookings
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
45
|
||||
{stats.total_bookings}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
{/* Total Spending */}
|
||||
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<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"
|
||||
/>
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+8%
|
||||
</span>
|
||||
{stats.spending_change_percentage !== 0 && (
|
||||
<span className={`text-sm font-medium flex items-center gap-1 ${
|
||||
stats.spending_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stats.spending_change_percentage > 0 ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
{Math.abs(stats.spending_change_percentage).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
<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)}
|
||||
{formatCurrency(stats.total_spending, 'VND')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
{/* Currently Staying */}
|
||||
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
||||
<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>
|
||||
{stats.currently_staying > 0 && (
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Currently Staying
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
2
|
||||
{stats.currently_staying}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
{/* Upcoming Bookings Count */}
|
||||
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
|
||||
<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"
|
||||
/>
|
||||
<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 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Upcoming Bookings
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
1,250
|
||||
{stats.upcoming_bookings.length}
|
||||
</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 & Upcoming Bookings */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activity */}
|
||||
<div className="enterprise-card p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
|
||||
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"
|
||||
{stats.recent_activity && stats.recent_activity.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{stats.recent_activity.map((activity, index) => (
|
||||
<div
|
||||
key={activity.booking_id || index}
|
||||
className="flex items-center space-x-4 pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/bookings/${activity.booking_id}`)}
|
||||
>
|
||||
<Activity className="w-5 h-5
|
||||
text-blue-600"
|
||||
/>
|
||||
<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?.room_number || activity.booking_number}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatRelativeTime(new Date(activity.created_at))}
|
||||
</span>
|
||||
</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>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Recent Activity"
|
||||
description="Your recent bookings and activities will appear here"
|
||||
action={{
|
||||
label: 'View All Bookings',
|
||||
onClick: () => navigate('/bookings')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<h2 className="text-xl font-semibold
|
||||
text-gray-800 mb-4"
|
||||
>
|
||||
{/* Upcoming Bookings */}
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
|
||||
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'
|
||||
}`}
|
||||
{stats.upcoming_bookings && stats.upcoming_bookings.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{stats.upcoming_bookings.map((booking) => (
|
||||
<div
|
||||
key={booking.id}
|
||||
className="flex items-center justify-between pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||
>
|
||||
{booking.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
Room {booking.room?.room_number || 'N/A'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatDate(booking.check_in_date, 'medium')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{formatCurrency(booking.total_price, 'VND')}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`enterprise-badge ${
|
||||
booking.status === 'confirmed'
|
||||
? 'bg-green-100 text-green-800 shadow-sm shadow-green-500/20'
|
||||
: booking.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800 shadow-sm shadow-yellow-500/20'
|
||||
: 'bg-gray-100 text-gray-800 shadow-sm'
|
||||
}`}>
|
||||
{booking.status.charAt(0).toUpperCase() + booking.status.slice(1).replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Upcoming Bookings"
|
||||
description="You don't have any upcoming bookings yet"
|
||||
action={{
|
||||
label: 'Browse Rooms',
|
||||
onClick: () => navigate('/rooms')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
543
Frontend/src/pages/customer/ProfilePage.tsx
Normal file
543
Frontend/src/pages/customer/ProfilePage.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Save,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Camera
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import authService from '../../services/api/authService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { useGlobalLoading } from '../../contexts/GlobalLoadingContext';
|
||||
|
||||
// Validation schema
|
||||
const profileValidationSchema = yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.required('Full name is required')
|
||||
.min(2, 'Full name must be at least 2 characters')
|
||||
.max(100, 'Full name cannot exceed 100 characters'),
|
||||
email: yup
|
||||
.string()
|
||||
.required('Email is required')
|
||||
.email('Invalid email address'),
|
||||
phone: yup
|
||||
.string()
|
||||
.required('Phone number is required')
|
||||
.matches(
|
||||
/^[0-9]{10,11}$/,
|
||||
'Phone number must have 10-11 digits'
|
||||
),
|
||||
});
|
||||
|
||||
const passwordValidationSchema = yup.object().shape({
|
||||
currentPassword: yup
|
||||
.string()
|
||||
.required('Current password is required'),
|
||||
newPassword: yup
|
||||
.string()
|
||||
.required('New password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required('Please confirm your password')
|
||||
.oneOf([yup.ref('newPassword')], 'Passwords must match'),
|
||||
});
|
||||
|
||||
type ProfileFormData = yup.InferType<typeof profileValidationSchema>;
|
||||
type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { userInfo, setUser } = useAuthStore();
|
||||
const { setLoading } = useGlobalLoading();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
|
||||
// Fetch profile data
|
||||
const fetchProfile = async () => {
|
||||
const response = await authService.getProfile();
|
||||
if (response.status === 'success' || response.success) {
|
||||
const user = response.data?.user || response.data;
|
||||
if (user) {
|
||||
setUser(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to load profile');
|
||||
};
|
||||
|
||||
const {
|
||||
data: profileData,
|
||||
loading: loadingProfile,
|
||||
error: profileError,
|
||||
execute: refetchProfile
|
||||
} = useAsync(fetchProfile, {
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load profile');
|
||||
}
|
||||
});
|
||||
|
||||
// Profile form
|
||||
const {
|
||||
register: registerProfile,
|
||||
handleSubmit: handleSubmitProfile,
|
||||
formState: { errors: profileErrors },
|
||||
reset: resetProfile,
|
||||
} = useForm<ProfileFormData>({
|
||||
resolver: yupResolver(profileValidationSchema),
|
||||
defaultValues: {
|
||||
name: userInfo?.name || '',
|
||||
email: userInfo?.email || '',
|
||||
phone: userInfo?.phone || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Password form
|
||||
const {
|
||||
register: registerPassword,
|
||||
handleSubmit: handleSubmitPassword,
|
||||
formState: { errors: passwordErrors },
|
||||
reset: resetPassword,
|
||||
} = useForm<PasswordFormData>({
|
||||
resolver: yupResolver(passwordValidationSchema),
|
||||
});
|
||||
|
||||
// Update form when profile data loads
|
||||
useEffect(() => {
|
||||
if (profileData || userInfo) {
|
||||
const data = profileData || userInfo;
|
||||
resetProfile({
|
||||
name: data?.name || '',
|
||||
email: data?.email || '',
|
||||
phone: data?.phone || '',
|
||||
});
|
||||
if (data?.avatar) {
|
||||
setAvatarPreview(data.avatar);
|
||||
}
|
||||
}
|
||||
}, [profileData, userInfo, resetProfile]);
|
||||
|
||||
// Handle profile update
|
||||
const onSubmitProfile = async (data: ProfileFormData) => {
|
||||
try {
|
||||
setLoading(true, 'Updating profile...');
|
||||
|
||||
// Check if updateProfile exists in authService, otherwise use userService
|
||||
if ('updateProfile' in authService) {
|
||||
const response = await (authService as any).updateProfile({
|
||||
full_name: data.name,
|
||||
email: data.email,
|
||||
phone_number: data.phone,
|
||||
});
|
||||
|
||||
if (response.status === 'success' || response.success) {
|
||||
const updatedUser = response.data?.user || response.data;
|
||||
if (updatedUser) {
|
||||
setUser(updatedUser);
|
||||
toast.success('Profile updated successfully!');
|
||||
refetchProfile();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use userService if updateProfile doesn't exist
|
||||
const { updateUser } = await import('../../services/api/userService');
|
||||
const response = await updateUser(userInfo!.id, {
|
||||
full_name: data.name,
|
||||
email: data.email,
|
||||
phone_number: data.phone,
|
||||
});
|
||||
|
||||
if (response.success || response.status === 'success') {
|
||||
const updatedUser = response.data?.user || response.data;
|
||||
if (updatedUser) {
|
||||
setUser({
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.full_name || updatedUser.name,
|
||||
email: updatedUser.email,
|
||||
phone: updatedUser.phone_number || updatedUser.phone,
|
||||
avatar: updatedUser.avatar,
|
||||
role: updatedUser.role,
|
||||
});
|
||||
toast.success('Profile updated successfully!');
|
||||
refetchProfile();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'Failed to update profile';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle password change
|
||||
const onSubmitPassword = async (data: PasswordFormData) => {
|
||||
try {
|
||||
setLoading(true, 'Changing password...');
|
||||
|
||||
// Use updateProfile with password fields if available
|
||||
if ('updateProfile' in authService) {
|
||||
const response = await (authService as any).updateProfile({
|
||||
currentPassword: data.currentPassword,
|
||||
password: data.newPassword,
|
||||
});
|
||||
|
||||
if (response.status === 'success' || response.success) {
|
||||
toast.success('Password changed successfully!');
|
||||
resetPassword();
|
||||
}
|
||||
} else {
|
||||
// Fallback: use userService
|
||||
const { updateUser } = await import('../../services/api/userService');
|
||||
const response = await updateUser(userInfo!.id, {
|
||||
password: data.newPassword,
|
||||
});
|
||||
|
||||
if (response.success || response.status === 'success') {
|
||||
toast.success('Password changed successfully!');
|
||||
resetPassword();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'Failed to change password';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle avatar upload (placeholder - would need backend support)
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Image size must be less than 2MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// TODO: Upload to backend
|
||||
toast.info('Avatar upload feature coming soon');
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingProfile) {
|
||||
return <Loading fullScreen text="Loading profile..." />;
|
||||
}
|
||||
|
||||
if (profileError && !userInfo) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<EmptyState
|
||||
title="Unable to Load Profile"
|
||||
description={profileError.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: refetchProfile
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<h1 className="enterprise-section-title mb-2">
|
||||
Profile Settings
|
||||
</h1>
|
||||
<p className="enterprise-section-subtitle">
|
||||
Manage your account information and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<div className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'profile'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Profile Information
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'password'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="enterprise-card animate-slide-up">
|
||||
<form onSubmit={handleSubmitProfile(onSubmitProfile)} className="space-y-6">
|
||||
{/* Avatar Section */}
|
||||
<div className="flex items-center space-x-6 pb-6 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
{avatarPreview || userInfo?.avatar ? (
|
||||
<img
|
||||
src={avatarPreview || userInfo?.avatar}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover ring-4 ring-[#d4af37]/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center ring-4 ring-[#d4af37]/20">
|
||||
<User className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="absolute bottom-0 right-0 p-2 bg-[#d4af37] rounded-full cursor-pointer hover:bg-[#c9a227] transition-colors shadow-lg"
|
||||
>
|
||||
<Camera className="w-4 h-4 text-white" />
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{userInfo?.name || 'User'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{userInfo?.email}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{userInfo?.role?.charAt(0).toUpperCase() + userInfo?.role?.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<User className="w-4 h-4 inline mr-2" />
|
||||
Full Name
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerProfile('name')}
|
||||
type="text"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
profileErrors.name ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
{profileErrors.name && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{profileErrors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Mail className="w-4 h-4 inline mr-2" />
|
||||
Email Address
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerProfile('email')}
|
||||
type="email"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
profileErrors.email ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
{profileErrors.email && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{profileErrors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Phone className="w-4 h-4 inline mr-2" />
|
||||
Phone Number
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerProfile('phone')}
|
||||
type="tel"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
profileErrors.phone ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your phone number"
|
||||
/>
|
||||
{profileErrors.phone && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{profileErrors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Tab */}
|
||||
{activeTab === 'password' && (
|
||||
<div className="enterprise-card animate-slide-up">
|
||||
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-6">
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">
|
||||
Password Requirements
|
||||
</h4>
|
||||
<ul className="text-xs text-blue-700 space-y-1">
|
||||
<li>• At least 6 characters long</li>
|
||||
<li>• Use a combination of letters and numbers for better security</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 inline mr-2" />
|
||||
Current Password
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerPassword('currentPassword')}
|
||||
type="password"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
passwordErrors.currentPassword ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your current password"
|
||||
/>
|
||||
{passwordErrors.currentPassword && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordErrors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 inline mr-2" />
|
||||
New Password
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerPassword('newPassword')}
|
||||
type="password"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
passwordErrors.newPassword ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
{passwordErrors.newPassword && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordErrors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 inline mr-2" />
|
||||
Confirm New Password
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerPassword('confirmPassword')}
|
||||
type="password"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
passwordErrors.confirmPassword ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Confirm your new password"
|
||||
/>
|
||||
{passwordErrors.confirmPassword && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordErrors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>Change Password</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
|
||||
@@ -66,23 +66,24 @@ const RoomListPage: React.FC = () => {
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50/30 to-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"
|
||||
className="inline-flex items-center gap-2 btn-enterprise-secondary mb-8 animate-fade-in"
|
||||
>
|
||||
<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">
|
||||
<div className="mb-10 text-center animate-fade-in">
|
||||
<h1 className="enterprise-section-title">
|
||||
Room List
|
||||
</h1>
|
||||
<p className="enterprise-section-subtitle mt-2">
|
||||
Browse our available accommodations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
@@ -102,29 +103,34 @@ const RoomListPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="bg-red-50 border border-red-200
|
||||
rounded-lg p-6 text-center"
|
||||
<div className="enterprise-card p-8 text-center animate-fade-in
|
||||
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
|
||||
>
|
||||
<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>
|
||||
<div className="inline-flex items-center justify-center w-16 h-16
|
||||
bg-red-100 rounded-full mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-600"
|
||||
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>
|
||||
</div>
|
||||
<p className="text-red-800 font-semibold text-lg mb-2">{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"
|
||||
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
|
||||
text-white rounded-lg font-semibold
|
||||
hover:from-red-700 hover:to-red-800
|
||||
transition-all duration-300 shadow-lg shadow-red-500/30
|
||||
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
@@ -132,26 +138,28 @@ const RoomListPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-12 text-center"
|
||||
<div className="enterprise-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<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"
|
||||
<div className="inline-flex items-center justify-center w-24 h-24
|
||||
bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-400"
|
||||
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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold
|
||||
text-gray-900 mb-2"
|
||||
>
|
||||
No matching rooms found
|
||||
</h3>
|
||||
@@ -160,8 +168,7 @@ const RoomListPage: React.FC = () => {
|
||||
</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"
|
||||
className="btn-enterprise-primary"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
|
||||
63
Frontend/src/services/api/adminPrivacyService.ts
Normal file
63
Frontend/src/services/api/adminPrivacyService.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import apiClient from './apiClient';
|
||||
|
||||
export type CookiePolicySettings = {
|
||||
analytics_enabled: boolean;
|
||||
marketing_enabled: boolean;
|
||||
preferences_enabled: boolean;
|
||||
};
|
||||
|
||||
export type CookiePolicySettingsResponse = {
|
||||
status: string;
|
||||
data: CookiePolicySettings;
|
||||
updated_at?: string;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
|
||||
export type CookieIntegrationSettings = {
|
||||
ga_measurement_id?: string | null;
|
||||
fb_pixel_id?: string | null;
|
||||
};
|
||||
|
||||
export type CookieIntegrationSettingsResponse = {
|
||||
status: string;
|
||||
data: CookieIntegrationSettings;
|
||||
updated_at?: string;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
|
||||
const adminPrivacyService = {
|
||||
getCookiePolicy: async (): Promise<CookiePolicySettingsResponse> => {
|
||||
const res = await apiClient.get<CookiePolicySettingsResponse>(
|
||||
'/admin/privacy/cookie-policy'
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
updateCookiePolicy: async (
|
||||
payload: CookiePolicySettings
|
||||
): Promise<CookiePolicySettingsResponse> => {
|
||||
const res = await apiClient.put<CookiePolicySettingsResponse>(
|
||||
'/admin/privacy/cookie-policy',
|
||||
payload
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
getIntegrations: async (): Promise<CookieIntegrationSettingsResponse> => {
|
||||
const res = await apiClient.get<CookieIntegrationSettingsResponse>(
|
||||
'/admin/privacy/integrations'
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
updateIntegrations: async (
|
||||
payload: CookieIntegrationSettings
|
||||
): Promise<CookieIntegrationSettingsResponse> => {
|
||||
const res = await apiClient.put<CookieIntegrationSettingsResponse>(
|
||||
'/admin/privacy/integrations',
|
||||
payload
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default adminPrivacyService;
|
||||
|
||||
|
||||
@@ -1,85 +1,212 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } 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'.
|
||||
// Base URL from environment or default
|
||||
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
|
||||
// Retry configuration
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000; // 1 second
|
||||
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];
|
||||
|
||||
// Create axios instance with enhanced configuration
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
timeout: 30000, // 30 seconds timeout
|
||||
withCredentials: true, // Enable sending cookies
|
||||
});
|
||||
|
||||
// Request interceptor - Add token to header
|
||||
// Retry logic helper
|
||||
const retryRequest = async (
|
||||
error: AxiosError,
|
||||
retryCount: number = 0
|
||||
): Promise<any> => {
|
||||
const config = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Don't retry if already retried or not a retryable error
|
||||
if (
|
||||
config._retry ||
|
||||
retryCount >= MAX_RETRIES ||
|
||||
!error.config ||
|
||||
!error.response ||
|
||||
!RETRYABLE_STATUS_CODES.includes(error.response.status)
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
config._retry = true;
|
||||
|
||||
// Exponential backoff
|
||||
const delay = RETRY_DELAY * Math.pow(2, retryCount);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
console.log(`Retrying request (${retryCount + 1}/${MAX_RETRIES}): ${config.url}`);
|
||||
|
||||
return apiClient.request(config);
|
||||
};
|
||||
|
||||
// Request interceptor - Add token and request ID
|
||||
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.
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Normalize request URL
|
||||
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(/\/\/+/, '/');
|
||||
}
|
||||
|
||||
// Add authorization token
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Generate and add request ID for tracking
|
||||
const requestId = crypto.randomUUID ? crypto.randomUUID() :
|
||||
`${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
config.headers['X-Request-ID'] = requestId;
|
||||
|
||||
// Add timestamp for request tracking
|
||||
(config as any).metadata = { startTime: new Date() };
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor - Handle common errors
|
||||
// Response interceptor - Handle errors with retry logic
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle network errors
|
||||
(response) => {
|
||||
// Log request duration
|
||||
const config = response.config as InternalAxiosRequestConfig & { metadata?: any };
|
||||
if (config.metadata?.startTime) {
|
||||
const duration = new Date().getTime() - config.metadata.startTime.getTime();
|
||||
if (duration > 1000) { // Log slow requests (>1s)
|
||||
console.warn(`Slow request detected: ${config.url} took ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract request ID from response headers for debugging
|
||||
const requestId = response.headers['x-request-id'];
|
||||
if (requestId && import.meta.env.DEV) {
|
||||
console.debug(`Request completed: ${config.url} [${requestId}]`);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle network errors (no response)
|
||||
if (!error.response) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: 'Request timeout. Please check your connection and try again.',
|
||||
});
|
||||
}
|
||||
|
||||
// Retry network errors
|
||||
if (originalRequest && !originalRequest._retry) {
|
||||
return retryRequest(error);
|
||||
}
|
||||
|
||||
console.error('Network error:', error);
|
||||
// You can show a toast notification here
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: 'Network error. Please check ' +
|
||||
'your internet connection.',
|
||||
message: 'Network error. Please check your internet connection.',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid
|
||||
const status = error.response.status;
|
||||
const requestId = error.response.headers['x-request-id'];
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// Handle other HTTP errors
|
||||
if (error.response?.status >= 500) {
|
||||
console.error('Server error:', error);
|
||||
|
||||
// Don't redirect if already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
const errorMessage = (error.response?.data as any)?.message || 'Session expired. Please login again.';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: 'Server error. Please try again later.',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
// Handle 403 Forbidden
|
||||
if (status === 403) {
|
||||
const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle 404 Not Found
|
||||
if (status === 404) {
|
||||
const errorMessage = (error.response?.data as any)?.message || 'Resource not found.';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle 429 Too Many Requests (rate limiting)
|
||||
if (status === 429) {
|
||||
const retryAfter = error.response.headers['retry-after'];
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: `Too many requests. ${retryAfter ? `Please try again after ${retryAfter} seconds.` : 'Please try again later.'}`,
|
||||
retryAfter: retryAfter ? parseInt(retryAfter) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle 5xx server errors with retry
|
||||
if (status >= 500 && status < 600) {
|
||||
if (originalRequest && !originalRequest._retry) {
|
||||
return retryRequest(error);
|
||||
}
|
||||
|
||||
console.error(`Server error [${requestId || 'unknown'}]:`, error);
|
||||
const errorMessage = (error.response?.data as any)?.message || 'Server error. Please try again later.';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle validation errors (400)
|
||||
if (status === 400) {
|
||||
const errorData = error.response.data as any;
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorData?.message || errorData?.errors?.[0]?.message || 'Invalid request. Please check your input.',
|
||||
errors: errorData?.errors || [],
|
||||
});
|
||||
}
|
||||
|
||||
// For other errors, extract message from response
|
||||
const errorMessage = (error.response.data as any)?.message || error.message || 'An error occurred';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
116
Frontend/src/services/api/auditService.ts
Normal file
116
Frontend/src/services/api/auditService.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import apiClient from './apiClient';
|
||||
|
||||
/**
|
||||
* Audit Log API Service
|
||||
*/
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id?: number;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
request_id?: string;
|
||||
details?: any;
|
||||
status: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
user?: {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditLogListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
logs: AuditLog[];
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditLogStatsResponse {
|
||||
status: string;
|
||||
data: {
|
||||
total: number;
|
||||
by_status: {
|
||||
success: number;
|
||||
failed: number;
|
||||
error: number;
|
||||
};
|
||||
top_actions: Array<{
|
||||
action: string;
|
||||
count: number;
|
||||
}>;
|
||||
top_resource_types: Array<{
|
||||
resource_type: string;
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
action?: string;
|
||||
resource_type?: string;
|
||||
user_id?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class AuditService {
|
||||
/**
|
||||
* Get audit logs with filters
|
||||
*/
|
||||
async getAuditLogs(filters: AuditLogFilters = {}): Promise<AuditLogListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.action) params.append('action', filters.action);
|
||||
if (filters.resource_type) params.append('resource_type', filters.resource_type);
|
||||
if (filters.user_id) params.append('user_id', filters.user_id.toString());
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters.page) params.append('page', filters.page.toString());
|
||||
if (filters.limit) params.append('limit', filters.limit.toString());
|
||||
|
||||
const response = await apiClient.get(`/audit-logs/?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log by ID
|
||||
*/
|
||||
async getAuditLogById(id: number): Promise<{ status: string; data: { log: AuditLog } }> {
|
||||
const response = await apiClient.get(`/audit-logs/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log statistics
|
||||
*/
|
||||
async getAuditStats(startDate?: string, endDate?: string): Promise<AuditLogStatsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const response = await apiClient.get(`/audit-logs/stats?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const auditService = new AuditService();
|
||||
export default auditService;
|
||||
|
||||
@@ -132,6 +132,25 @@ const authService = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update profile
|
||||
*/
|
||||
updateProfile: async (
|
||||
data: {
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
phone_number?: string;
|
||||
password?: string;
|
||||
currentPassword?: string;
|
||||
}
|
||||
): Promise<AuthResponse> => {
|
||||
const response = await apiClient.put<AuthResponse>(
|
||||
'/api/auth/profile',
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default authService;
|
||||
|
||||
@@ -2,6 +2,9 @@ import apiClient from './apiClient';
|
||||
|
||||
/**
|
||||
* Banner API Service
|
||||
*
|
||||
* NOTE: To avoid hammering the API, we add a very lightweight
|
||||
* in-memory + optional localStorage cache for read endpoints.
|
||||
*/
|
||||
|
||||
export interface Banner {
|
||||
@@ -27,28 +30,211 @@ export interface BannerListResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ---------- Simple client-side cache ----------
|
||||
|
||||
type CacheEntry = {
|
||||
timestamp: number;
|
||||
response: BannerListResponse;
|
||||
};
|
||||
|
||||
const BANNER_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const memoryCache = new Map<string, CacheEntry>();
|
||||
|
||||
const getCacheKey = (position?: string) =>
|
||||
position ? `banners:position:${position}` : 'banners:active';
|
||||
|
||||
const isCacheValid = (entry: CacheEntry | undefined) => {
|
||||
if (!entry) return false;
|
||||
return Date.now() - entry.timestamp < BANNER_CACHE_TTL_MS;
|
||||
};
|
||||
|
||||
const loadFromStorage = (key: string): CacheEntry | undefined => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return undefined;
|
||||
const parsed = JSON.parse(raw) as CacheEntry;
|
||||
return isCacheValid(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const saveToStorage = (key: string, entry: CacheEntry) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(entry));
|
||||
} catch {
|
||||
// ignore storage errors (quota, disabled, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
const getCachedOrFetch = async (
|
||||
key: string,
|
||||
fetcher: () => Promise<BannerListResponse>
|
||||
): Promise<BannerListResponse> => {
|
||||
// 1) Check in-memory cache
|
||||
const inMemory = memoryCache.get(key);
|
||||
if (isCacheValid(inMemory)) {
|
||||
return inMemory!.response;
|
||||
}
|
||||
|
||||
// 2) Check localStorage cache (fills memory cache if valid)
|
||||
const fromStorage = loadFromStorage(key);
|
||||
if (fromStorage) {
|
||||
memoryCache.set(key, fromStorage);
|
||||
return fromStorage.response;
|
||||
}
|
||||
|
||||
// 3) Fetch from API and cache result
|
||||
const response = await fetcher();
|
||||
const entry: CacheEntry = {
|
||||
timestamp: Date.now(),
|
||||
response,
|
||||
};
|
||||
memoryCache.set(key, entry);
|
||||
saveToStorage(key, entry);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get banners by position
|
||||
*
|
||||
* Cached per-position on the client for a short TTL.
|
||||
*/
|
||||
export const getBannersByPosition = async (
|
||||
position: string = 'home'
|
||||
): Promise<BannerListResponse> => {
|
||||
const response = await apiClient.get('/banners', {
|
||||
params: { position },
|
||||
const key = getCacheKey(position);
|
||||
return getCachedOrFetch(key, async () => {
|
||||
const response = await apiClient.get('/banners', {
|
||||
params: { position },
|
||||
});
|
||||
return response.data;
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all active banners
|
||||
*
|
||||
* Cached on the client for a short TTL.
|
||||
*/
|
||||
export const getActiveBanners = async ():
|
||||
export const getActiveBanners = async ():
|
||||
Promise<BannerListResponse> => {
|
||||
const response = await apiClient.get('/banners');
|
||||
const key = getCacheKey();
|
||||
return getCachedOrFetch(key, async () => {
|
||||
const response = await apiClient.get('/banners');
|
||||
return response.data;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all banners (admin)
|
||||
*/
|
||||
export const getAllBanners = async (
|
||||
params?: { position?: string; page?: number; limit?: number }
|
||||
): Promise<BannerListResponse> => {
|
||||
const response = await apiClient.get('/banners', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Get banner by ID
|
||||
*/
|
||||
export const getBannerById = async (
|
||||
id: number
|
||||
): Promise<{ success: boolean; data: { banner: Banner } }> => {
|
||||
const response = await apiClient.get(`/banners/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create banner
|
||||
*/
|
||||
export const createBanner = async (
|
||||
data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
image_url: string;
|
||||
link?: string;
|
||||
position?: string;
|
||||
display_order?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data: { banner: Banner }; message: string }> => {
|
||||
const response = await apiClient.post('/banners', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update banner
|
||||
*/
|
||||
export const updateBanner = async (
|
||||
id: number,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image_url?: string;
|
||||
link?: string;
|
||||
position?: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data: { banner: Banner }; message: string }> => {
|
||||
const response = await apiClient.put(`/banners/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete banner
|
||||
*/
|
||||
export const deleteBanner = async (
|
||||
id: number
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.delete(`/banners/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload banner image
|
||||
*/
|
||||
export const uploadBannerImage = async (
|
||||
file: File
|
||||
): Promise<{ success: boolean; data: { image_url: string; full_url: string }; message: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await apiClient.post('/banners/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface BannerService {
|
||||
getBannersByPosition: typeof getBannersByPosition;
|
||||
getActiveBanners: typeof getActiveBanners;
|
||||
getAllBanners: typeof getAllBanners;
|
||||
getBannerById: typeof getBannerById;
|
||||
createBanner: typeof createBanner;
|
||||
updateBanner: typeof updateBanner;
|
||||
deleteBanner: typeof deleteBanner;
|
||||
uploadBannerImage: typeof uploadBannerImage;
|
||||
}
|
||||
|
||||
const bannerService: BannerService = {
|
||||
getBannersByPosition,
|
||||
getActiveBanners,
|
||||
getAllBanners,
|
||||
getBannerById,
|
||||
createBanner,
|
||||
updateBanner,
|
||||
deleteBanner,
|
||||
uploadBannerImage,
|
||||
};
|
||||
|
||||
export default bannerService as BannerService;
|
||||
|
||||
57
Frontend/src/services/api/dashboardService.ts
Normal file
57
Frontend/src/services/api/dashboardService.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import apiClient from './apiClient';
|
||||
|
||||
/**
|
||||
* Customer Dashboard API Service
|
||||
*/
|
||||
|
||||
export interface CustomerDashboardStats {
|
||||
total_bookings: number;
|
||||
total_spending: number;
|
||||
currently_staying: number;
|
||||
upcoming_bookings: Array<{
|
||||
id: number;
|
||||
booking_number: string;
|
||||
check_in_date: string;
|
||||
check_out_date: string;
|
||||
status: string;
|
||||
total_price: number;
|
||||
room?: {
|
||||
id: number;
|
||||
room_number: string;
|
||||
room_type: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
recent_activity: Array<{
|
||||
action: string;
|
||||
booking_id: number;
|
||||
booking_number: string;
|
||||
created_at: string;
|
||||
room?: {
|
||||
room_number: string;
|
||||
};
|
||||
}>;
|
||||
booking_change_percentage: number;
|
||||
spending_change_percentage: number;
|
||||
}
|
||||
|
||||
export interface CustomerDashboardResponse {
|
||||
success: boolean;
|
||||
status?: string;
|
||||
data: CustomerDashboardStats;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer dashboard statistics
|
||||
*/
|
||||
export const getCustomerDashboardStats = async (): Promise<CustomerDashboardResponse> => {
|
||||
const response = await apiClient.get('/reports/customer/dashboard');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export default {
|
||||
getCustomerDashboardStats,
|
||||
};
|
||||
|
||||
@@ -30,4 +30,8 @@ export { default as promotionService } from './promotionService';
|
||||
export type * from './promotionService';
|
||||
|
||||
export { default as reportService } from './reportService';
|
||||
export { default as dashboardService } from './dashboardService';
|
||||
export { default as auditService } from './auditService';
|
||||
export type { CustomerDashboardStats, CustomerDashboardResponse } from './dashboardService';
|
||||
export type * from './reportService';
|
||||
export type * from './auditService';
|
||||
|
||||
84
Frontend/src/services/api/privacyService.ts
Normal file
84
Frontend/src/services/api/privacyService.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import apiClient from './apiClient';
|
||||
|
||||
export type CookieCategoryPreferences = {
|
||||
necessary: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
preferences: boolean;
|
||||
};
|
||||
|
||||
export type CookieConsent = {
|
||||
version: number;
|
||||
updated_at: string;
|
||||
has_decided: boolean;
|
||||
categories: CookieCategoryPreferences;
|
||||
};
|
||||
|
||||
export type CookieConsentResponse = {
|
||||
status: string;
|
||||
data: CookieConsent;
|
||||
};
|
||||
|
||||
export type UpdateCookieConsentRequest = {
|
||||
analytics?: boolean;
|
||||
marketing?: boolean;
|
||||
preferences?: boolean;
|
||||
};
|
||||
|
||||
export type PublicPrivacyConfig = {
|
||||
policy: {
|
||||
analytics_enabled: boolean;
|
||||
marketing_enabled: boolean;
|
||||
preferences_enabled: boolean;
|
||||
};
|
||||
integrations: {
|
||||
ga_measurement_id?: string | null;
|
||||
fb_pixel_id?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type PublicPrivacyConfigResponse = {
|
||||
status: string;
|
||||
data: PublicPrivacyConfig;
|
||||
};
|
||||
|
||||
const privacyService = {
|
||||
/**
|
||||
* Fetch current cookie consent from the backend.
|
||||
*/
|
||||
getCookieConsent: async (): Promise<CookieConsent> => {
|
||||
const response = await apiClient.get<CookieConsentResponse>(
|
||||
'/privacy/cookie-consent'
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch public privacy configuration:
|
||||
* - global policy flags
|
||||
* - integration IDs (e.g. GA4, Meta Pixel)
|
||||
*/
|
||||
getPublicConfig: async (): Promise<PublicPrivacyConfig> => {
|
||||
const response = await apiClient.get<PublicPrivacyConfigResponse>(
|
||||
'/privacy/config'
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update cookie consent preferences on the backend.
|
||||
*/
|
||||
updateCookieConsent: async (
|
||||
payload: UpdateCookieConsentRequest
|
||||
): Promise<CookieConsent> => {
|
||||
const response = await apiClient.post<CookieConsentResponse>(
|
||||
'/privacy/cookie-consent',
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default privacyService;
|
||||
|
||||
|
||||
@@ -2,33 +2,81 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
/* Luxury Hotel Design System - Custom Properties */
|
||||
:root {
|
||||
/* Luxury Gold Palette */
|
||||
--luxury-gold: #d4af37;
|
||||
--luxury-gold-light: #f5d76e;
|
||||
--luxury-gold-dark: #c9a227;
|
||||
--luxury-gold-accent: #e8c547;
|
||||
|
||||
/* Luxury Dark Palette */
|
||||
--luxury-black: #0f0f0f;
|
||||
--luxury-black-light: #1a1a1a;
|
||||
--luxury-black-medium: #2a2a2a;
|
||||
--luxury-gray-dark: #3a3a3a;
|
||||
|
||||
/* Luxury Grays */
|
||||
--luxury-gray-50: #fafafa;
|
||||
--luxury-gray-100: #f5f5f5;
|
||||
--luxury-gray-200: #e5e5e5;
|
||||
--luxury-gray-300: #d4d4d4;
|
||||
--luxury-gray-400: #a3a3a3;
|
||||
--luxury-gray-500: #737373;
|
||||
--luxury-gray-600: #525252;
|
||||
--luxury-gray-700: #404040;
|
||||
--luxury-gray-800: #262626;
|
||||
--luxury-gray-900: #171717;
|
||||
|
||||
/* Luxury Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
--shadow-luxury: 0 4px 20px rgba(212, 175, 55, 0.15);
|
||||
--shadow-luxury-gold: 0 8px 30px rgba(212, 175, 55, 0.25);
|
||||
|
||||
/* Luxury Gradients */
|
||||
--gradient-gold: linear-gradient(135deg, #d4af37 0%, #f5d76e 100%);
|
||||
--gradient-gold-dark: linear-gradient(135deg, #c9a227 0%, #d4af37 100%);
|
||||
--gradient-dark: linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%);
|
||||
--gradient-overlay: linear-gradient(180deg, rgba(15, 15, 15, 0.8) 0%, rgba(15, 15, 15, 0.4) 100%);
|
||||
--gradient-gold-overlay: linear-gradient(135deg, rgba(212, 175, 55, 0.1) 0%, rgba(245, 215, 110, 0.05) 100%);
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles - Luxury Hotel */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--luxury-black-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, var(--luxury-gold) 0%, var(--luxury-gold-dark) 100%);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--luxury-black-light);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
background: linear-gradient(180deg, var(--luxury-gold-light) 0%, var(--luxury-gold) 100%);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
/* Base styles - Luxury Hotel Typography */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
font-family: 'Inter', -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;
|
||||
background: #fafafa;
|
||||
color: var(--luxury-gray-900);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -36,15 +84,174 @@ code {
|
||||
'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Luxury Hotel Components */
|
||||
@layer components {
|
||||
/* Luxury Card */
|
||||
.luxury-card {
|
||||
@apply bg-white rounded-sm shadow-lg border border-gray-100;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:shadow-xl hover:shadow-[#d4af37]/10;
|
||||
@apply hover:-translate-y-1;
|
||||
}
|
||||
|
||||
/* Luxury Card with Gold Border */
|
||||
.luxury-card-gold {
|
||||
@apply bg-white rounded-sm shadow-lg;
|
||||
@apply relative overflow-hidden;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:shadow-xl hover:shadow-[#d4af37]/20;
|
||||
@apply border-t-2 border-[#d4af37];
|
||||
}
|
||||
|
||||
/* Luxury Dark Card */
|
||||
.luxury-card-dark {
|
||||
@apply bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] rounded-sm shadow-2xl;
|
||||
@apply border border-[#d4af37]/20;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:shadow-[#d4af37]/30 hover:border-[#d4af37]/40;
|
||||
}
|
||||
|
||||
/* Luxury Button Primary */
|
||||
.btn-luxury-primary {
|
||||
@apply px-6 py-3 rounded-sm font-medium tracking-wide;
|
||||
@apply bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f];
|
||||
@apply shadow-lg shadow-[#d4af37]/30;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:from-[#f5d76e] hover:to-[#d4af37];
|
||||
@apply hover:shadow-xl hover:shadow-[#d4af37]/40;
|
||||
@apply hover:-translate-y-0.5;
|
||||
@apply active:translate-y-0;
|
||||
@apply disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0;
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.btn-luxury-primary::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0;
|
||||
@apply translate-x-[-100%] transition-transform duration-700;
|
||||
}
|
||||
|
||||
.btn-luxury-primary:hover::before {
|
||||
@apply translate-x-[100%];
|
||||
}
|
||||
|
||||
/* Luxury Button Secondary */
|
||||
.btn-luxury-secondary {
|
||||
@apply px-6 py-3 rounded-sm font-medium tracking-wide;
|
||||
@apply bg-white/10 backdrop-blur-sm text-white border border-[#d4af37]/30;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:bg-[#d4af37]/10 hover:border-[#d4af37] hover:text-[#d4af37];
|
||||
@apply hover:-translate-y-0.5;
|
||||
@apply active:translate-y-0;
|
||||
}
|
||||
|
||||
/* Luxury Section Header */
|
||||
.luxury-section-header {
|
||||
@apply mb-8 pb-4 border-b border-[#d4af37]/20;
|
||||
}
|
||||
|
||||
.luxury-section-title {
|
||||
@apply text-3xl md:text-4xl font-serif font-semibold;
|
||||
@apply text-gray-900 tracking-tight;
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.luxury-section-subtitle {
|
||||
@apply text-gray-600 text-lg font-light;
|
||||
@apply tracking-wide;
|
||||
}
|
||||
|
||||
/* Luxury Stat Card */
|
||||
.luxury-stat-card {
|
||||
@apply luxury-card p-6;
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.luxury-stat-card::before {
|
||||
content: '';
|
||||
@apply absolute top-0 left-0 w-1 h-full;
|
||||
background: var(--gradient-gold);
|
||||
}
|
||||
|
||||
/* Luxury Table */
|
||||
.luxury-table {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.luxury-table thead {
|
||||
@apply bg-gradient-to-r from-gray-50 to-gray-100;
|
||||
@apply border-b-2 border-[#d4af37]/30;
|
||||
}
|
||||
|
||||
.luxury-table th {
|
||||
@apply px-6 py-4 text-left text-xs font-semibold;
|
||||
@apply text-gray-700 uppercase tracking-wider;
|
||||
@apply border-b-2 border-gray-200;
|
||||
}
|
||||
|
||||
.luxury-table td {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
||||
@apply border-b border-gray-100;
|
||||
}
|
||||
|
||||
.luxury-table tbody tr {
|
||||
@apply transition-colors duration-150;
|
||||
@apply hover:bg-[#d4af37]/5;
|
||||
}
|
||||
|
||||
/* Luxury Badge */
|
||||
.luxury-badge {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-sm;
|
||||
@apply text-xs font-medium tracking-wide;
|
||||
@apply transition-all duration-200;
|
||||
@apply bg-[#d4af37]/10 text-[#c9a227] border border-[#d4af37]/30;
|
||||
}
|
||||
|
||||
/* Luxury Input */
|
||||
.luxury-input {
|
||||
@apply w-full px-4 py-3 rounded-sm border border-gray-300;
|
||||
@apply focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37];
|
||||
@apply transition-all duration-200;
|
||||
@apply bg-white text-gray-900;
|
||||
@apply placeholder:text-gray-400;
|
||||
@apply font-light tracking-wide;
|
||||
}
|
||||
|
||||
/* Luxury Gradient Background */
|
||||
.luxury-gradient-bg {
|
||||
background: var(--gradient-gold);
|
||||
}
|
||||
|
||||
/* Luxury Glass Effect */
|
||||
.luxury-glass {
|
||||
@apply bg-white/90 backdrop-blur-xl;
|
||||
@apply border border-[#d4af37]/20;
|
||||
@apply shadow-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom utilities */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Luxury Shadows */
|
||||
.shadow-luxury {
|
||||
box-shadow: var(--shadow-luxury);
|
||||
}
|
||||
|
||||
.shadow-luxury-lg {
|
||||
box-shadow: var(--shadow-luxury-gold);
|
||||
}
|
||||
|
||||
.shadow-luxury-gold {
|
||||
box-shadow: 0 8px 30px rgba(212, 175, 55, 0.25);
|
||||
}
|
||||
|
||||
/* Smooth fade-in animation */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -60,7 +267,7 @@ code {
|
||||
|
||||
/* Slide-in animation */
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.4s ease-out;
|
||||
animation: slideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -76,7 +283,7 @@ code {
|
||||
|
||||
/* Scale-in animation */
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
animation: scaleIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
@@ -94,6 +301,79 @@ code {
|
||||
.transition-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.transition-enterprise {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Slide up animation */
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse animation for enterprise */
|
||||
.animate-pulse-enterprise {
|
||||
animation: pulseEnterprise 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseEnterprise {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer animation for skeleton loading - Enhanced */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#f1f5f9 0%,
|
||||
#e2e8f0 20%,
|
||||
#f1f5f9 40%,
|
||||
#f1f5f9 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
|
||||
/* Luxury gradient text */
|
||||
.text-gradient-luxury {
|
||||
@apply bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37];
|
||||
@apply bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.text-gradient-luxury-dark {
|
||||
@apply bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900;
|
||||
@apply bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Luxury backdrop blur */
|
||||
.backdrop-blur-luxury {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Image loading optimization */
|
||||
|
||||
53
Frontend/src/utils/constants.ts
Normal file
53
Frontend/src/utils/constants.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Application constants
|
||||
*/
|
||||
|
||||
export const API_TIMEOUT = 30000; // 30 seconds
|
||||
export const MAX_RETRIES = 3;
|
||||
export const RETRY_DELAY = 1000; // 1 second
|
||||
|
||||
export const DATE_FORMATS = {
|
||||
SHORT: 'short',
|
||||
MEDIUM: 'medium',
|
||||
LONG: 'long',
|
||||
FULL: 'full',
|
||||
} as const;
|
||||
|
||||
export const CURRENCY = {
|
||||
VND: 'VND',
|
||||
USD: 'USD',
|
||||
EUR: 'EUR',
|
||||
} as const;
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
TOKEN: 'token',
|
||||
USER_INFO: 'userInfo',
|
||||
GUEST_FAVORITES: 'guestFavorites',
|
||||
THEME: 'theme',
|
||||
LANGUAGE: 'language',
|
||||
} as const;
|
||||
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
LOGIN: '/login',
|
||||
REGISTER: '/register',
|
||||
DASHBOARD: '/dashboard',
|
||||
ROOMS: '/rooms',
|
||||
BOOKINGS: '/bookings',
|
||||
FAVORITES: '/favorites',
|
||||
PROFILE: '/profile',
|
||||
ADMIN: '/admin',
|
||||
} as const;
|
||||
|
||||
export const TOAST_DURATION = {
|
||||
SUCCESS: 3000,
|
||||
ERROR: 5000,
|
||||
WARNING: 4000,
|
||||
INFO: 3000,
|
||||
} as const;
|
||||
|
||||
export const PAGINATION = {
|
||||
DEFAULT_PAGE_SIZE: 10,
|
||||
PAGE_SIZE_OPTIONS: [10, 20, 50, 100],
|
||||
} as const;
|
||||
|
||||
162
Frontend/src/utils/format.ts
Normal file
162
Frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Utility functions for formatting data
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format currency
|
||||
*/
|
||||
export const formatCurrency = (
|
||||
amount: number | string,
|
||||
currency: string = 'VND'
|
||||
): string => {
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
|
||||
if (isNaN(numAmount)) return '0 ₫';
|
||||
|
||||
if (currency === 'VND') {
|
||||
return new Intl.NumberFormat('vi-VN', {
|
||||
style: 'currency',
|
||||
currency: 'VND',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(numAmount);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(numAmount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date
|
||||
*/
|
||||
export const formatDate = (
|
||||
date: string | Date,
|
||||
format: 'short' | 'medium' | 'long' | 'full' = 'medium'
|
||||
): string => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(dateObj.getTime())) return 'Invalid Date';
|
||||
|
||||
const formatOptions: Record<string, Intl.DateTimeFormatOptions> = {
|
||||
short: { year: 'numeric', month: 'numeric', day: 'numeric' },
|
||||
medium: { year: 'numeric', month: 'short', day: 'numeric' },
|
||||
long: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
full: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
},
|
||||
};
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', formatOptions[format]).format(dateObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date and time
|
||||
*/
|
||||
export const formatDateTime = (
|
||||
date: string | Date,
|
||||
includeSeconds: boolean = false
|
||||
): string => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(dateObj.getTime())) return 'Invalid Date';
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(includeSeconds && { second: '2-digit' }),
|
||||
}).format(dateObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
export const formatRelativeTime = (date: string | Date): string => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 0) return 'in the future';
|
||||
if (diffInSeconds < 60) return 'just now';
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
return formatDate(dateObj, 'short');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format number
|
||||
*/
|
||||
export const formatNumber = (
|
||||
num: number | string,
|
||||
decimals: number = 0
|
||||
): string => {
|
||||
const numValue = typeof num === 'string' ? parseFloat(num) : num;
|
||||
|
||||
if (isNaN(numValue)) return '0';
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(numValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format phone number
|
||||
*/
|
||||
export const formatPhoneNumber = (phone: string): string => {
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
|
||||
if (cleaned.length === 10) {
|
||||
return cleaned.replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3');
|
||||
}
|
||||
|
||||
if (cleaned.length === 11) {
|
||||
return cleaned.replace(/(\d{1})(\d{3})(\d{3})(\d{4})/, '+$1 ($2) $3-$4');
|
||||
}
|
||||
|
||||
return phone;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Truncate text
|
||||
*/
|
||||
export const truncateText = (
|
||||
text: string,
|
||||
maxLength: number,
|
||||
suffix: string = '...'
|
||||
): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - suffix.length) + suffix;
|
||||
};
|
||||
|
||||
4
Frontend/src/utils/index.ts
Normal file
4
Frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './format';
|
||||
export * from './constants';
|
||||
export * from './validationSchemas';
|
||||
|
||||
@@ -6,7 +6,18 @@ export default {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
serif: ['Playfair Display', 'Georgia', 'Times New Roman', 'serif'],
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
luxury: {
|
||||
gold: '#d4af37',
|
||||
'gold-light': '#f5d76e',
|
||||
'gold-dark': '#c9a227',
|
||||
black: '#0f0f0f',
|
||||
'black-light': '#1a1a1a',
|
||||
},
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
|
||||
Reference in New Issue
Block a user