update
This commit is contained in:
116
Frontend/src/shared/components/AnalyticsLoader.tsx
Normal file
116
Frontend/src/shared/components/AnalyticsLoader.tsx
Normal 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;
|
||||
|
||||
162
Frontend/src/shared/components/ConfirmationDialog.tsx
Normal file
162
Frontend/src/shared/components/ConfirmationDialog.tsx
Normal 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;
|
||||
|
||||
199
Frontend/src/shared/components/CookieConsentBanner.tsx
Normal file
199
Frontend/src/shared/components/CookieConsentBanner.tsx
Normal 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;
|
||||
|
||||
20
Frontend/src/shared/components/CookiePreferencesLink.tsx
Normal file
20
Frontend/src/shared/components/CookiePreferencesLink.tsx
Normal 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;
|
||||
|
||||
223
Frontend/src/shared/components/CookiePreferencesModal.tsx
Normal file
223
Frontend/src/shared/components/CookiePreferencesModal.tsx
Normal 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;
|
||||
|
||||
37
Frontend/src/shared/components/CurrencyIcon.tsx
Normal file
37
Frontend/src/shared/components/CurrencyIcon.tsx
Normal 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;
|
||||
|
||||
126
Frontend/src/shared/components/CurrencySelector.tsx
Normal file
126
Frontend/src/shared/components/CurrencySelector.tsx
Normal 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;
|
||||
|
||||
100
Frontend/src/shared/components/EmptyState.tsx
Normal file
100
Frontend/src/shared/components/EmptyState.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
secondaryAction?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm
|
||||
p-12 text-center ${className}`}
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className="w-24 h-24 bg-gray-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-6"
|
||||
>
|
||||
<Icon className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3
|
||||
className="text-2xl font-bold
|
||||
text-gray-900 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p
|
||||
className="text-gray-600 mb-6
|
||||
max-w-md mx-auto"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{(action || secondaryAction) && (
|
||||
<div
|
||||
className="flex flex-col sm:flex-row
|
||||
gap-3 justify-center mt-6"
|
||||
>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="px-6 py-3 bg-indigo-600
|
||||
text-white rounded-lg
|
||||
hover:bg-indigo-700
|
||||
transition-colors font-semibold
|
||||
inline-flex items-center
|
||||
justify-center gap-2"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{secondaryAction && (
|
||||
<button
|
||||
onClick={secondaryAction.onClick}
|
||||
className="px-6 py-3 border
|
||||
border-gray-300 text-gray-700
|
||||
rounded-lg hover:bg-gray-50
|
||||
transition-colors font-semibold
|
||||
inline-flex items-center
|
||||
justify-center gap-2"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
147
Frontend/src/shared/components/ErrorBoundary.tsx
Normal file
147
Frontend/src/shared/components/ErrorBoundary.tsx
Normal 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;
|
||||
25
Frontend/src/shared/components/ErrorBoundaryRoute.tsx
Normal file
25
Frontend/src/shared/components/ErrorBoundaryRoute.tsx
Normal 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;
|
||||
|
||||
53
Frontend/src/shared/components/ErrorMessage.tsx
Normal file
53
Frontend/src/shared/components/ErrorMessage.tsx
Normal 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;
|
||||
|
||||
186
Frontend/src/shared/components/ExportButton.tsx
Normal file
186
Frontend/src/shared/components/ExportButton.tsx
Normal 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;
|
||||
|
||||
435
Frontend/src/shared/components/Footer.tsx
Normal file
435
Frontend/src/shared/components/Footer.tsx
Normal 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;
|
||||
30
Frontend/src/shared/components/GlobalLoading.tsx
Normal file
30
Frontend/src/shared/components/GlobalLoading.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface GlobalLoadingProps {
|
||||
isLoading: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const GlobalLoading: React.FC<GlobalLoadingProps> = ({
|
||||
isLoading,
|
||||
text = 'Loading...',
|
||||
}) => {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-white bg-opacity-75 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
||||
<p className="text-sm font-medium text-gray-700">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalLoading;
|
||||
|
||||
538
Frontend/src/shared/components/Header.tsx
Normal file
538
Frontend/src/shared/components/Header.tsx
Normal 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;
|
||||
52
Frontend/src/shared/components/HoneypotField.tsx
Normal file
52
Frontend/src/shared/components/HoneypotField.tsx
Normal 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;
|
||||
|
||||
44
Frontend/src/shared/components/LayoutMain.tsx
Normal file
44
Frontend/src/shared/components/LayoutMain.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
interface LayoutMainProps {
|
||||
isAuthenticated?: boolean;
|
||||
userInfo?: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
} | null;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
const LayoutMain: React.FC<LayoutMainProps> = ({
|
||||
isAuthenticated = false,
|
||||
userInfo = null,
|
||||
onLogout
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{}
|
||||
<Header
|
||||
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;
|
||||
60
Frontend/src/shared/components/Loading.tsx
Normal file
60
Frontend/src/shared/components/Loading.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
text?: string;
|
||||
fullScreen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Loading: React.FC<LoadingProps> = ({
|
||||
size = 'md',
|
||||
text = 'Loading...',
|
||||
fullScreen = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className={`flex flex-col items-center
|
||||
justify-center gap-3 ${className}`}
|
||||
>
|
||||
<Loader2
|
||||
className={`${sizeClasses[size]}
|
||||
text-indigo-600 animate-spin`}
|
||||
/>
|
||||
{text && (
|
||||
<p className={`${textSizeClasses[size]}
|
||||
text-gray-600 font-medium`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50
|
||||
flex items-center justify-center"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
52
Frontend/src/shared/components/LoadingButton.tsx
Normal file
52
Frontend/src/shared/components/LoadingButton.tsx
Normal 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;
|
||||
|
||||
135
Frontend/src/shared/components/Navbar.tsx
Normal file
135
Frontend/src/shared/components/Navbar.tsx
Normal 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;
|
||||
|
||||
58
Frontend/src/shared/components/OfflineIndicator.tsx
Normal file
58
Frontend/src/shared/components/OfflineIndicator.tsx
Normal 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;
|
||||
|
||||
86
Frontend/src/shared/components/OptimizedImage.tsx
Normal file
86
Frontend/src/shared/components/OptimizedImage.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
ImgHTMLAttributes
|
||||
} from 'react';
|
||||
|
||||
interface OptimizedImageProps
|
||||
extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
fallbackSrc?: string;
|
||||
aspectRatio?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
fallbackSrc = '/images/placeholder.jpg',
|
||||
aspectRatio,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const [imageSrc, setImageSrc] = useState<string>(src);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImageSrc(src);
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
}, [src]);
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
console.error(`Failed to load image: ${imageSrc}`);
|
||||
setImageSrc(fallbackSrc);
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden
|
||||
bg-gray-200 ${className}`}
|
||||
style={
|
||||
aspectRatio
|
||||
? { aspectRatio }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<div
|
||||
className="absolute inset-0
|
||||
flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 border-4
|
||||
border-gray-300 border-t-indigo-600
|
||||
rounded-full animate-spin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className={`
|
||||
w-full h-full object-cover
|
||||
transition-opacity duration-300
|
||||
${isLoading ? 'opacity-0' : 'opacity-100'}
|
||||
${hasError ? 'opacity-50' : ''}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptimizedImage;
|
||||
162
Frontend/src/shared/components/Pagination.tsx
Normal file
162
Frontend/src/shared/components/Pagination.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalItems?: number;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
totalItems,
|
||||
itemsPerPage = 5,
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisiblePages = 5;
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems || 0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
|
||||
{}
|
||||
<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;
|
||||
189
Frontend/src/shared/components/PaymentMethodSelector.tsx
Normal file
189
Frontend/src/shared/components/PaymentMethodSelector.tsx
Normal 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;
|
||||
98
Frontend/src/shared/components/PaymentStatusBadge.tsx
Normal file
98
Frontend/src/shared/components/PaymentStatusBadge.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface PaymentStatusBadgeProps {
|
||||
status: 'pending' | 'completed' | 'failed' | 'unpaid' | 'paid' | 'refunded';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
const PaymentStatusBadge: React.FC<PaymentStatusBadgeProps> = ({
|
||||
status,
|
||||
size = 'md',
|
||||
showIcon = true,
|
||||
}) => {
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
case 'completed':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-100 text-green-800',
|
||||
text: 'Paid',
|
||||
};
|
||||
case 'unpaid':
|
||||
case 'pending':
|
||||
return {
|
||||
icon: Clock,
|
||||
color: 'bg-yellow-100 text-yellow-800',
|
||||
text: 'Unpaid',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: 'bg-red-100 text-red-800',
|
||||
text: 'Payment Failed',
|
||||
};
|
||||
case 'refunded':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: 'Refunded',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: status,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'text-xs px-2 py-1';
|
||||
case 'lg':
|
||||
return 'text-base px-4 py-2';
|
||||
case 'md':
|
||||
default:
|
||||
return 'text-sm px-3 py-1.5';
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'w-3 h-3';
|
||||
case 'lg':
|
||||
return 'w-5 h-5';
|
||||
case 'md':
|
||||
default:
|
||||
return 'w-4 h-4';
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5
|
||||
rounded-full font-medium
|
||||
${config.color} ${getSizeClasses()}`}
|
||||
>
|
||||
{showIcon && (
|
||||
<StatusIcon className={getIconSize()} />
|
||||
)}
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentStatusBadge;
|
||||
87
Frontend/src/shared/components/Preloader.tsx
Normal file
87
Frontend/src/shared/components/Preloader.tsx
Normal 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;
|
||||
|
||||
175
Frontend/src/shared/components/Recaptcha.tsx
Normal file
175
Frontend/src/shared/components/Recaptcha.tsx
Normal 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;
|
||||
|
||||
17
Frontend/src/shared/components/ScrollToTop.tsx
Normal file
17
Frontend/src/shared/components/ScrollToTop.tsx
Normal 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;
|
||||
|
||||
278
Frontend/src/shared/components/SidebarAccountant.tsx
Normal file
278
Frontend/src/shared/components/SidebarAccountant.tsx
Normal 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;
|
||||
|
||||
519
Frontend/src/shared/components/SidebarAdmin.tsx
Normal file
519
Frontend/src/shared/components/SidebarAdmin.tsx
Normal 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;
|
||||
315
Frontend/src/shared/components/SidebarStaff.tsx
Normal file
315
Frontend/src/shared/components/SidebarStaff.tsx
Normal 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;
|
||||
|
||||
47
Frontend/src/shared/components/Skeleton.tsx
Normal file
47
Frontend/src/shared/components/Skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
variant?: 'text' | 'circular' | 'rectangular';
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
const Skeleton: React.FC<SkeletonProps> = ({
|
||||
width,
|
||||
height,
|
||||
className = '',
|
||||
variant = 'rectangular',
|
||||
animation = 'pulse',
|
||||
}) => {
|
||||
const baseClasses = 'bg-gray-200';
|
||||
|
||||
const variantClasses = {
|
||||
text: 'h-4 rounded',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded',
|
||||
};
|
||||
|
||||
const animationClasses = {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'animate-shimmer',
|
||||
none: '',
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
|
||||
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${animationClasses[animation]} ${className}`}
|
||||
style={style}
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Skeleton;
|
||||
|
||||
167
Frontend/src/shared/components/__tests__/Recaptcha.test.tsx
Normal file
167
Frontend/src/shared/components/__tests__/Recaptcha.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
7
Frontend/src/shared/components/index.ts
Normal file
7
Frontend/src/shared/components/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user