This commit is contained in:
Iliyan Angelov
2025-11-30 22:43:09 +02:00
parent 24b40450dd
commit 39fcfff811
1610 changed files with 5442 additions and 1383 deletions

View File

@@ -0,0 +1,116 @@
import React, { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import privacyService, {
PublicPrivacyConfig,
} from '../../features/content/services/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);
useEffect(() => {
let mounted = true;
const loadConfig = async () => {
try {
const cfg = await privacyService.getPublicConfig();
if (!mounted) return;
setConfig(cfg);
} catch {
}
};
void loadConfig();
return () => {
mounted = false;
};
}, []);
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;
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${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 () => {
};
}, [config, consent]);
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]);
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;
(function (f: any, b: Document, e: string) {
if (f.fbq) return;
const n: any = f.fbq = function () {
(n.callMethod ? n.callMethod : n.queue.push).apply(n, arguments);
};
if (!f._fbq) f._fbq = n;
n.push = n;
n.loaded = true;
n.version = '2.0';
n.queue = [];
const t = b.createElement(e) as HTMLScriptElement;
t.async = true;
t.src = 'https://connect.facebook.net/en_US/fbevents.js';
const s = b.getElementsByTagName(e)[0];
if (s && s.parentNode) {
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,162 @@
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;
return (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
{}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{}
<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">
{}
<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,199 @@
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)]">
{}
<div className="absolute inset-0 rounded-2xl border border-[#d4af37]/40" />
{}
<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">
{}
<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>
{}
<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,20 @@
import React from 'react';
const CookiePreferencesLink: React.FC = () => {
const handleClick = () => {
window.dispatchEvent(new CustomEvent('open-cookie-preferences'));
};
return (
<button
type="button"
onClick={handleClick}
className="hover:text-[#d4af37] transition-colors cursor-pointer font-light tracking-wide text-gray-700"
>
Cookie Preferences
</button>
);
};
export default CookiePreferencesLink;

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { useCookieConsent } from '../contexts/CookieConsentContext';
import { useClickOutside } from '../hooks/useClickOutside';
const CookiePreferencesModal: React.FC = () => {
const { consent, updateConsent } = useCookieConsent();
const [isOpen, setIsOpen] = useState(false);
const [analyticsChecked, setAnalyticsChecked] = useState(false);
const [marketingChecked, setMarketingChecked] = useState(false);
const [preferencesChecked, setPreferencesChecked] = useState(false);
const modalRef = React.useRef<HTMLDivElement>(null);
useClickOutside(modalRef, () => {
if (isOpen) {
setIsOpen(false);
}
});
useEffect(() => {
if (consent) {
setAnalyticsChecked(consent.categories.analytics);
setMarketingChecked(consent.categories.marketing);
setPreferencesChecked(consent.categories.preferences);
}
}, [consent]);
useEffect(() => {
const handleOpenPreferences = () => {
setIsOpen(true);
};
window.addEventListener('open-cookie-preferences', handleOpenPreferences);
return () => {
window.removeEventListener('open-cookie-preferences', handleOpenPreferences);
};
}, []);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) {
return null;
}
const handleAcceptAll = async () => {
await updateConsent({
analytics: true,
marketing: true,
preferences: true,
});
setIsOpen(false);
};
const handleRejectNonEssential = async () => {
await updateConsent({
analytics: false,
marketing: false,
preferences: false,
});
setIsOpen(false);
};
const handleSaveSelection = async () => {
await updateConsent({
analytics: analyticsChecked,
marketing: marketingChecked,
preferences: preferencesChecked,
});
setIsOpen(false);
};
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/70 backdrop-blur-sm transition-opacity"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
{/* Modal */}
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
ref={modalRef}
className="relative transform overflow-hidden rounded-2xl bg-gradient-to-br from-zinc-950/95 via-zinc-900/95 to-black/95 text-left shadow-2xl border border-[#d4af37]/30 transition-all sm:my-8 sm:w-full sm:max-w-2xl"
>
{/* Close button */}
<button
onClick={() => setIsOpen(false)}
className="absolute right-4 top-4 z-10 rounded-full p-2 text-gray-400 hover:bg-zinc-800/50 hover:text-white transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
<div className="px-6 py-6 sm:px-8 sm:py-8">
{/* Header */}
<div className="mb-6">
<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 mb-4">
<span className="h-1.5 w-1.5 rounded-full bg-[#d4af37]" />
Privacy Suite
</div>
<h2 className="text-2xl font-elegant font-bold text-white mb-2">
Cookie Preferences
</h2>
<p className="text-sm text-zinc-300">
Manage your cookie preferences. You can enable or disable different types of cookies below.
</p>
</div>
{/* Cookie Categories */}
<div className="space-y-4 mb-6">
<div className="rounded-xl bg-black/40 p-4 ring-1 ring-zinc-800/80 backdrop-blur-md">
<div className="flex items-start gap-3">
<div className="mt-0.5 h-5 w-5 rounded border border-[#d4af37]/50 bg-[#d4af37]/20 flex items-center justify-center">
<span className="text-[#d4af37] text-xs"></span>
</div>
<div className="flex-1">
<p className="font-semibold text-zinc-50 mb-1">Strictly necessary</p>
<p className="text-xs text-zinc-400">
Essential for security, authentication, and core booking flows. These are always enabled.
</p>
</div>
</div>
</div>
<div className="rounded-xl bg-black/40 p-4 ring-1 ring-zinc-800/80 backdrop-blur-md">
<div className="flex items-start gap-3">
<input
id="cookie-analytics-modal"
type="checkbox"
className="mt-0.5 h-5 w-5 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37] cursor-pointer"
checked={analyticsChecked}
onChange={(e) => setAnalyticsChecked(e.target.checked)}
/>
<label htmlFor="cookie-analytics-modal" className="flex-1 cursor-pointer">
<p className="font-semibold text-zinc-50 mb-1">Analytics</p>
<p className="text-xs text-zinc-400">
Anonymous insights that help us refine performance and guest experience throughout the site.
</p>
</label>
</div>
</div>
<div className="rounded-xl bg-black/40 p-4 ring-1 ring-zinc-800/80 backdrop-blur-md">
<div className="flex items-start gap-3">
<input
id="cookie-marketing-modal"
type="checkbox"
className="mt-0.5 h-5 w-5 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37] cursor-pointer"
checked={marketingChecked}
onChange={(e) => setMarketingChecked(e.target.checked)}
/>
<label htmlFor="cookie-marketing-modal" className="flex-1 cursor-pointer">
<p className="font-semibold text-zinc-50 mb-1">Tailored offers</p>
<p className="text-xs text-zinc-400">
Allow us to present bespoke promotions and experiences aligned with your interests.
</p>
</label>
</div>
</div>
<div className="rounded-xl bg-black/40 p-4 ring-1 ring-zinc-800/80 backdrop-blur-md">
<div className="flex items-start gap-3">
<input
id="cookie-preferences-modal"
type="checkbox"
className="mt-0.5 h-5 w-5 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37] cursor-pointer"
checked={preferencesChecked}
onChange={(e) => setPreferencesChecked(e.target.checked)}
/>
<label htmlFor="cookie-preferences-modal" className="flex-1 cursor-pointer">
<p className="font-semibold text-zinc-50 mb-1">Comfort preferences</p>
<p className="text-xs text-zinc-400">
Remember your choices such as language, currency, and layout for a smoother return visit.
</p>
</label>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-zinc-800/50">
<button
type="button"
onClick={handleAcceptAll}
className="flex-1 inline-flex items-center justify-center rounded-full bg-gradient-to-r from-[#d4af37] via-[#f2cf74] to-[#d4af37] px-6 py-3 text-sm font-semibold uppercase tracking-[0.16em] text-black shadow-lg transition hover:from-[#f8e4a6] hover:via-[#ffe6a3] hover:to-[#f2cf74] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d4af37]/70"
>
Accept all
</button>
<button
type="button"
onClick={handleRejectNonEssential}
className="flex-1 inline-flex items-center justify-center rounded-full border border-zinc-600/80 bg-black/40 px-6 py-3 text-sm font-semibold uppercase tracking-[0.16em] text-zinc-100 shadow-lg transition hover:border-zinc-400 hover:bg-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
>
Essential only
</button>
<button
type="button"
onClick={handleSaveSelection}
className="flex-1 inline-flex items-center justify-center rounded-full border border-[#d4af37]/60 bg-zinc-900/80 px-6 py-3 text-sm font-semibold uppercase tracking-[0.16em] text-[#d4af37] shadow-lg transition hover:border-[#d4af37] hover:bg-zinc-800/80 hover:text-[#f5e9c6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d4af37]/70"
>
Save selection
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CookiePreferencesModal;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { useCurrency } from '../../features/payments/contexts/CurrencyContext';
import { getCurrencySymbol } from '../utils/format';
interface CurrencyIconProps {
className?: string;
size?: number;
currency?: string;
}
const CurrencyIcon: React.FC<CurrencyIconProps> = ({
className = '',
size = 24,
currency
}) => {
const { currency: contextCurrency } = useCurrency();
const currencyToUse = currency || contextCurrency || 'VND';
const symbol = getCurrencySymbol(currencyToUse);
return (
<div
className={`flex items-center justify-center font-semibold ${className}`}
style={{
width: `${size}px`,
height: `${size}px`,
fontSize: `${size * 0.6}px`,
lineHeight: 1
}}
title={`${currencyToUse} currency symbol`}
>
{symbol}
</div>
);
};
export default CurrencyIcon;

View File

@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { useCurrency } from '../../features/payments/contexts/CurrencyContext';
import { Globe } from 'lucide-react';
import { toast } from 'react-toastify';
import systemSettingsService from '../../features/system/services/systemSettingsService';
import useAuthStore from '../../store/useAuthStore';
import { getCurrencySymbol } from '../utils/format';
interface CurrencySelectorProps {
className?: string;
showLabel?: boolean;
variant?: 'dropdown' | 'select';
adminMode?: boolean;
}
const CurrencySelector: React.FC<CurrencySelectorProps> = ({
className = '',
showLabel = true,
variant = 'select',
adminMode = false,
}) => {
const { currency, supportedCurrencies, isLoading, refreshCurrency } = useCurrency();
const { userInfo } = useAuthStore();
const [, setSaving] = useState(false);
const isAdmin = userInfo?.role === 'admin';
const currencyNames: Record<string, string> = {
VND: 'Vietnamese Dong',
USD: 'US Dollar',
EUR: 'Euro',
GBP: 'British Pound',
JPY: 'Japanese Yen',
CNY: 'Chinese Yuan',
KRW: 'South Korean Won',
SGD: 'Singapore Dollar',
THB: 'Thai Baht',
AUD: 'Australian Dollar',
CAD: 'Canadian Dollar',
};
const getCurrencyDisplayName = (code: string): string => {
const name = currencyNames[code] || code;
const symbol = getCurrencySymbol(code);
return `${name} (${symbol})`;
};
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newCurrency = e.target.value;
if (adminMode && isAdmin) {
try {
setSaving(true);
await systemSettingsService.updatePlatformCurrency(newCurrency);
await refreshCurrency();
toast.success('Platform currency updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update platform currency');
} finally {
setSaving(false);
}
}
};
if (isLoading) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && <span className="text-sm text-gray-600">Currency:</span>}
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded"></div>
</div>
);
}
if (variant === 'dropdown') {
return (
<div className={`relative ${className}`}>
{showLabel && (
<label className="block text-sm font-medium text-gray-700 mb-1">
Currency
</label>
)}
<div className="relative">
<select
value={currency}
onChange={handleChange}
className="appearance-none bg-white border border-gray-300 rounded-lg px-4 py-2 pr-8 text-sm font-medium text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors cursor-pointer"
>
{supportedCurrencies.map((curr) => (
<option key={curr} value={curr}>
{curr} - {getCurrencyDisplayName(curr)}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<Globe className="w-4 h-4 text-gray-400" />
</div>
</div>
</div>
);
}
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium text-gray-700 flex items-center gap-1">
<Globe className="w-4 h-4" />
Currency:
</span>
)}
<select
value={currency}
onChange={handleChange}
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors cursor-pointer"
>
{supportedCurrencies.map((curr) => (
<option key={curr} value={curr}>
{curr}
</option>
))}
</select>
</div>
);
};
export default CurrencySelector;

View File

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

View File

@@ -0,0 +1,147 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { logger } from '../utils/logger';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error('ErrorBoundary caught an error', error, {
componentStack: errorInfo.componentStack,
errorBoundary: true,
});
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-gray-50
flex items-center justify-center p-4"
>
<div className="max-w-md w-full bg-white
rounded-lg shadow-lg p-8"
>
<div className="flex items-center
justify-center w-16 h-16 bg-red-100
rounded-full mx-auto mb-4"
>
<AlertCircle className="w-8 h-8
text-red-600"
/>
</div>
<h1 className="text-2xl font-bold
text-gray-900 text-center mb-2"
>
An Error Occurred
</h1>
<p className="text-gray-600 text-center mb-6">
Sorry, an error has occurred. Please try again
or contact support if the problem persists.
</p>
{process.env.NODE_ENV === 'development' &&
this.state.error && (
<div className="bg-red-50 border
border-red-200 rounded-lg p-4 mb-6"
>
<p className="text-sm font-mono
text-red-800 break-all"
>
{this.state.error.toString()}
</p>
{this.state.errorInfo && (
<details className="mt-2">
<summary className="text-sm
text-red-700 cursor-pointer
hover:text-red-800"
>
Error Details
</summary>
<pre className="mt-2 text-xs
text-red-600 overflow-auto
max-h-40"
>
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
)}
<div className="flex gap-3">
<button
onClick={this.handleReset}
className="flex-1 flex items-center
justify-center gap-2 bg-indigo-600
text-white px-6 py-3 rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
<RefreshCw className="w-5 h-5" />
Reload Page
</button>
<button
onClick={() => window.location.href = '/'}
className="flex-1 bg-gray-200
text-gray-700 px-6 py-3 rounded-lg
hover:bg-gray-300 transition-colors
font-semibold"
>
Go to Home
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,25 @@
import React, { ReactNode } from 'react';
import ErrorBoundary from './ErrorBoundary';
interface ErrorBoundaryRouteProps {
children: ReactNode;
fallback?: ReactNode;
}
/**
* Wrapper component that wraps a route with an ErrorBoundary
* Use this for critical routes that need isolated error handling
*/
const ErrorBoundaryRoute: React.FC<ErrorBoundaryRouteProps> = ({
children,
fallback
}) => {
return (
<ErrorBoundary fallback={fallback}>
{children}
</ErrorBoundary>
);
};
export default ErrorBoundaryRoute;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { AlertCircle, X } from 'lucide-react';
interface ErrorMessageProps {
message: string;
onDismiss?: () => void;
className?: string;
variant?: 'error' | 'warning' | 'info';
}
const ErrorMessage: React.FC<ErrorMessageProps> = ({
message,
onDismiss,
className = '',
variant = 'error',
}) => {
const variantStyles = {
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
const iconColors = {
error: 'text-red-600',
warning: 'text-yellow-600',
info: 'text-blue-600',
};
return (
<div
className={`flex items-start gap-3 p-4 rounded-lg border ${variantStyles[variant]} ${className}`}
role="alert"
aria-live="polite"
>
<AlertCircle className={`w-5 h-5 flex-shrink-0 mt-0.5 ${iconColors[variant]}`} />
<div className="flex-1">
<p className="text-sm font-medium">{message}</p>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
};
export default ErrorMessage;

View File

@@ -0,0 +1,186 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, FileText, FileJson, FileSpreadsheet, File, ChevronDown } from 'lucide-react';
import { exportData, formatDataForExport, ExportFormat } from '../utils/exportUtils';
import { toast } from 'react-toastify';
interface ExportButtonProps {
data: any[];
filename: string;
title?: string;
customHeaders?: Record<string, string>;
className?: string;
disabled?: boolean;
}
const ExportButton: React.FC<ExportButtonProps> = ({
data,
filename,
title,
customHeaders,
className = '',
disabled = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleExport = async (format: ExportFormat) => {
try {
if (!data || data.length === 0) {
toast.error('No data available to export');
return;
}
const { headers, formattedData } = formatDataForExport(data, customHeaders);
exportData({
format,
filename,
title: title || filename,
headers,
data: formattedData
});
toast.success(`Data exported successfully as ${format.toUpperCase()}!`);
setIsOpen(false);
} catch (error: any) {
toast.error(error.message || 'Failed to export data');
}
};
const exportOptions = [
{
format: 'csv' as ExportFormat,
label: 'CSV',
icon: FileText,
description: 'Comma-separated values',
color: 'from-blue-500 to-blue-600'
},
{
format: 'xlsx' as ExportFormat,
label: 'Excel',
icon: FileSpreadsheet,
description: 'Microsoft Excel format',
color: 'from-emerald-500 to-emerald-600'
},
{
format: 'pdf' as ExportFormat,
label: 'PDF',
icon: File,
description: 'Portable Document Format',
color: 'from-rose-500 to-rose-600'
},
{
format: 'json' as ExportFormat,
label: 'JSON',
icon: FileJson,
description: 'JavaScript Object Notation',
color: 'from-purple-500 to-purple-600'
}
];
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<button
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled || !data || data.length === 0}
className={`
group relative flex items-center gap-2 px-5 py-2.5
bg-gradient-to-r from-emerald-600 via-emerald-600 to-emerald-700
text-white rounded-xl font-semibold
shadow-lg hover:shadow-xl
transition-all duration-300
disabled:opacity-50 disabled:cursor-not-allowed
${isOpen ? 'ring-4 ring-emerald-200' : ''}
`}
>
<Download className="w-5 h-5 transition-transform duration-300 group-hover:scale-110" />
<span>Export Data</span>
<ChevronDown className={`w-4 h-4 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`} />
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-emerald-500 rounded-xl opacity-0 group-hover:opacity-20 transition-opacity duration-300"></div>
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-72 z-50 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden animate-fade-in">
<div className="p-4 bg-gradient-to-r from-emerald-50 to-emerald-100 border-b border-emerald-200">
<h3 className="text-sm font-bold text-emerald-900 uppercase tracking-wider">
Export Format
</h3>
<p className="text-xs text-emerald-700 mt-1">
Choose your preferred format
</p>
</div>
<div className="py-2">
{exportOptions.map((option, index) => {
const Icon = option.icon;
return (
<button
key={option.format}
onClick={() => handleExport(option.format)}
className={`
w-full flex items-center gap-4 px-4 py-3.5
text-left transition-all duration-200
hover:bg-gradient-to-r hover:from-gray-50 hover:to-emerald-50
group relative
${index !== exportOptions.length - 1 ? 'border-b border-gray-100' : ''}
`}
>
<div className={`
p-2.5 rounded-xl
bg-gradient-to-br ${option.color}
text-white shadow-lg
group-hover:scale-110 transition-transform duration-200
`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900 group-hover:text-emerald-700 transition-colors">
{option.label}
</span>
</div>
<p className="text-xs text-gray-500 mt-0.5">
{option.description}
</p>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
</div>
</button>
);
})}
</div>
<div className="px-4 py-3 bg-gradient-to-r from-gray-50 to-emerald-50 border-t border-gray-100">
<p className="text-xs text-gray-600 text-center">
<span className="font-semibold">{data?.length || 0}</span> records available
</p>
</div>
</div>
</>
)}
</div>
);
};
export default ExportButton;

View File

@@ -0,0 +1,435 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
Hotel,
Facebook,
Twitter,
Instagram,
Mail,
Phone,
MapPin,
Linkedin,
Youtube,
Award,
Shield,
Star,
Trophy,
Medal,
BadgeCheck,
CheckCircle,
Heart,
Crown,
Gem,
Zap,
Target,
TrendingUp,
LucideIcon
} from 'lucide-react';
import CookiePreferencesLink from './CookiePreferencesLink';
import ChatWidget from '../../features/notifications/components/ChatWidget';
import pageContentService, { type PageContent } from '../../features/content/services/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
const Footer: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [enabledPages, setEnabledPages] = useState<Set<string>>(new Set());
useEffect(() => {
const fetchPageContent = async () => {
try {
const response = await pageContentService.getFooterContent();
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);
}
} catch (err: any) {
console.error('Error fetching footer content:', err);
}
};
const checkEnabledPages = async () => {
const enabled = new Set<string>();
const policyPages = [
{ type: 'privacy', url: '/privacy', service: () => pageContentService.getPrivacyContent() },
{ type: 'terms', url: '/terms', service: () => pageContentService.getTermsContent() },
{ type: 'refunds', url: '/refunds', service: () => pageContentService.getRefundsContent() },
{ type: 'cancellation', url: '/cancellation', service: () => pageContentService.getCancellationContent() },
{ type: 'accessibility', url: '/accessibility', service: () => pageContentService.getAccessibilityContent() },
{ type: 'faq', url: '/faq', service: () => pageContentService.getFAQContent() },
];
await Promise.all(
policyPages.map(async (page) => {
try {
const response = await page.service();
if (response.status === 'success' && response.data?.page_content?.is_active) {
enabled.add(page.url);
}
} catch (err: any) {
// If 404, page is disabled, don't add to enabled set
if (err.response?.status !== 404) {
console.error(`Error checking ${page.type} page:`, err);
}
}
})
);
setEnabledPages(enabled);
};
fetchPageContent();
checkEnabledPages();
}, []);
const displayPhone = settings.company_phone || null;
const displayEmail = settings.company_email || null;
const displayAddress = settings.company_address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam';
const phoneNumber = displayPhone ? displayPhone.replace(/\s+/g, '').replace(/[()]/g, '') : '';
const phoneHref = displayPhone ? 'tel:' + phoneNumber : '';
const logoUrl = settings.company_logo_url
? (settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`)
: null;
const iconMap: Record<string, LucideIcon> = {
Award,
Star,
Trophy,
Medal,
BadgeCheck,
CheckCircle,
Shield,
Heart,
Crown,
Gem,
Zap,
Target,
TrendingUp,
};
const badges = pageContent?.badges || [];
const defaultQuickLinks = [
{ label: 'Home', url: '/' },
{ label: 'Rooms & Suites', url: '/rooms' },
{ label: 'My Bookings', url: '/bookings' },
{ label: 'About Us', url: '/about' },
{ label: 'Blog', url: '/blog' }
];
const defaultSupportLinks = [
{ label: 'FAQ', url: '/faq' },
{ label: 'Terms of Service', url: '/terms' },
{ label: 'Privacy Policy', url: '/privacy' },
{ label: 'Refunds Policy', url: '/refunds' },
{ label: 'Cancellation Policy', url: '/cancellation' },
{ label: 'Accessibility', url: '/accessibility' },
{ label: 'Contact Us', url: '/contact' }
];
const quickLinks = pageContent?.footer_links?.quick_links && pageContent.footer_links.quick_links.length > 0
? pageContent.footer_links.quick_links
: defaultQuickLinks;
const allSupportLinks = pageContent?.footer_links?.support_links && pageContent.footer_links.support_links.length > 0
? pageContent.footer_links.support_links
: defaultSupportLinks;
// Filter support links to only show enabled policy pages
const supportLinks = allSupportLinks.filter((link) => {
// Always show Contact Us
if (link.url === '/contact') return true;
// Only show policy pages if they are enabled
return enabledPages.has(link.url);
});
return (
<footer className="relative bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-black text-gray-300 overflow-hidden">
{/* Top Gold Accent Line */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-transparent via-[#d4af37] to-transparent shadow-lg shadow-[#d4af37]/50"></div>
{/* Decorative Pattern Overlay */}
<div className="absolute inset-0 opacity-[0.03]" 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%3Cpath d='M9 0h2v20H9zM25 0h2v20h-2zM41 0h2v20h-2zM57 0h2v20h-2zM0 9h20v2H0zM0 25h20v2H0zM0 41h20v2H0zM0 57h20v2H0zM40 9h20v2H40zM40 25h20v2H40zM40 41h20v2H40zM40 57h20v2H40zM9 40h2v20H9zM25 40h2v20h-2zM41 40h2v20h-2zM57 40h2v20h-2z' fill='%23d4af37'/%3E%3C/svg%3E")`
}}></div>
{/* Subtle Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent pointer-events-none"></div>
<div className="relative container mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
{/* Main Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-10 sm:gap-12 lg:gap-16 mb-16 sm:mb-20">
{/* Brand Section */}
<div className="lg:col-span-2">
<div className="flex items-center space-x-4 mb-6 sm:mb-8">
{logoUrl ? (
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/20 to-transparent rounded-lg blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<img
src={logoUrl}
alt={settings.company_name}
className="h-12 sm:h-14 w-auto object-contain drop-shadow-2xl relative z-10 filter brightness-110"
/>
</div>
) : (
<div className="relative group">
<div className="p-3 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 rounded-lg border border-[#d4af37]/20 backdrop-blur-sm">
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 text-[#d4af37] relative z-10 drop-shadow-lg" />
</div>
<div className="absolute inset-0 bg-[#d4af37]/20 blur-2xl opacity-50 group-hover:opacity-75 transition-opacity duration-500"></div>
</div>
)}
<div>
<h2 className="text-2xl sm:text-3xl font-display font-semibold text-white tracking-tight mb-1">
{settings.company_name || pageContent?.title || 'Luxury Hotel'}
</h2>
<p className="text-xs sm:text-sm text-[#d4af37] font-light tracking-[3px] sm:tracking-[4px] uppercase">
{settings.company_tagline || 'Excellence Redefined'}
</p>
</div>
</div>
<p className="text-sm sm:text-base text-gray-400 mb-8 leading-relaxed max-w-md font-light">
{pageContent?.description || 'Experience unparalleled luxury and world-class hospitality. Your journey to exceptional comfort begins here.'}
</p>
{/* Badges */}
{badges.length > 0 && badges.some(b => b.text) && (
<div className="flex flex-wrap items-center gap-4 sm:gap-6 mb-8">
{badges.map((badge, index) => {
if (!badge.text) return null;
const BadgeIcon = iconMap[badge.icon] || Award;
return (
<div
key={index}
className="group flex items-center space-x-2 px-3 py-2 bg-gradient-to-r from-[#d4af37]/5 to-transparent border border-[#d4af37]/10 rounded-lg hover:border-[#d4af37]/30 hover:from-[#d4af37]/10 transition-all duration-300"
>
<BadgeIcon className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37] group-hover:scale-110 transition-transform duration-300" />
<span className="text-xs sm:text-sm font-medium tracking-wide text-gray-300 group-hover:text-[#d4af37] transition-colors">{badge.text}</span>
</div>
);
})}
</div>
)}
{/* Social Media Links */}
<div className="flex items-center space-x-3 sm:space-x-4">
{pageContent?.social_links?.facebook && (
<a
href={pageContent.social_links.facebook}
target="_blank"
rel="noopener noreferrer"
className="group relative w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center rounded-lg bg-gradient-to-br from-gray-800/60 to-gray-900/60 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/60 transition-all duration-300 hover:bg-gradient-to-br hover:from-[#d4af37]/10 hover:to-[#c9a227]/10 hover:shadow-lg hover:shadow-[#d4af37]/20 hover:-translate-y-0.5"
aria-label="Facebook"
>
<Facebook className="w-5 h-5 sm:w-6 sm:h-6 text-gray-400 group-hover:text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
<div className="absolute inset-0 rounded-lg bg-[#d4af37]/0 group-hover:bg-[#d4af37]/10 blur-xl transition-all duration-500"></div>
</a>
)}
{pageContent?.social_links?.twitter && (
<a
href={pageContent.social_links.twitter}
target="_blank"
rel="noopener noreferrer"
className="group relative w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center rounded-lg bg-gradient-to-br from-gray-800/60 to-gray-900/60 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/60 transition-all duration-300 hover:bg-gradient-to-br hover:from-[#d4af37]/10 hover:to-[#c9a227]/10 hover:shadow-lg hover:shadow-[#d4af37]/20 hover:-translate-y-0.5"
aria-label="Twitter"
>
<Twitter className="w-5 h-5 sm:w-6 sm:h-6 text-gray-400 group-hover:text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
<div className="absolute inset-0 rounded-lg bg-[#d4af37]/0 group-hover:bg-[#d4af37]/10 blur-xl transition-all duration-500"></div>
</a>
)}
{pageContent?.social_links?.instagram && (
<a
href={pageContent.social_links.instagram}
target="_blank"
rel="noopener noreferrer"
className="group relative w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center rounded-lg bg-gradient-to-br from-gray-800/60 to-gray-900/60 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/60 transition-all duration-300 hover:bg-gradient-to-br hover:from-[#d4af37]/10 hover:to-[#c9a227]/10 hover:shadow-lg hover:shadow-[#d4af37]/20 hover:-translate-y-0.5"
aria-label="Instagram"
>
<Instagram className="w-5 h-5 sm:w-6 sm:h-6 text-gray-400 group-hover:text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
<div className="absolute inset-0 rounded-lg bg-[#d4af37]/0 group-hover:bg-[#d4af37]/10 blur-xl transition-all duration-500"></div>
</a>
)}
{pageContent?.social_links?.linkedin && (
<a
href={pageContent.social_links.linkedin}
target="_blank"
rel="noopener noreferrer"
className="group relative w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center rounded-lg bg-gradient-to-br from-gray-800/60 to-gray-900/60 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/60 transition-all duration-300 hover:bg-gradient-to-br hover:from-[#d4af37]/10 hover:to-[#c9a227]/10 hover:shadow-lg hover:shadow-[#d4af37]/20 hover:-translate-y-0.5"
aria-label="LinkedIn"
>
<Linkedin className="w-5 h-5 sm:w-6 sm:h-6 text-gray-400 group-hover:text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
<div className="absolute inset-0 rounded-lg bg-[#d4af37]/0 group-hover:bg-[#d4af37]/10 blur-xl transition-all duration-500"></div>
</a>
)}
{pageContent?.social_links?.youtube && (
<a
href={pageContent.social_links.youtube}
target="_blank"
rel="noopener noreferrer"
className="group relative w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center rounded-lg bg-gradient-to-br from-gray-800/60 to-gray-900/60 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/60 transition-all duration-300 hover:bg-gradient-to-br hover:from-[#d4af37]/10 hover:to-[#c9a227]/10 hover:shadow-lg hover:shadow-[#d4af37]/20 hover:-translate-y-0.5"
aria-label="YouTube"
>
<Youtube className="w-5 h-5 sm:w-6 sm:h-6 text-gray-400 group-hover:text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
<div className="absolute inset-0 rounded-lg bg-[#d4af37]/0 group-hover:bg-[#d4af37]/10 blur-xl transition-all duration-500"></div>
</a>
)}
</div>
</div>
{/* Quick Links */}
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<span className="relative z-10">Quick Links</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
<ul className="space-y-3 sm:space-y-4">
{quickLinks.map((link) => (
<li key={link.url}>
<Link
to={link.url}
className="group flex items-center text-sm sm:text-base text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-[2px] bg-gradient-to-r from-[#d4af37] to-[#c9a227] group-hover:w-8 transition-all duration-300 rounded-full"></span>
<span className="ml-10 group-hover:translate-x-2 transition-transform duration-300 group-hover:font-medium">{link.label}</span>
</Link>
</li>
))}
</ul>
</div>
{/* Guest Services */}
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<span className="relative z-10">Guest Services</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
<ul className="space-y-3 sm:space-y-4">
{supportLinks.map((link) => (
<li key={link.url}>
<Link
to={link.url}
className="group flex items-center text-sm sm:text-base text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-[2px] bg-gradient-to-r from-[#d4af37] to-[#c9a227] group-hover:w-8 transition-all duration-300 rounded-full"></span>
<span className="ml-10 group-hover:translate-x-2 transition-transform duration-300 group-hover:font-medium">{link.label}</span>
</Link>
</li>
))}
</ul>
</div>
{/* Contact Information */}
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<span className="relative z-10">Contact</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
<ul className="space-y-5 sm:space-y-6">
<li className="flex items-start space-x-4 group">
<div className="relative mt-1 flex-shrink-0">
<div className="p-2 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 rounded-lg border border-[#d4af37]/20 group-hover:border-[#d4af37]/40 transition-all duration-300">
<MapPin className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
</div>
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</div>
<span className="text-sm sm:text-base text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed font-light pt-1">
{(displayAddress
.split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < displayAddress.split('\n').length - 1 && <br />}
</React.Fragment>
)))}
</span>
</li>
{displayPhone && (
<li className="flex items-center space-x-4 group">
<div className="relative flex-shrink-0">
<div className="p-2 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 rounded-lg border border-[#d4af37]/20 group-hover:border-[#d4af37]/40 transition-all duration-300">
<Phone className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
</div>
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</div>
<a href={phoneHref} className="text-sm sm:text-base text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
{displayPhone}
</a>
</li>
)}
{displayEmail && (
<li className="flex items-center space-x-4 group">
<div className="relative flex-shrink-0">
<div className="p-2 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 rounded-lg border border-[#d4af37]/20 group-hover:border-[#d4af37]/40 transition-all duration-300">
<Mail className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37] transition-all duration-300 group-hover:scale-110" />
</div>
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</div>
<a href={`mailto:${displayEmail}`} className="text-sm sm:text-base text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide break-all">
{displayEmail}
</a>
</li>
)}
</ul>
{/* Rating Section */}
<div className="mt-8 sm:mt-10 pt-6 sm:pt-8 border-t border-gray-800/50">
<div className="flex items-center space-x-1 mb-3">
{[...Array(5)].map((_, i) => (
<Star key={i} className="w-4 h-4 sm:w-5 sm:h-5 fill-[#d4af37] text-[#d4af37] drop-shadow-lg" />
))}
</div>
<p className="text-xs sm:text-sm text-gray-500 font-light tracking-wide">Rated 5.0 by 10,000+ guests</p>
</div>
</div>
</div>
{/* Divider */}
<div className="relative my-12 sm:my-16">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-800/50"></div>
</div>
<div className="relative flex justify-center">
<div className="bg-gradient-to-b from-[#0f0f0f] to-[#1a1a1a] px-6">
<div className="w-24 sm:w-32 h-[2px] bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent shadow-lg shadow-[#d4af37]/30"></div>
</div>
</div>
</div>
{/* Bottom Section */}
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<div className="text-sm sm:text-base text-gray-500 font-light tracking-wide text-center md:text-left">
{(() => {
const currentYear = new Date().getFullYear();
const copyrightText = pageContent?.copyright_text || '© {YEAR} Luxury Hotel. All rights reserved.';
return copyrightText.replace(/{YEAR}/g, currentYear.toString());
})()}
</div>
<div className="flex items-center space-x-4 sm:space-x-6 text-xs sm:text-sm text-gray-600">
<Link to="/privacy" className="hover:text-[#d4af37] transition-colors cursor-pointer font-light tracking-wide">Privacy</Link>
<span className="text-gray-700"></span>
<Link to="/terms" className="hover:text-[#d4af37] transition-colors cursor-pointer font-light tracking-wide">Terms</Link>
<span className="text-gray-700"></span>
<Link to="/refunds" className="hover:text-[#d4af37] transition-colors cursor-pointer font-light tracking-wide">Refunds</Link>
<span className="text-gray-700"></span>
<CookiePreferencesLink />
</div>
</div>
</div>
{/* Bottom Gold Accent Line */}
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-gradient-to-r from-transparent via-[#d4af37] to-transparent shadow-lg shadow-[#d4af37]/50"></div>
{/* Chat Widget */}
<ChatWidget />
</footer>
);
};
export default Footer;

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,538 @@
import React, { useState, useRef } from 'react';
import { Link } from 'react-router-dom';
import {
Hotel,
User,
LogOut,
LogIn,
UserPlus,
Heart,
Phone,
Mail,
Calendar,
Star,
Users,
} from 'lucide-react';
import { useClickOutside } from '../hooks/useClickOutside';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import { useAuthModal } from '../../features/auth/contexts/AuthModalContext';
import { normalizeImageUrl } from '../utils/imageUtils';
import InAppNotificationBell from '../../features/notifications/components/InAppNotificationBell';
import Navbar from './Navbar';
interface HeaderProps {
isAuthenticated?: boolean;
userInfo?: {
name: string;
email: string;
avatar?: string;
role: string;
} | null;
onLogout?: () => void;
}
const Header: React.FC<HeaderProps> = ({
isAuthenticated = false,
userInfo = null,
onLogout
}) => {
const { settings } = useCompanySettings();
const { openModal } = useAuthModal();
const displayPhone = settings.company_phone || '+1 (234) 567-890';
const displayEmail = settings.company_email || 'info@luxuryhotel.com';
const logoUrl = settings.company_logo_url
? (settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`)
: null;
const [isMobileMenuOpen, setIsMobileMenuOpen] =
useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
useClickOutside(userMenuRef, () => {
if (isUserMenuOpen) {
setIsUserMenuOpen(false);
}
});
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const toggleUserMenu = () => {
setIsUserMenuOpen(!isUserMenuOpen);
};
const handleLogout = () => {
if (onLogout) {
onLogout();
}
setIsUserMenuOpen(false);
setIsMobileMenuOpen(false);
};
// Mobile menu content with user authentication
const mobileMenuContent = (
<>
{!isAuthenticated ? (
<>
<button
onClick={() => {
setIsMobileMenuOpen(false);
openModal('login');
}}
className="flex items-center
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 w-full text-left"
>
<LogIn className="w-4 h-4" />
<span>Login</span>
</button>
<button
onClick={() => {
setIsMobileMenuOpen(false);
openModal('register');
}}
className="flex items-center
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 w-full text-left"
>
<UserPlus className="w-4 h-4" />
<span>Register</span>
</button>
</>
) : (
<>
<div className="px-4 py-2 text-sm
text-[#d4af37]/70 font-light tracking-wide"
>
Hello, {userInfo?.name}
</div>
<Link
to="/profile"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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>
</Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
<>
<Link
to="/favorites"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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"
>
<Heart className="w-4 h-4" />
<span>Favorites</span>
</Link>
<Link
to="/bookings"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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"
>
<Calendar className="w-4 h-4" />
<span>My Bookings</span>
</Link>
<Link
to="/loyalty"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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"
>
<Star className="w-4 h-4" />
<span>Loyalty Program</span>
</Link>
<Link
to="/group-bookings"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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"
>
<Users className="w-4 h-4" />
<span>Group Bookings</span>
</Link>
</>
)}
{userInfo?.role === 'admin' && (
<Link
to="/admin"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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>
</Link>
)}
{userInfo?.role === 'staff' && (
<Link
to="/staff"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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>Staff Dashboard</span>
</Link>
)}
{userInfo?.role === 'accountant' && (
<Link
to="/accountant"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
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>Accountant Dashboard</span>
</Link>
)}
<div className="border-t border-[#d4af37]/20 my-2"></div>
<button
onClick={handleLogout}
className="w-full flex items-center
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>
</button>
</>
)}
</>
);
return (
<header className="bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] sticky top-0 z-50 border-b border-[#d4af37]/20 shadow-2xl">
<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">
{displayPhone && (
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} 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">{displayPhone}</span>
</a>
)}
{displayEmail && (
<a href={`mailto:${displayEmail}`} 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">{displayEmail}</span>
</a>
)}
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
to="/"
className="flex items-center space-x-3
group transition-all duration-300 hover:opacity-90"
>
{logoUrl ? (
<div className="relative">
<img
src={logoUrl}
alt={settings.company_name}
className="h-10 w-auto object-contain drop-shadow-lg"
/>
</div>
) : (
<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-display font-semibold text-white tracking-tight leading-tight">
{settings.company_name}
</span>
<span className="text-[10px] text-[#d4af37] tracking-[0.2em] uppercase font-light">
{settings.company_tagline || 'Excellence Redefined'}
</span>
</div>
</Link>
<Navbar
isMobileMenuOpen={isMobileMenuOpen}
onMobileMenuToggle={toggleMobileMenu}
onLinkClick={() => setIsMobileMenuOpen(false)}
mobileMenuContent={mobileMenuContent}
/>
<div className="hidden md:flex items-center
space-x-3"
>
{!isAuthenticated ? (
<>
<button
onClick={() => openModal('login')}
className="flex items-center space-x-2
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 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>
</button>
<button
onClick={() => openModal('register')}
className="flex items-center space-x-2
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"
>
<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>
</button>
</>
) : (
<div className="flex items-center gap-3">
{isAuthenticated && <InAppNotificationBell />}
<div className="relative" ref={userMenuRef}>
<button
onClick={toggleUserMenu}
className="flex items-center space-x-3
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={normalizeImageUrl(userInfo.avatar)}
alt={userInfo.name}
className="w-9 h-9 rounded-full
object-cover ring-2 ring-[#d4af37]/50"
/>
) : (
<div className="w-9 h-9 bg-gradient-to-br from-[#d4af37] to-[#c9a227]
rounded-full flex items-center
justify-center ring-2 ring-[#d4af37]/50 shadow-lg"
>
<span className="text-[#0f0f0f]
font-semibold text-sm"
>
{userInfo?.name?.charAt(0)
.toUpperCase()}
</span>
</div>
)}
<span className="font-light text-white/90 tracking-wide">
{userInfo?.name}
</span>
</button>
{isUserMenuOpen && (
<div className="absolute right-0 mt-2
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-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 className="font-light tracking-wide">Profile</span>
</Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
<>
<Link
to="/favorites"
onClick={() => setIsUserMenuOpen(false)}
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]"
>
<Heart className="w-4 h-4" />
<span className="font-light tracking-wide">Favorites</span>
</Link>
<Link
to="/bookings"
onClick={() => setIsUserMenuOpen(false)}
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]"
>
<Calendar className="w-4 h-4" />
<span className="font-light tracking-wide">My Bookings</span>
</Link>
<Link
to="/loyalty"
onClick={() => setIsUserMenuOpen(false)}
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]"
>
<Star className="w-4 h-4" />
<span className="font-light tracking-wide">Loyalty Program</span>
</Link>
<Link
to="/group-bookings"
onClick={() => setIsUserMenuOpen(false)}
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]"
>
<Users className="w-4 h-4" />
<span className="font-light tracking-wide">Group Bookings</span>
</Link>
</>
)}
{userInfo?.role === 'admin' && (
<Link
to="/admin"
onClick={() =>
setIsUserMenuOpen(false)
}
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 className="font-light tracking-wide">Admin</span>
</Link>
)}
{userInfo?.role === 'staff' && (
<Link
to="/staff"
onClick={() =>
setIsUserMenuOpen(false)
}
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 className="font-light tracking-wide">Staff Dashboard</span>
</Link>
)}
{userInfo?.role === 'accountant' && (
<Link
to="/accountant"
onClick={() =>
setIsUserMenuOpen(false)
}
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 className="font-light tracking-wide">Accountant Dashboard</span>
</Link>
)}
<div className="border-t border-[#d4af37]/20 my-1"></div>
<button
onClick={handleLogout}
className="w-full flex items-center
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 className="font-light tracking-wide">Logout</span>
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,52 @@
import React from 'react';
interface HoneypotFieldProps {
value: string;
onChange: (value: string) => void;
name?: string;
}
/**
* Honeypot field - hidden field that should never be filled by humans
* Bots often fill all fields, so if this is filled, it's likely a bot
*/
const HoneypotField: React.FC<HoneypotFieldProps> = ({
value,
onChange,
name = 'website',
}) => {
return (
<div
style={{
position: 'absolute',
left: '-9999px',
width: '1px',
height: '1px',
overflow: 'hidden',
opacity: 0,
pointerEvents: 'none',
}}
aria-hidden="true"
>
<label htmlFor={name} style={{ display: 'none' }}>
Please leave this field empty
</label>
<input
type="text"
id={name}
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
tabIndex={-1}
style={{
position: 'absolute',
left: '-9999px',
}}
/>
</div>
);
};
export default HoneypotField;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
interface LayoutMainProps {
isAuthenticated?: boolean;
userInfo?: {
name: string;
email: string;
avatar?: string;
role: string;
} | null;
onLogout?: () => void;
}
const LayoutMain: React.FC<LayoutMainProps> = ({
isAuthenticated = false,
userInfo = null,
onLogout
}) => {
return (
<div className="flex flex-col min-h-screen">
{}
<Header
isAuthenticated={isAuthenticated}
userInfo={userInfo}
onLogout={onLogout}
/>
{}
<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>
</main>
{}
<Footer />
</div>
);
};
export default LayoutMain;

View File

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

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoadingButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isLoading?: boolean;
loadingText?: string;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger' | 'outline';
size?: 'sm' | 'md' | 'lg';
}
const LoadingButton: React.FC<LoadingButtonProps> = ({
isLoading = false,
loadingText,
children,
variant = 'primary',
size = 'md',
disabled,
className = '',
...props
}) => {
const variantStyles = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
outline: 'border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 focus:ring-indigo-500',
};
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
return (
<button
{...props}
disabled={disabled || isLoading}
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
aria-busy={isLoading}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{isLoading && loadingText ? loadingText : children}
</button>
);
};
export default LoadingButton;

View File

@@ -0,0 +1,135 @@
import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { Menu, X } from 'lucide-react';
import { useClickOutside } from '../hooks/useClickOutside';
interface NavbarProps {
isMobileMenuOpen: boolean;
onMobileMenuToggle: () => void;
onLinkClick?: () => void;
renderMobileLinksOnly?: boolean;
mobileMenuContent?: React.ReactNode;
}
export const navLinks = [
{ to: '/', label: 'Home' },
{ to: '/rooms', label: 'Rooms' },
{ to: '/about', label: 'About' },
{ to: '/contact', label: 'Contact' },
{ to: '/blog', label: 'Blog' },
];
const Navbar: React.FC<NavbarProps> = ({
isMobileMenuOpen,
onMobileMenuToggle,
onLinkClick,
renderMobileLinksOnly = false,
mobileMenuContent
}) => {
const mobileMenuContainerRef = useRef<HTMLDivElement>(null);
useClickOutside(mobileMenuContainerRef, () => {
if (isMobileMenuOpen) {
onMobileMenuToggle();
}
});
const handleLinkClick = () => {
if (onLinkClick) {
onLinkClick();
}
};
// If only rendering mobile links (for use inside Header's mobile menu container)
if (renderMobileLinksOnly) {
return (
<>
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
onClick={handleLinkClick}
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"
>
{link.label}
</Link>
))}
</>
);
}
return (
<div className="relative" ref={mobileMenuContainerRef}>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">{link.label}</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>
{/* Mobile Menu Button */}
<button
onClick={onMobileMenuToggle}
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 text-[#d4af37]" />
) : (
<Menu className="w-6 h-6 text-white/90" />
)}
</button>
{/* Mobile Menu Dropdown - Absolute positioned */}
{isMobileMenuOpen && (
<div
className="md:hidden absolute right-0 mt-2 w-64
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 max-h-[calc(100vh-120px)]
overflow-y-auto"
>
<div className="flex flex-col space-y-1">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
onClick={handleLinkClick}
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"
>
{link.label}
</Link>
))}
{mobileMenuContent && (
<>
<div className="border-t border-[#d4af37]/20 my-2"></div>
{mobileMenuContent}
</>
)}
</div>
</div>
)}
</div>
);
};
export default Navbar;

View File

@@ -0,0 +1,58 @@
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 animate-slide-up"
role="alert"
aria-live="polite"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-4 sm:pb-6">
<div className="relative overflow-hidden rounded-lg shadow-2xl border border-[#d4af37]/30 backdrop-blur-xl bg-gradient-to-r from-slate-900/95 via-slate-800/95 to-slate-900/95">
{/* Gold accent border top */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent"></div>
{/* Subtle glow effect */}
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/5 via-transparent to-[#d4af37]/5 pointer-events-none"></div>
{/* Content */}
<div className="relative px-6 py-4 sm:px-8 sm:py-5 flex items-center justify-center gap-3 sm:gap-4">
{/* Icon with gold accent */}
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl rounded-full"></div>
<div className="relative bg-gradient-to-br from-[#d4af37] to-[#c9a227] p-2.5 rounded-full shadow-lg shadow-[#d4af37]/30">
<WifiOff className="w-5 h-5 sm:w-6 sm:h-6 text-slate-900" strokeWidth={2.5} />
</div>
</div>
{/* Text content */}
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-2 text-center sm:text-left">
<span className="text-sm sm:text-base font-serif font-semibold text-white tracking-wide">
Connection Lost
</span>
<span className="text-xs sm:text-sm text-slate-300 font-light tracking-wide">
You're currently offline. Some features may be unavailable.
</span>
</div>
{/* Decorative elements */}
<div className="hidden sm:flex absolute right-6 top-1/2 -translate-y-1/2 items-center gap-1 opacity-30">
<div className="w-1 h-1 rounded-full bg-[#d4af37] animate-pulse"></div>
<div className="w-1 h-1 rounded-full bg-[#d4af37] animate-pulse" style={{ animationDelay: '0.2s' }}></div>
<div className="w-1 h-1 rounded-full bg-[#d4af37] animate-pulse" style={{ animationDelay: '0.4s' }}></div>
</div>
</div>
</div>
</div>
</div>
);
};
export default OfflineIndicator;

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
import React from 'react';
import { CreditCard } from 'lucide-react';
interface PaymentMethodSelectorProps {
value: 'cash' | 'stripe' | 'borica' | 'paypal';
onChange: (value: 'cash' | 'stripe' | 'borica' | 'paypal') => void;
error?: string;
disabled?: boolean;
}
const PaymentMethodSelector: React.FC<
PaymentMethodSelectorProps
> = ({ value, onChange, error, disabled = false }) => {
return (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Payment Method
<span className="text-red-500 ml-1">*</span>
</h3>
<div className="space-y-3">
{}
<label
className={`flex items-start p-4 border-2
rounded-lg cursor-pointer transition-all
${
value === 'cash'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="radio"
name="payment_method"
value="cash"
checked={value === 'cash'}
onChange={(e) =>
onChange(e.target.value as 'cash')
}
disabled={disabled}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CreditCard
className="w-5 h-5 text-gray-600"
/>
<span className="font-medium text-gray-900">
Pay at Hotel
</span>
</div>
<p className="text-sm text-gray-600">
Pay directly at the hotel when checking in.
Cash and card accepted.
</p>
<div className="mt-2 text-xs text-gray-500
bg-white rounded px-2 py-1 inline-block"
>
Payment at check-in
</div>
</div>
</label>
{}
<label
className={`flex items-start p-4 border-2
rounded-lg cursor-pointer transition-all
${
value === 'stripe'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="radio"
name="payment_method"
value="stripe"
checked={value === 'stripe'}
onChange={(e) =>
onChange(e.target.value as 'stripe')
}
disabled={disabled}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CreditCard
className="w-5 h-5 text-indigo-600"
/>
<span className="font-medium text-gray-900">
Pay with Card (Stripe)
</span>
<span className="text-xs bg-indigo-100
text-indigo-700 px-2 py-0.5 rounded-full
font-medium"
>
Instant
</span>
</div>
<p className="text-sm text-gray-600">
Secure payment with credit or debit card.
Instant confirmation.
</p>
<div className="mt-2 text-xs text-gray-500
bg-white rounded px-2 py-1 inline-block"
>
💳 Secure card payment
</div>
</div>
</label>
{/* Borica Payment */}
<label
className={`flex items-start p-4 border-2
rounded-lg cursor-pointer transition-all
${
value === 'borica'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="radio"
name="payment_method"
value="borica"
checked={value === 'borica'}
onChange={(e) =>
onChange(e.target.value as 'borica')
}
disabled={disabled}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CreditCard
className="w-5 h-5 text-indigo-600"
/>
<span className="font-medium text-gray-900">
Pay with Borica
</span>
<span className="text-xs bg-indigo-100
text-indigo-700 px-2 py-0.5 rounded-full
font-medium"
>
BG
</span>
</div>
<p className="text-sm text-gray-600">
Secure payment through Borica payment gateway.
Bulgarian payment system.
</p>
<div className="mt-2 text-xs text-gray-500
bg-white rounded px-2 py-1 inline-block"
>
🇧🇬 Bulgarian payment gateway
</div>
</div>
</label>
</div>
{error && (
<p className="text-sm text-red-600 mt-2">
{error}
</p>
)}
{}
<div className="mt-4 p-3 bg-blue-50 border
border-blue-200 rounded-lg"
>
<p className="text-xs text-blue-800">
💡 <strong>Note:</strong> {' '}
{value === 'cash'
? 'You will pay when checking in. Cash and card accepted at the hotel.'
: value === 'stripe'
? 'Your payment will be processed securely through Stripe.'
: value === 'borica'
? 'Your payment will be processed securely through Borica payment gateway.'
: 'Your payment will be processed securely.'}
</p>
</div>
</div>
);
};
export default PaymentMethodSelector;

View File

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

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import { Loader2 } from 'lucide-react';
interface PreloaderProps {
isLoading?: boolean;
}
const Preloader: React.FC<PreloaderProps> = ({ isLoading = true }) => {
const { settings } = useCompanySettings();
const [logoError, setLogoError] = useState(false);
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Get logo URL - handle both absolute and relative URLs
const logoUrl = settings.company_logo_url && !logoError
? settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${baseUrl}${settings.company_logo_url}`
: null;
if (!isLoading) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-gradient-to-br from-slate-50/95 via-white/95 to-slate-50/95 backdrop-blur-sm">
<div className="text-center space-y-6 px-4">
<div className="relative">
{/* Animated background glow */}
<div className="absolute inset-0 bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 rounded-3xl blur-2xl opacity-40 animate-pulse"></div>
{/* Main container */}
<div className="relative bg-white/90 backdrop-blur-md p-8 rounded-3xl shadow-2xl border border-amber-200/50 min-w-[200px] min-h-[200px] flex flex-col items-center justify-center">
{/* Logo or fallback */}
{logoUrl && !logoError ? (
<div className="mb-6">
<img
src={logoUrl}
alt={settings.company_name || 'Logo'}
className="max-w-[120px] max-h-[120px] object-contain animate-pulse"
onError={() => setLogoError(true)}
/>
</div>
) : (
<div className="mb-6">
<Loader2 className="w-16 h-16 text-amber-600 animate-spin mx-auto" />
</div>
)}
{/* Loading bar */}
<div className="w-48 h-1 bg-amber-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 rounded-full animate-shimmer"
style={{
animation: 'shimmer 1.5s infinite'
}}
></div>
</div>
</div>
</div>
{/* Loading text */}
<p className="text-slate-600 font-medium text-lg tracking-wide">
{settings.company_name ? `Loading ${settings.company_name}...` : 'Loading...'}
</p>
</div>
{/* CSS for shimmer animation */}
<style>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
width: 0%;
}
50% {
width: 70%;
}
100% {
transform: translateX(200%);
width: 100%;
}
}
`}</style>
</div>
);
};
export default Preloader;

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useRef, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { recaptchaService } from '../../features/system/services/systemSettingsService';
interface RecaptchaProps {
onChange?: (token: string | null) => void;
onError?: (error: string) => void;
theme?: 'light' | 'dark';
size?: 'normal' | 'compact';
className?: string;
}
// Cache for reCAPTCHA settings to avoid multiple API calls
interface RecaptchaSettingsCache {
siteKey: string;
enabled: boolean;
timestamp: number;
}
const CACHE_KEY = 'recaptcha_settings_cache';
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
let settingsCache: RecaptchaSettingsCache | null = null;
let fetchPromise: Promise<RecaptchaSettingsCache | null> | null = null;
const getCachedSettings = (): RecaptchaSettingsCache | null => {
// Check in-memory cache first
if (settingsCache) {
const age = Date.now() - settingsCache.timestamp;
if (age < CACHE_DURATION) {
return settingsCache;
}
}
// Check localStorage cache
try {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const parsed: RecaptchaSettingsCache = JSON.parse(cached);
const age = Date.now() - parsed.timestamp;
if (age < CACHE_DURATION) {
settingsCache = parsed;
return parsed;
}
}
} catch (error) {
// Ignore cache errors
}
return null;
};
const fetchRecaptchaSettings = async (): Promise<RecaptchaSettingsCache | null> => {
// If there's already a fetch in progress, return that promise
if (fetchPromise) {
return fetchPromise;
}
fetchPromise = (async () => {
try {
const response = await recaptchaService.getRecaptchaSettings();
if (response.status === 'success' && response.data) {
const settings: RecaptchaSettingsCache = {
siteKey: response.data.recaptcha_site_key || '',
enabled: response.data.recaptcha_enabled || false,
timestamp: Date.now(),
};
// Update caches
settingsCache = settings;
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(settings));
} catch (error) {
// Ignore localStorage errors
}
return settings;
}
return null;
} catch (error) {
console.error('Error fetching reCAPTCHA settings:', error);
return null;
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
};
const Recaptcha: React.FC<RecaptchaProps> = ({
onChange,
onError,
theme = 'dark',
size = 'normal',
className = '',
}) => {
const recaptchaRef = useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
const [enabled, setEnabled] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const loadSettings = async () => {
// Try to get from cache first
const cached = getCachedSettings();
if (cached) {
setSiteKey(cached.siteKey);
setEnabled(cached.enabled);
setLoading(false);
return;
}
// Fetch from API if not cached
const settings = await fetchRecaptchaSettings();
if (settings) {
setSiteKey(settings.siteKey);
setEnabled(settings.enabled);
} else {
if (onError) {
onError('Failed to load reCAPTCHA settings');
}
}
setLoading(false);
};
loadSettings();
}, [onError]);
const handleChange = (token: string | null) => {
if (onChange) {
onChange(token);
}
};
const handleExpired = () => {
if (onChange) {
onChange(null);
}
};
const handleError = () => {
if (onError) {
onError('reCAPTCHA error occurred');
}
if (onChange) {
onChange(null);
}
};
if (loading) {
return null;
}
if (!enabled || !siteKey) {
return null;
}
return (
<div className={className}>
<ReCAPTCHA
ref={recaptchaRef}
sitekey={siteKey}
onChange={handleChange}
onExpired={handleExpired}
onError={handleError}
theme={theme}
size={size}
/>
</div>
);
};
export default Recaptcha;

View File

@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
const ScrollToTop = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
};
export default ScrollToTop;

View File

@@ -0,0 +1,278 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
BarChart3,
ChevronLeft,
ChevronRight,
LogOut,
Menu,
X,
CreditCard,
Receipt
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
import { logger } from '../utils/logger';
interface SidebarAccountantProps {
isCollapsed?: boolean;
onToggle?: () => void;
}
const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
isCollapsed: controlledCollapsed,
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const { isMobile, isDesktop } = useResponsive();
const handleLogout = async () => {
try {
await logout();
navigate('/');
if (isMobile) {
setIsMobileOpen(false);
}
} catch (error) {
logger.error('Logout error', error);
}
};
// Close mobile menu when screen becomes desktop
useEffect(() => {
if (isDesktop) {
setIsMobileOpen(false);
}
}, [isDesktop]);
const isCollapsed =
controlledCollapsed !== undefined
? controlledCollapsed
: internalCollapsed;
const handleToggle = () => {
if (onToggle) {
onToggle();
} else {
setInternalCollapsed(!internalCollapsed);
}
};
const handleMobileToggle = () => {
setIsMobileOpen(!isMobileOpen);
};
const handleLinkClick = () => {
if (isMobile) {
setIsMobileOpen(false);
}
};
// Menu items for accountant
const menuItems = [
{
path: '/accountant/dashboard',
icon: LayoutDashboard,
label: 'Dashboard'
},
{
path: '/accountant/payments',
icon: CreditCard,
label: 'Payments'
},
{
path: '/accountant/invoices',
icon: Receipt,
label: 'Invoices'
},
{
path: '/accountant/reports',
icon: BarChart3,
label: 'Financial Reports'
},
];
const isActive = (path: string) => {
// Exact match
if (location.pathname === path) return true;
// Sub-path match
return location.pathname.startsWith(`${path}/`);
};
return (
<>
{/* Mobile menu button */}
{isMobile && (
<button
onClick={handleMobileToggle}
className="fixed top-4 left-4 z-50 lg:hidden p-3 bg-gradient-to-r from-emerald-900 to-emerald-800 text-white rounded-xl shadow-2xl border border-emerald-700 hover:from-emerald-800 hover:to-emerald-700 transition-all duration-200"
aria-label="Toggle menu"
>
{isMobileOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
)}
{/* Mobile overlay */}
{isMobile && isMobileOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
onClick={handleMobileToggle}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:static inset-y-0 left-0 z-40
bg-gradient-to-b from-emerald-900 via-emerald-800 to-emerald-900
text-white shadow-2xl
transition-all duration-300 ease-in-out flex flex-col
${isMobile
? (isMobileOpen ? 'translate-x-0' : '-translate-x-full')
: ''
}
${!isMobile && (isCollapsed ? 'w-20' : 'w-72')}
${isMobile ? 'w-72' : ''}
border-r border-emerald-700/50
`}
>
{/* Header */}
<div className="p-6 border-b border-emerald-700/50 flex items-center justify-between bg-gradient-to-r from-emerald-800/50 to-emerald-900/50 backdrop-blur-sm">
{!isCollapsed && (
<div className="flex items-center gap-3">
<div className="h-1 w-12 bg-gradient-to-r from-emerald-400 to-emerald-600 rounded-full"></div>
<h2 className="text-xl font-bold bg-gradient-to-r from-emerald-100 to-emerald-200 bg-clip-text text-transparent">
Accountant Panel
</h2>
</div>
)}
{isCollapsed && !isMobile && (
<div className="w-full flex justify-center">
<div className="h-8 w-8 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-xl flex items-center justify-center shadow-lg">
<span className="text-white font-bold text-sm">A</span>
</div>
</div>
)}
{!isMobile && (
<button
onClick={handleToggle}
className="p-2.5 rounded-xl bg-emerald-800/50 hover:bg-emerald-700/50 border border-emerald-700/50 hover:border-emerald-500/50 transition-all duration-200 ml-auto shadow-lg hover:shadow-xl"
aria-label="Toggle sidebar"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5 text-emerald-200" />
) : (
<ChevronLeft className="w-5 h-5 text-emerald-200" />
)}
</button>
)}
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 custom-scrollbar">
<ul className="space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
onClick={handleLinkClick}
className={`
flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
${active
? 'bg-gradient-to-r from-emerald-500/20 to-emerald-600/20 text-emerald-100 shadow-lg border border-emerald-500/30'
: 'text-emerald-300 hover:bg-emerald-800/50 hover:text-emerald-100 border border-transparent hover:border-emerald-700/50'
}
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? item.label : undefined}
>
{active && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-emerald-400 to-emerald-600 rounded-r-full"></div>
)}
<Icon className={`
flex-shrink-0 transition-transform duration-200
${active ? 'text-emerald-400' : 'text-emerald-400 group-hover:text-emerald-300'}
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className={`
font-semibold transition-all duration-200
${active ? 'text-emerald-100' : 'group-hover:text-emerald-100'}
`}>
{item.label}
</span>
)}
{active && !isCollapsed && (
<div className="ml-auto w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Logout Button */}
<div className="p-4 border-t border-emerald-700/50">
<button
onClick={handleLogout}
className={`
w-full flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
text-emerald-300 hover:bg-gradient-to-r hover:from-rose-600/20 hover:to-rose-700/20
hover:text-rose-100 border border-transparent hover:border-rose-500/30
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? 'Logout' : undefined}
>
<LogOut className={`
flex-shrink-0 transition-transform duration-200
text-emerald-400 group-hover:text-rose-400 group-hover:rotate-12
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className="font-semibold transition-all duration-200 group-hover:text-rose-100">
Logout
</span>
)}
</button>
</div>
{/* Footer */}
<div className="p-4 border-t border-emerald-700/50 bg-gradient-to-r from-emerald-800/50 to-emerald-900/50 backdrop-blur-sm">
{(!isCollapsed || isMobile) ? (
<div className="text-xs text-emerald-400 text-center space-y-1">
<p className="font-semibold text-emerald-200/80">Accountant Dashboard</p>
<p className="text-emerald-500">
© {new Date().getFullYear()} Luxury Hotel
</p>
</div>
) : (
<div className="flex justify-center">
<div className="w-3 h-3 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg animate-pulse"></div>
</div>
)}
</div>
</aside>
</>
);
};
export default SidebarAccountant;

View File

@@ -0,0 +1,519 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Users,
Settings,
FileText,
BarChart3,
Globe,
ChevronLeft,
ChevronRight,
LogIn,
LogOut,
Menu,
X,
Award,
User,
Workflow,
CheckSquare,
Bell,
UserCheck,
Hotel,
Tag,
Package,
Shield,
Mail,
TrendingUp,
Building2,
Crown,
Star
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
import { logger } from '../utils/logger';
interface SidebarAdminProps {
isCollapsed?: boolean;
onToggle?: () => void;
}
interface MenuGroup {
title: string;
icon?: React.ComponentType<{ className?: string }>;
items: Array<{
path: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
}>;
}
const SidebarAdmin: React.FC<SidebarAdminProps> = ({
isCollapsed: controlledCollapsed,
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const { isMobile, isDesktop } = useResponsive();
const handleLogout = async () => {
try {
await logout();
navigate('/');
if (isMobile) {
setIsMobileOpen(false);
}
} catch (error) {
logger.error('Logout error', error);
}
};
// Close mobile menu when screen becomes desktop
useEffect(() => {
if (isDesktop) {
setIsMobileOpen(false);
}
}, [isDesktop]);
const isCollapsed =
controlledCollapsed !== undefined
? controlledCollapsed
: internalCollapsed;
const handleToggle = () => {
if (onToggle) {
onToggle();
} else {
setInternalCollapsed(!internalCollapsed);
}
};
const handleMobileToggle = () => {
setIsMobileOpen(!isMobileOpen);
};
const handleLinkClick = () => {
if (isMobile) {
setIsMobileOpen(false);
}
};
const menuGroups: MenuGroup[] = [
{
title: 'Overview',
icon: LayoutDashboard,
items: [
{
path: '/admin/dashboard',
icon: LayoutDashboard,
label: 'Dashboard'
},
]
},
{
title: 'Operations',
icon: Building2,
items: [
{
path: '/admin/reception',
icon: LogIn,
label: 'Reception'
},
{
path: '/admin/advanced-rooms',
icon: Hotel,
label: 'Room Management'
},
]
},
{
title: 'Business',
icon: TrendingUp,
items: [
{
path: '/admin/business',
icon: FileText,
label: 'Business Dashboard'
},
]
},
{
title: 'Analytics & Reports',
icon: BarChart3,
items: [
{
path: '/admin/analytics',
icon: BarChart3,
label: 'Analytics'
},
]
},
{
title: 'Users & Guests',
icon: Users,
items: [
{
path: '/admin/users',
icon: Users,
label: 'Users'
},
{
path: '/admin/guest-profiles',
icon: User,
label: 'Guest Profiles'
},
{
path: '/admin/group-bookings',
icon: UserCheck,
label: 'Group Bookings'
},
{
path: '/admin/loyalty',
icon: Award,
label: 'Loyalty Program'
},
]
},
{
title: 'Products & Pricing',
icon: Tag,
items: [
{
path: '/admin/rate-plans',
icon: Tag,
label: 'Rate Plans'
},
{
path: '/admin/packages',
icon: Package,
label: 'Packages'
},
]
},
{
title: 'Marketing',
icon: Mail,
items: [
{
path: '/admin/email-campaigns',
icon: Mail,
label: 'Email Campaigns'
},
]
},
{
title: 'Content Management',
icon: Globe,
items: [
{
path: '/admin/page-content',
icon: Globe,
label: 'Page Content'
},
{
path: '/admin/blog',
icon: FileText,
label: 'Blog Management'
},
{
path: '/admin/reviews',
icon: Star,
label: 'Reviews'
},
]
},
{
title: 'System',
icon: Settings,
items: [
{
path: '/admin/security',
icon: Shield,
label: 'Security'
},
{
path: '/admin/tasks',
icon: CheckSquare,
label: 'Tasks'
},
{
path: '/admin/workflows',
icon: Workflow,
label: 'Workflows'
},
{
path: '/admin/notifications',
icon: Bell,
label: 'Notifications'
},
{
path: '/admin/settings',
icon: Settings,
label: 'Settings'
},
]
},
];
const isActive = (path: string) => {
if (location.pathname === path) return true;
if (path === '/admin/settings' || path === '/admin/analytics' || path === '/admin/business' || path === '/admin/reception' || path === '/admin/advanced-rooms' || path === '/admin/page-content' || path === '/admin/loyalty') {
return location.pathname === path;
}
if (path === '/admin/reception' || path === '/admin/advanced-rooms') {
return location.pathname === path || location.pathname.startsWith(`${path}/`);
}
return location.pathname.startsWith(`${path}/`);
};
return (
<>
{/* Mobile Menu Button - Always visible on mobile screens */}
<button
onClick={handleMobileToggle}
className="fixed top-2 left-2 sm:top-3 sm:left-3 z-50 lg:hidden p-2.5 sm:p-3 bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 text-white rounded-xl sm:rounded-2xl shadow-2xl border border-amber-400/30 hover:from-amber-600 hover:via-amber-700 hover:to-amber-800 transition-all duration-300 backdrop-blur-sm hover:scale-110"
aria-label="Toggle menu"
>
{isMobileOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
{/* Mobile Overlay */}
{isMobileOpen && (
<div
className="fixed inset-0 bg-black/80 backdrop-blur-md z-30 lg:hidden"
onClick={handleMobileToggle}
/>
)}
<aside
className={`
bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950
text-white shadow-2xl
transition-all duration-300 ease-in-out flex flex-col
border-r border-amber-500/20
overflow-hidden
${isMobile
? `fixed inset-y-0 left-0 z-40 w-80 ${isMobileOpen ? 'translate-x-0' : '-translate-x-full'}`
: `relative ${isCollapsed ? 'w-20' : 'w-80'} translate-x-0`
}
`}
>
{/* Luxury background pattern */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
<div className="absolute inset-0" style={{
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(251, 191, 36, 0.4) 1px, transparent 0)`,
backgroundSize: '50px 50px'
}}></div>
</div>
{/* Animated gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-amber-900/5 via-transparent to-amber-800/5 pointer-events-none"></div>
{/* Header */}
<div className="relative p-6 border-b border-amber-500/20 flex items-center justify-between bg-gradient-to-r from-amber-900/30 via-amber-800/20 to-transparent backdrop-blur-sm">
{!isCollapsed && (
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-amber-400 to-amber-600 rounded-2xl blur-lg opacity-60 animate-pulse"></div>
<div className="relative h-12 w-12 bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 rounded-2xl flex items-center justify-center shadow-2xl border border-amber-400/40">
<Crown className="w-6 h-6 text-white" />
<div className="absolute -top-1 -right-1 w-3 h-3 bg-gradient-to-br from-amber-300 to-amber-500 rounded-full shadow-lg animate-ping"></div>
</div>
</div>
<div>
<h2 className="text-lg font-bold bg-gradient-to-r from-amber-200 via-amber-100 to-amber-200 bg-clip-text text-transparent tracking-wide">
Admin Panel
</h2>
<p className="text-xs text-amber-300/60 font-medium mt-0.5">Luxury Hotel</p>
</div>
</div>
)}
{isCollapsed && !isMobile && (
<div className="w-full flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-amber-400 to-amber-600 rounded-2xl blur-lg opacity-60 animate-pulse"></div>
<div className="relative h-12 w-12 bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 rounded-2xl flex items-center justify-center shadow-2xl border border-amber-400/40">
<Crown className="w-6 h-6 text-white" />
</div>
</div>
</div>
)}
{!isMobile && (
<button
onClick={handleToggle}
className="p-2.5 rounded-xl bg-slate-800/70 hover:bg-amber-500/20 border border-slate-700/50 hover:border-amber-500/50 transition-all duration-300 ml-auto shadow-lg hover:shadow-xl backdrop-blur-sm hover:scale-110"
aria-label="Toggle sidebar"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5 text-amber-300" />
) : (
<ChevronLeft className="w-5 h-5 text-amber-300" />
)}
</button>
)}
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-6 px-4 custom-scrollbar relative">
<ul className="space-y-6">
{menuGroups.map((group, groupIndex) => {
return (
<li key={groupIndex} className="space-y-2">
{!isCollapsed && (
<div className="px-4 mb-3">
<div className="flex items-center gap-2.5">
{group.icon && (
<group.icon className="w-3.5 h-3.5 text-amber-400/70" />
)}
<span className="text-[10px] font-bold uppercase tracking-widest text-amber-300/40 letter-spacing-wider">
{group.title}
</span>
</div>
<div className="h-px bg-gradient-to-r from-amber-500/30 via-amber-500/15 to-transparent mt-2.5"></div>
</div>
)}
<ul className="space-y-1.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
onClick={handleLinkClick}
className={`
flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-300 group relative overflow-hidden
${active
? 'bg-gradient-to-r from-amber-500/30 via-amber-600/25 to-amber-500/30 text-amber-50 shadow-xl shadow-amber-500/25 border border-amber-500/50'
: 'text-slate-300 hover:bg-gradient-to-r hover:from-slate-800/70 hover:via-slate-800/50 hover:to-slate-800/70 hover:text-amber-100 border border-transparent hover:border-amber-500/20'
}
${isCollapsed && !isMobile ? 'justify-center px-3' : ''}
`}
title={isCollapsed && !isMobile ? item.label : undefined}
>
{active && (
<>
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-12 bg-gradient-to-b from-amber-400 via-amber-500 to-amber-600 rounded-r-full shadow-lg shadow-amber-500/60"></div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-500/10 to-transparent"></div>
</>
)}
<div className={`
relative flex items-center justify-center z-10
${active
? 'bg-gradient-to-br from-amber-500/40 to-amber-600/30 p-2.5 rounded-xl shadow-lg'
: 'p-2.5 rounded-xl group-hover:bg-amber-500/15 transition-all duration-300'
}
`}>
<Icon className={`
flex-shrink-0 transition-all duration-300
${active
? 'text-amber-200 scale-110 drop-shadow-lg'
: 'text-slate-400 group-hover:text-amber-400 group-hover:scale-110'
}
${isCollapsed && !isMobile ? 'w-5 h-5' : 'w-4 h-4'}
`} />
</div>
{(!isCollapsed || isMobile) && (
<span className={`
font-medium text-sm transition-all duration-300 relative z-10
${active
? 'text-amber-50 font-semibold tracking-wide'
: 'group-hover:text-amber-100'
}
`}>
{item.label}
</span>
)}
{active && !isCollapsed && (
<div className="ml-auto relative z-10">
<div className="w-2 h-2 bg-amber-400 rounded-full animate-pulse shadow-lg shadow-amber-400/60"></div>
</div>
)}
</Link>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</nav>
{/* Footer */}
<div className="relative border-t border-amber-500/20 bg-gradient-to-r from-slate-900/60 to-slate-950/60 backdrop-blur-sm">
<div className="p-4">
<button
onClick={handleLogout}
className={`
w-full flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-300 group relative overflow-hidden
text-slate-300 hover:bg-gradient-to-r hover:from-rose-600/25 hover:via-rose-700/20 hover:to-rose-600/25
hover:text-rose-100 border border-transparent hover:border-rose-500/40
${isCollapsed && !isMobile ? 'justify-center px-3' : ''}
`}
title={isCollapsed && !isMobile ? 'Logout' : undefined}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-rose-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className={`
relative flex items-center justify-center z-10
p-2.5 rounded-xl group-hover:bg-rose-500/15 transition-all duration-300
`}>
<LogOut className={`
flex-shrink-0 transition-all duration-300
text-slate-400 group-hover:text-rose-400 group-hover:rotate-12
${isCollapsed && !isMobile ? 'w-5 h-5' : 'w-4 h-4'}
`} />
</div>
{(!isCollapsed || isMobile) && (
<span className="font-medium text-sm transition-all duration-300 relative z-10 group-hover:text-rose-100">
Logout
</span>
)}
</button>
</div>
<div className="px-4 pb-4">
{(!isCollapsed || isMobile) ? (
<div className="text-xs text-center space-y-2.5 p-3.5 rounded-xl bg-gradient-to-r from-amber-900/30 to-amber-800/20 border border-amber-500/15 backdrop-blur-sm">
<div className="flex items-center justify-center gap-2">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full blur-md opacity-50 animate-pulse"></div>
<div className="relative w-2 h-2 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg"></div>
</div>
<p className="font-semibold text-amber-200/90 tracking-wide">System Active</p>
</div>
<p className="text-amber-300/50 text-[10px] font-medium tracking-wider">
© {new Date().getFullYear()} Luxury Hotel Management
</p>
</div>
) : (
<div className="flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full blur-md opacity-50 animate-pulse"></div>
<div className="relative w-3 h-3 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg"></div>
</div>
</div>
)}
</div>
</div>
</aside>
</>
);
};
export default SidebarAdmin;

View File

@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
FileText,
BarChart3,
ChevronLeft,
ChevronRight,
LogIn,
LogOut,
Menu,
X,
CreditCard,
MessageCircle,
Award,
Users,
Wrench
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
import { useResponsive } from '../../hooks';
import { logger } from '../utils/logger';
interface SidebarStaffProps {
isCollapsed?: boolean;
onToggle?: () => void;
}
const SidebarStaff: React.FC<SidebarStaffProps> = ({
isCollapsed: controlledCollapsed,
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const { unreadCount } = useChatNotifications();
const { isMobile, isDesktop } = useResponsive();
const handleLogout = async () => {
try {
await logout();
navigate('/');
if (isMobile) {
setIsMobileOpen(false);
}
} catch (error) {
logger.error('Logout error', error);
}
};
// Close mobile menu when screen becomes desktop
useEffect(() => {
if (isDesktop) {
setIsMobileOpen(false);
}
}, [isDesktop]);
const isCollapsed =
controlledCollapsed !== undefined
? controlledCollapsed
: internalCollapsed;
const handleToggle = () => {
if (onToggle) {
onToggle();
} else {
setInternalCollapsed(!internalCollapsed);
}
};
const handleMobileToggle = () => {
setIsMobileOpen(!isMobileOpen);
};
const handleLinkClick = () => {
if (isMobile) {
setIsMobileOpen(false);
}
};
const menuItems = [
{
path: '/staff/dashboard',
icon: LayoutDashboard,
label: 'Dashboard'
},
{
path: '/staff/bookings',
icon: FileText,
label: 'Bookings'
},
{
path: '/staff/reception',
icon: LogIn,
label: 'Reception'
},
{
path: '/staff/payments',
icon: CreditCard,
label: 'Payments'
},
{
path: '/staff/loyalty',
icon: Award,
label: 'Loyalty Program'
},
{
path: '/staff/guest-profiles',
icon: Users,
label: 'Guest Profiles'
},
{
path: '/staff/advanced-rooms',
icon: Wrench,
label: 'Room Management'
},
{
path: '/staff/chats',
icon: MessageCircle,
label: 'Chat Support'
},
{
path: '/staff/reports',
icon: BarChart3,
label: 'Reports'
},
];
const isActive = (path: string) => {
if (location.pathname === path) return true;
return location.pathname.startsWith(`${path}/`);
};
return (
<>
{}
{isMobile && (
<button
onClick={handleMobileToggle}
className="fixed top-4 left-4 z-50 lg:hidden p-3 bg-gradient-to-r from-blue-900 to-blue-800 text-white rounded-xl shadow-2xl border border-blue-700 hover:from-blue-800 hover:to-blue-700 transition-all duration-200"
aria-label="Toggle menu"
>
{isMobileOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
)}
{}
{isMobile && isMobileOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
onClick={handleMobileToggle}
/>
)}
{}
<aside
className={`
fixed lg:static inset-y-0 left-0 z-40
bg-gradient-to-b from-blue-900 via-blue-800 to-blue-900
text-white shadow-2xl
transition-all duration-300 ease-in-out flex flex-col
${isMobile
? (isMobileOpen ? 'translate-x-0' : '-translate-x-full')
: ''
}
${!isMobile && (isCollapsed ? 'w-20' : 'w-72')}
${isMobile ? 'w-72' : ''}
border-r border-blue-700/50
`}
>
{}
<div className="p-6 border-b border-blue-700/50 flex items-center justify-between bg-gradient-to-r from-blue-800/50 to-blue-900/50 backdrop-blur-sm">
{!isCollapsed && (
<div className="flex items-center gap-3">
<div className="h-1 w-12 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full"></div>
<h2 className="text-xl font-bold bg-gradient-to-r from-blue-100 to-blue-200 bg-clip-text text-transparent">
Staff Panel
</h2>
</div>
)}
{isCollapsed && !isMobile && (
<div className="w-full flex justify-center">
<div className="h-8 w-8 bg-gradient-to-br from-blue-400 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
<span className="text-white font-bold text-sm">S</span>
</div>
</div>
)}
{!isMobile && (
<button
onClick={handleToggle}
className="p-2.5 rounded-xl bg-blue-800/50 hover:bg-blue-700/50 border border-blue-700/50 hover:border-blue-500/50 transition-all duration-200 ml-auto shadow-lg hover:shadow-xl"
aria-label="Toggle sidebar"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5 text-blue-200" />
) : (
<ChevronLeft className="w-5 h-5 text-blue-200" />
)}
</button>
)}
</div>
{}
<nav className="flex-1 overflow-y-auto py-4 px-3 custom-scrollbar">
<ul className="space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
onClick={handleLinkClick}
className={`
flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
${active
? 'bg-gradient-to-r from-blue-500/20 to-blue-600/20 text-blue-100 shadow-lg border border-blue-500/30'
: 'text-blue-300 hover:bg-blue-800/50 hover:text-blue-100 border border-transparent hover:border-blue-700/50'
}
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? item.label : undefined}
>
{active && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-blue-400 to-blue-600 rounded-r-full"></div>
)}
<Icon className={`
flex-shrink-0 transition-transform duration-200
${active ? 'text-blue-400' : 'text-blue-400 group-hover:text-blue-300'}
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className={`
font-semibold transition-all duration-200
${active ? 'text-blue-100' : 'group-hover:text-blue-100'}
`}>
{item.label}
</span>
)}
{item.path === '/staff/chats' && unreadCount > 0 && (
<span className="ml-auto bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
{active && !isCollapsed && item.path !== '/staff/chats' && (
<div className="ml-auto w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{}
<div className="p-4 border-t border-blue-700/50">
<button
onClick={handleLogout}
className={`
w-full flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
text-blue-300 hover:bg-gradient-to-r hover:from-rose-600/20 hover:to-rose-700/20
hover:text-rose-100 border border-transparent hover:border-rose-500/30
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? 'Logout' : undefined}
>
<LogOut className={`
flex-shrink-0 transition-transform duration-200
text-blue-400 group-hover:text-rose-400 group-hover:rotate-12
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className="font-semibold transition-all duration-200 group-hover:text-rose-100">
Logout
</span>
)}
</button>
</div>
{}
<div className="p-4 border-t border-blue-700/50 bg-gradient-to-r from-blue-800/50 to-blue-900/50 backdrop-blur-sm">
{(!isCollapsed || isMobile) ? (
<div className="text-xs text-blue-400 text-center space-y-1">
<p className="font-semibold text-blue-200/80">Staff Dashboard</p>
<p className="text-blue-500">
© {new Date().getFullYear()} Luxury Hotel
</p>
</div>
) : (
<div className="flex justify-center">
<div className="w-3 h-3 bg-gradient-to-br from-blue-400 to-blue-600 rounded-full shadow-lg animate-pulse"></div>
</div>
)}
</div>
</aside>
</>
);
};
export default SidebarStaff;

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,167 @@
/**
* Tests for Recaptcha component
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import Recaptcha from '../Recaptcha';
import * as systemSettingsService from '../../../features/system/services/systemSettingsService';
// Mock the reCAPTCHA service
vi.mock('../../../services/api/systemSettingsService', () => ({
recaptchaService: {
getRecaptchaSettings: vi.fn(),
},
}));
// Mock react-google-recaptcha
vi.mock('react-google-recaptcha', () => ({
default: ({ sitekey, onChange, onExpired, onError, theme, size }: any) => (
<div data-testid="recaptcha-mock">
<div>Site Key: {sitekey}</div>
<div>Theme: {theme}</div>
<div>Size: {size}</div>
<button onClick={() => onChange && onChange('test-token')}>Verify</button>
<button onClick={() => onExpired && onExpired()}>Expire</button>
<button onClick={() => onError && onError()}>Error</button>
</div>
),
}));
describe('Recaptcha', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders nothing when reCAPTCHA is disabled', async () => {
vi.mocked(systemSettingsService.recaptchaService.getRecaptchaSettings).mockResolvedValue({
status: 'success',
data: {
recaptcha_enabled: false,
recaptcha_site_key: 'test-key',
},
});
const { container } = render(<Recaptcha />);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
});
it('renders reCAPTCHA when enabled', async () => {
vi.mocked(systemSettingsService.recaptchaService.getRecaptchaSettings).mockResolvedValue({
status: 'success',
data: {
recaptcha_enabled: true,
recaptcha_site_key: 'test-site-key',
},
});
render(<Recaptcha />);
await waitFor(() => {
expect(screen.getByTestId('recaptcha-mock')).toBeInTheDocument();
expect(screen.getByText('Site Key: test-site-key')).toBeInTheDocument();
});
});
it('calls onChange callback when token is received', async () => {
const onChange = vi.fn();
vi.mocked(systemSettingsService.recaptchaService.getRecaptchaSettings).mockResolvedValue({
status: 'success',
data: {
recaptcha_enabled: true,
recaptcha_site_key: 'test-key',
},
});
render(<Recaptcha onChange={onChange} />);
await waitFor(() => {
expect(screen.getByTestId('recaptcha-mock')).toBeInTheDocument();
});
const verifyButton = screen.getByText('Verify');
verifyButton.click();
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('test-token');
});
});
it('calls onError callback when error occurs', async () => {
const onError = vi.fn();
vi.mocked(systemSettingsService.recaptchaService.getRecaptchaSettings).mockResolvedValue({
status: 'success',
data: {
recaptcha_enabled: true,
recaptcha_site_key: 'test-key',
},
});
render(<Recaptcha onError={onError} />);
await waitFor(() => {
expect(screen.getByTestId('recaptcha-mock')).toBeInTheDocument();
});
const errorButton = screen.getByText('Error');
errorButton.click();
await waitFor(() => {
expect(onError).toHaveBeenCalledWith('reCAPTCHA error occurred');
});
});
it('uses cached settings on subsequent renders', async () => {
vi.mocked(systemSettingsService.recaptchaService.getRecaptchaSettings).mockResolvedValue({
status: 'success',
data: {
recaptcha_enabled: true,
recaptcha_site_key: 'cached-key',
},
});
// First render
const { unmount } = render(<Recaptcha />);
await waitFor(() => {
expect(screen.getByText('Site Key: cached-key')).toBeInTheDocument();
});
unmount();
// Second render should use cache
render(<Recaptcha />);
await waitFor(() => {
expect(screen.getByText('Site Key: cached-key')).toBeInTheDocument();
});
// Should only call API once
expect(systemSettingsService.recaptchaService.getRecaptchaSettings).toHaveBeenCalledTimes(1);
});
it('applies custom theme and size props', async () => {
vi.mocked(systemSettingsService.recaptchaService.getRecaptchaSettings).mockResolvedValue({
status: 'success',
data: {
recaptcha_enabled: true,
recaptcha_site_key: 'test-key',
},
});
render(<Recaptcha theme="light" size="compact" />);
await waitFor(() => {
expect(screen.getByText('Theme: light')).toBeInTheDocument();
expect(screen.getByText('Size: compact')).toBeInTheDocument();
});
});
});

View File

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