This commit is contained in:
Iliyan Angelov
2025-11-16 20:05:08 +02:00
parent 98ccd5b6ff
commit 48353cde9c
118 changed files with 9488 additions and 1336 deletions

View File

@@ -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>

View File

@@ -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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View File

@@ -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">
&copy; {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">
&copy; {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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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;
};

View 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>
);
};

View 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';

View 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 };
}

View 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]);
}

View 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];
}

View 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;
}

View 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;

View File

@@ -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>
</>
);
};

View 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;

View 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;

View File

@@ -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>

View 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;

View File

@@ -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>

View 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;

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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;

View File

@@ -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>

View 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;

View File

@@ -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,
});
}
);

View 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;

View File

@@ -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;

View File

@@ -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;

View 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,
};

View File

@@ -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';

View 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;

View File

@@ -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 */

View 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;

View 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;
};

View File

@@ -0,0 +1,4 @@
export * from './format';
export * from './constants';
export * from './validationSchemas';

View File

@@ -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',