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

View File

@@ -0,0 +1,244 @@
import React from 'react';
import {
Hotel,
Award,
Users,
Heart,
MapPin,
Phone,
Mail,
Star,
Shield,
Clock
} from 'lucide-react';
import { Link } from 'react-router-dom';
const AboutPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
{/* Hero Section */}
<div className="relative bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] text-white py-20">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto text-center animate-fade-in">
<div className="flex justify-center mb-6">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-2xl opacity-30"></div>
<Hotel className="relative w-20 h-20 text-[#d4af37] drop-shadow-lg" />
</div>
</div>
<h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight">
About Luxury Hotel
</h1>
<p className="text-xl text-gray-300 font-light leading-relaxed">
Where Excellence Meets Unforgettable Experiences
</p>
</div>
</div>
</div>
{/* Our Story Section */}
<section className="py-16 bg-white">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Our Story
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div>
<div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up">
<p>
Welcome to Luxury Hotel, where timeless elegance meets modern sophistication.
Since our founding, we have been dedicated to providing exceptional hospitality
and creating unforgettable memories for our guests.
</p>
<p>
Nestled in the heart of the city, our hotel combines classic architecture with
contemporary amenities, offering a perfect blend of comfort and luxury. Every
detail has been carefully curated to ensure your stay exceeds expectations.
</p>
<p>
Our commitment to excellence extends beyond our beautiful rooms and facilities.
We believe in creating meaningful connections with our guests, understanding
their needs, and delivering personalized service that makes each visit special.
</p>
</div>
</div>
</div>
</section>
{/* Values Section */}
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Our Values
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{[
{
icon: Heart,
title: 'Passion',
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
},
{
icon: Award,
title: 'Excellence',
description: 'We strive for excellence in every aspect of our service, from the smallest detail to the grandest gesture.'
},
{
icon: Shield,
title: 'Integrity',
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
},
{
icon: Users,
title: 'Service',
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
}
].map((value, index) => (
<div
key={value.title}
className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up"
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-lg flex items-center justify-center mb-4">
<value.icon className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
{value.title}
</h3>
<p className="text-gray-600 leading-relaxed">
{value.description}
</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-16 bg-white">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Why Choose Us
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
icon: Star,
title: 'Premium Accommodations',
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
},
{
icon: Clock,
title: '24/7 Service',
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
},
{
icon: Award,
title: 'Award-Winning',
description: 'Recognized for excellence in hospitality and guest satisfaction.'
}
].map((feature, index) => (
<div
key={feature.title}
className="text-center p-6 animate-slide-up"
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<feature.icon className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">
{feature.title}
</h3>
<p className="text-gray-600 leading-relaxed">
{feature.description}
</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* Contact Section */}
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Get In Touch
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
<p className="text-gray-600 mt-4">
We'd love to hear from you. Contact us for reservations or inquiries.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up">
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<MapPin className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Address
</h3>
<p className="text-gray-600">
123 Luxury Street<br />
City, State 12345<br />
Country
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<Phone className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Phone
</h3>
<p className="text-gray-600">
<a href="tel:+1234567890" className="hover:text-[#d4af37] transition-colors">
+1 (234) 567-890
</a>
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<Mail className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Email
</h3>
<p className="text-gray-600">
<a href="mailto:info@luxuryhotel.com" className="hover:text-[#d4af37] transition-colors">
info@luxuryhotel.com
</a>
</p>
</div>
</div>
<div className="text-center mt-12 animate-fade-in">
<Link
to="/rooms"
className="inline-flex items-center space-x-2 px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
>
<span>Explore Our Rooms</span>
<Hotel className="w-5 h-5" />
</Link>
</div>
</div>
</div>
</section>
</div>
);
};
export default AboutPage;

View File

@@ -137,44 +137,50 @@ const HomePage: React.FC = () => {
}, []);
return (
<div className="min-h-screen bg-gray-50">
{/* Banner Section */}
<section className="container mx-auto px-4 pb-8">
<>
{/* Banner Section - Full Width, breaks out of container */}
<section
className="relative w-screen -mt-6"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)'
}}
>
{isLoadingBanners ? (
<BannerSkeleton />
) : (
<BannerCarousel banners={banners} />
<div className="animate-fade-in">
<BannerCarousel banners={banners}>
<SearchRoomForm className="overlay" />
</BannerCarousel>
</div>
)}
</section>
{/* Search Section */}
<section className="container mx-auto px-4 py-8">
<SearchRoomForm />
</section>
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100/50 to-gray-50">
{/* Featured Rooms Section */}
<section className="container mx-auto px-4 py-12">
<section className="container mx-auto px-4 py-16">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
<h2 className="luxury-section-title">
Featured Rooms
</h2>
<p className="luxury-section-subtitle">
Discover our most popular accommodations
</p>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
btn-luxury-secondary group text-white"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
@@ -193,21 +199,25 @@ const HomePage: React.FC = () => {
{/* Error State */}
{error && !isLoadingRooms && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-6 text-center"
className="luxury-card p-8 text-center animate-fade-in
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium">
<div className="inline-flex items-center justify-center w-16 h-16
bg-red-100 rounded-full mb-4">
<AlertCircle
className="w-8 h-8 text-red-600"
/>
</div>
<p className="text-red-800 font-serif font-semibold text-lg mb-2 tracking-tight">
{error}
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
text-white rounded-sm font-medium tracking-wide
hover:from-red-700 hover:to-red-800
transition-all duration-300 shadow-lg shadow-red-500/30
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
>
Try Again
</button>
@@ -228,10 +238,9 @@ const HomePage: React.FC = () => {
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
className="luxury-card p-12 text-center animate-fade-in"
>
<p className="text-gray-600 text-lg">
<p className="text-gray-600 text-lg font-light tracking-wide">
No featured rooms available
</p>
</div>
@@ -239,16 +248,13 @@ const HomePage: React.FC = () => {
{/* View All Button (Mobile) */}
{featuredRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<div className="mt-10 text-center md:hidden animate-slide-up">
<Link
to="/rooms"
className="inline-flex items-center gap-2
bg-indigo-600 text-white px-6 py-3
rounded-lg hover:bg-indigo-700
transition-colors font-semibold"
className="btn-luxury-primary inline-flex items-center gap-2"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<span className="relative z-10">View All Rooms</span>
<ArrowRight className="w-5 h-5 relative z-10" />
</Link>
</div>
)}
@@ -257,28 +263,27 @@ const HomePage: React.FC = () => {
</section>
{/* Newest Rooms Section */}
<section className="container mx-auto px-4 py-12">
<section className="container mx-auto px-4 py-16">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
<h2 className="luxury-section-title">
Newest Rooms
</h2>
<p className="luxury-section-subtitle">
Explore our latest additions
</p>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
btn-luxury-secondary group text-white"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
@@ -308,10 +313,9 @@ const HomePage: React.FC = () => {
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
className="luxury-card p-12 text-center animate-fade-in"
>
<p className="text-gray-600 text-lg">
<p className="text-gray-600 text-lg font-light tracking-wide">
No new rooms available
</p>
</div>
@@ -319,16 +323,13 @@ const HomePage: React.FC = () => {
{/* View All Button (Mobile) */}
{newestRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<div className="mt-10 text-center md:hidden animate-slide-up">
<Link
to="/rooms"
className="inline-flex items-center gap-2
bg-indigo-600 text-white px-6 py-3
rounded-lg hover:bg-indigo-700
transition-colors font-semibold"
className="btn-luxury-primary inline-flex items-center gap-2"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<span className="relative z-10">View All Rooms</span>
<ArrowRight className="w-5 h-5 relative z-10" />
</Link>
</div>
)}
@@ -337,73 +338,79 @@ const HomePage: React.FC = () => {
</section>
{/* Features Section */}
<section
className="container mx-auto px-4 py-12
bg-white rounded-xl shadow-sm mx-4"
>
<div
className="grid grid-cols-1 md:grid-cols-3
gap-8"
>
<div className="text-center">
<div
className="w-16 h-16 bg-indigo-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<span className="text-3xl">🏨</span>
<section className="container mx-auto px-4 py-16">
<div className="luxury-card-gold p-12 animate-fade-in relative overflow-hidden">
{/* Decorative gold accent */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e]"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
<div className="text-center group">
<div
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-sm flex items-center justify-center mx-auto mb-6
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
transition-all duration-300"
>
<span className="text-4xl">🏨</span>
</div>
<h3
className="text-xl font-serif font-semibold mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
>
Easy Booking
</h3>
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
Search and book rooms with just a few clicks
</p>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
>
Easy Booking
</h3>
<p className="text-gray-600">
Search and book rooms with just a few clicks
</p>
</div>
<div className="text-center">
<div
className="w-16 h-16 bg-green-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<span className="text-3xl">💰</span>
<div className="text-center group">
<div
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-sm flex items-center justify-center mx-auto mb-6
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
transition-all duration-300"
>
<span className="text-4xl">💰</span>
</div>
<h3
className="text-xl font-serif font-semibold mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
>
Best Prices
</h3>
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
Best price guarantee in the market
</p>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
>
Best Prices
</h3>
<p className="text-gray-600">
Best price guarantee in the market
</p>
</div>
<div className="text-center">
<div
className="w-16 h-16 bg-blue-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<span className="text-3xl">🎧</span>
<div className="text-center group">
<div
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-sm flex items-center justify-center mx-auto mb-6
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
transition-all duration-300"
>
<span className="text-4xl">🎧</span>
</div>
<h3
className="text-xl font-serif font-semibold mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
>
24/7 Support
</h3>
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
Support team always ready to serve
</p>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
>
24/7 Support
</h3>
<p className="text-gray-600">
Support team always ready to serve
</p>
</div>
</div>
</section>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,446 @@
import React, { useState, useEffect } from 'react';
import {
FileText,
Search,
Filter,
Eye,
Calendar,
User,
Activity,
AlertCircle,
CheckCircle,
XCircle,
Info
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import Pagination from '../../components/common/Pagination';
import { auditService, AuditLog, AuditLogFilters } from '../../services/api/auditService';
import { formatDate } from '../../utils/format';
const AuditLogsPage: React.FC = () => {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [showDetails, setShowDetails] = useState(false);
const itemsPerPage = 20;
const [filters, setFilters] = useState<AuditLogFilters>({
page: 1,
limit: itemsPerPage,
});
useEffect(() => {
fetchLogs();
}, [filters, currentPage]);
useEffect(() => {
setFilters(prev => ({ ...prev, page: currentPage }));
}, [currentPage]);
const fetchLogs = async () => {
try {
setLoading(true);
const response = await auditService.getAuditLogs({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setLogs(response.data.logs);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
console.error('Error fetching audit logs:', error);
toast.error(error.response?.data?.message || 'Unable to load audit logs');
} finally {
setLoading(false);
}
};
const handleFilterChange = (key: keyof AuditLogFilters, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1);
};
const handleSearch = (searchTerm: string) => {
handleFilterChange('search', searchTerm || undefined);
};
const handleViewDetails = (log: AuditLog) => {
setSelectedLog(log);
setShowDetails(true);
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'failed':
return <XCircle className="w-5 h-5 text-red-500" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-orange-500" />;
default:
return <Info className="w-5 h-5 text-gray-500" />;
}
};
const getStatusBadge = (status: string) => {
const baseClasses = "px-2 py-1 rounded-full text-xs font-medium";
switch (status) {
case 'success':
return `${baseClasses} bg-green-100 text-green-800`;
case 'failed':
return `${baseClasses} bg-red-100 text-red-800`;
case 'error':
return `${baseClasses} bg-orange-100 text-orange-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
};
if (loading && logs.length === 0) {
return <Loading fullScreen text="Loading audit logs..." />;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Audit Logs</h1>
<p className="text-gray-600">View all system activity and actions</p>
</div>
{/* Filters */}
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-2 mb-4">
<Filter className="w-5 h-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search actions, types..."
value={filters.search || ''}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Action
</label>
<input
type="text"
placeholder="Filter by action"
value={filters.action || ''}
onChange={(e) => handleFilterChange('action', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Resource Type
</label>
<input
type="text"
placeholder="Filter by type"
value={filters.resource_type || ''}
onChange={(e) => handleFilterChange('resource_type', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="">All</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<input
type="date"
value={filters.start_date || ''}
onChange={(e) => handleFilterChange('start_date', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<input
type="date"
value={filters.end_date || ''}
onChange={(e) => handleFilterChange('end_date', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Logs</p>
<p className="text-2xl font-bold text-gray-900">{totalItems}</p>
</div>
<FileText className="w-8 h-8 text-[#d4af37]" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Current Page</p>
<p className="text-2xl font-bold text-gray-900">{currentPage}</p>
</div>
<Activity className="w-8 h-8 text-blue-500" />
</div>
</div>
</div>
{/* Logs Table */}
{logs.length === 0 ? (
<EmptyState
title="No Audit Logs Found"
description="No audit logs match your current filters."
/>
) : (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Resource
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP Address
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
#{log.id}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{log.action}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{log.resource_type}</div>
{log.resource_id && (
<div className="text-xs text-gray-500">ID: {log.resource_id}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{log.user ? (
<div>
<div className="text-sm font-medium text-gray-900">{log.user.full_name}</div>
<div className="text-xs text-gray-500">{log.user.email}</div>
</div>
) : (
<span className="text-sm text-gray-400">System</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center space-x-2">
{getStatusIcon(log.status)}
<span className={getStatusBadge(log.status)}>
{log.status}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.ip_address || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(log.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleViewDetails(log)}
className="text-[#d4af37] hover:text-[#c9a227] flex items-center space-x-1"
>
<Eye className="w-4 h-4" />
<span>View</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Details Modal */}
{showDetails && selectedLog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-gray-900">Audit Log Details</h2>
<button
onClick={() => setShowDetails(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">ID</label>
<p className="mt-1 text-sm text-gray-900">#{selectedLog.id}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<div className="mt-1 flex items-center space-x-2">
{getStatusIcon(selectedLog.status)}
<span className={getStatusBadge(selectedLog.status)}>
{selectedLog.status}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Action</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.action}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Resource Type</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_type}</p>
</div>
{selectedLog.resource_id && (
<div>
<label className="block text-sm font-medium text-gray-700">Resource ID</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_id}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">Date</label>
<p className="mt-1 text-sm text-gray-900">{formatDate(selectedLog.created_at)}</p>
</div>
{selectedLog.user && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">User</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.user.full_name}</p>
<p className="text-xs text-gray-500">{selectedLog.user.email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">User ID</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.user_id}</p>
</div>
</>
)}
{selectedLog.ip_address && (
<div>
<label className="block text-sm font-medium text-gray-700">IP Address</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.ip_address}</p>
</div>
)}
{selectedLog.request_id && (
<div>
<label className="block text-sm font-medium text-gray-700">Request ID</label>
<p className="mt-1 text-sm text-gray-900 font-mono text-xs">{selectedLog.request_id}</p>
</div>
)}
</div>
{selectedLog.user_agent && (
<div>
<label className="block text-sm font-medium text-gray-700">User Agent</label>
<p className="mt-1 text-sm text-gray-900 break-all">{selectedLog.user_agent}</p>
</div>
)}
{selectedLog.error_message && (
<div>
<label className="block text-sm font-medium text-red-700">Error Message</label>
<p className="mt-1 text-sm text-red-900 bg-red-50 p-3 rounded">{selectedLog.error_message}</p>
</div>
)}
{selectedLog.details && (
<div>
<label className="block text-sm font-medium text-gray-700">Details</label>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-x-auto">
{JSON.stringify(selectedLog.details, null, 2)}
</pre>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowDetails(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AuditLogsPage;

View File

@@ -0,0 +1,677 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Image as ImageIcon, Eye, EyeOff, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { ConfirmationDialog } from '../../components/common';
import bannerServiceModule from '../../services/api/bannerService';
import type { Banner } from '../../services/api/bannerService';
// Extract functions from default export - workaround for TypeScript cache issue
// All functions are properly exported in bannerService.ts
const {
getAllBanners,
createBanner,
updateBanner,
deleteBanner,
uploadBannerImage,
} = bannerServiceModule as any;
const BannerManagementPage: React.FC = () => {
const [banners, setBanners] = useState<Banner[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; id: number | null }>({
show: false,
id: null,
});
const [filters, setFilters] = useState({
search: '',
position: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const itemsPerPage = 10;
const [formData, setFormData] = useState({
title: '',
description: '',
image_url: '',
link: '',
position: 'home',
display_order: 0,
is_active: true,
start_date: '',
end_date: '',
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [useFileUpload, setUseFileUpload] = useState(true);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchBanners();
}, [filters, currentPage]);
const fetchBanners = async () => {
try {
setLoading(true);
const response = await getAllBanners({
position: filters.position || undefined,
page: currentPage,
limit: itemsPerPage,
});
let allBanners = response.data?.banners || [];
// Filter by search if provided
if (filters.search) {
allBanners = allBanners.filter((banner: Banner) =>
banner.title.toLowerCase().includes(filters.search.toLowerCase())
);
}
setBanners(allBanners);
setTotalPages(Math.ceil(allBanners.length / itemsPerPage));
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load banners');
} finally {
setLoading(false);
}
};
const handleImageFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('Image size must be less than 5MB');
return;
}
setImageFile(file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
// Upload image immediately
try {
setUploadingImage(true);
const response = await uploadBannerImage(file);
if (response.status === 'success' || response.success) {
setFormData({ ...formData, image_url: response.data.image_url });
toast.success('Image uploaded successfully');
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to upload image');
setImageFile(null);
setImagePreview(null);
} finally {
setUploadingImage(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate image URL or file
if (!formData.image_url && !imageFile) {
toast.error('Please upload an image or provide an image URL');
return;
}
try {
// If there's a file but no URL yet, upload it first
let imageUrl = formData.image_url;
if (imageFile && !imageUrl) {
setUploadingImage(true);
const uploadResponse = await uploadBannerImage(imageFile);
if (uploadResponse.status === 'success' || uploadResponse.success) {
imageUrl = uploadResponse.data.image_url;
} else {
throw new Error('Failed to upload image');
}
setUploadingImage(false);
}
const submitData = {
...formData,
image_url: imageUrl,
start_date: formData.start_date || undefined,
end_date: formData.end_date || undefined,
};
if (editingBanner) {
await updateBanner(editingBanner.id, submitData);
toast.success('Banner updated successfully');
} else {
await createBanner(submitData);
toast.success('Banner created successfully');
}
setShowModal(false);
resetForm();
fetchBanners();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
setUploadingImage(false);
}
};
const handleEdit = (banner: Banner) => {
setEditingBanner(banner);
setFormData({
title: banner.title || '',
description: '',
image_url: banner.image_url || '',
link: banner.link || '',
position: banner.position || 'home',
display_order: banner.display_order || 0,
is_active: banner.is_active ?? true,
start_date: banner.start_date ? banner.start_date.split('T')[0] : '',
end_date: banner.end_date ? banner.end_date.split('T')[0] : '',
});
setImageFile(null);
// Normalize image URL for preview (handle both relative and absolute URLs)
const previewUrl = banner.image_url
? (banner.image_url.startsWith('http')
? banner.image_url
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${banner.image_url}`)
: null;
setImagePreview(previewUrl);
setUseFileUpload(false); // When editing, show URL by default
setShowModal(true);
};
const handleDelete = async () => {
if (!deleteConfirm.id) return;
try {
await deleteBanner(deleteConfirm.id);
toast.success('Banner deleted successfully');
setDeleteConfirm({ show: false, id: null });
fetchBanners();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to delete banner');
}
};
const resetForm = () => {
setFormData({
title: '',
description: '',
image_url: '',
link: '',
position: 'home',
display_order: 0,
is_active: true,
start_date: '',
end_date: '',
});
setImageFile(null);
setImagePreview(null);
setUseFileUpload(true);
setEditingBanner(null);
};
const toggleActive = async (banner: Banner) => {
try {
await updateBanner(banner.id, {
is_active: !banner.is_active,
});
toast.success(`Banner ${!banner.is_active ? 'activated' : 'deactivated'}`);
fetchBanners();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to update banner');
}
};
if (loading && banners.length === 0) {
return <Loading fullScreen text="Loading banners..." />;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Banner Management</h1>
<p className="text-gray-600">Manage promotional banners and advertisements</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
>
<Plus className="w-5 h-5" />
<span>Add Banner</span>
</button>
</div>
{/* Filters */}
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by title..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Position
</label>
<select
value={filters.position}
onChange={(e) => setFilters({ ...filters, position: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="">All Positions</option>
<option value="home">Home</option>
<option value="rooms">Rooms</option>
<option value="about">About</option>
</select>
</div>
</div>
</div>
{/* Banners Table */}
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Image
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Position
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Order
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{banners.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
No banners found
</td>
</tr>
) : (
banners.map((banner) => (
<tr key={banner.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
{banner.image_url ? (
<img
src={banner.image_url}
alt={banner.title}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
<ImageIcon className="w-8 h-8 text-gray-400" />
</div>
)}
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">{banner.title}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-800">
{banner.position}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{banner.display_order}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => toggleActive(banner)}
className={`flex items-center space-x-1 px-2 py-1 rounded text-xs font-medium ${
banner.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{banner.is_active ? (
<>
<Eye className="w-4 h-4" />
<span>Active</span>
</>
) : (
<>
<EyeOff className="w-4 h-4" />
<span>Inactive</span>
</>
)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => handleEdit(banner)}
className="text-[#d4af37] hover:text-[#c9a227]"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => setDeleteConfirm({ show: true, id: banner.id })}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900">
{editingBanner ? 'Edit Banner' : 'Create Banner'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
{/* Image Upload/URL Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Banner Image *
</label>
{/* Toggle between file upload and URL */}
<div className="flex space-x-4 mb-3">
<button
type="button"
onClick={() => {
setUseFileUpload(true);
setImageFile(null);
setImagePreview(null);
setFormData({ ...formData, image_url: '' });
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
useFileUpload
? 'bg-[#d4af37] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Upload File
</button>
<button
type="button"
onClick={() => {
setUseFileUpload(false);
setImageFile(null);
setImagePreview(null);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
!useFileUpload
? 'bg-[#d4af37] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Use URL
</button>
</div>
{useFileUpload ? (
<div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors">
{uploadingImage ? (
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Loader2 className="w-8 h-8 text-[#d4af37] animate-spin mb-2" />
<p className="text-sm text-gray-500">Uploading...</p>
</div>
) : imagePreview ? (
<div className="relative w-full h-full">
<img
src={imagePreview}
alt="Preview"
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => {
setImageFile(null);
setImagePreview(null);
setFormData({ ...formData, image_url: '' });
}}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<ImageIcon className="w-10 h-10 mb-2 text-gray-400" />
<p className="mb-2 text-sm text-gray-500">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
</div>
)}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleImageFileChange}
disabled={uploadingImage}
/>
</label>
</div>
) : (
<div>
<input
type="url"
required={!imageFile}
value={formData.image_url}
onChange={(e) => {
setFormData({ ...formData, image_url: e.target.value });
setImagePreview(e.target.value || null);
}}
placeholder="https://example.com/image.jpg"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
{imagePreview && (
<div className="mt-3 relative">
<img
src={imagePreview}
alt="Preview"
className="w-full h-32 object-cover rounded-lg border border-gray-300"
onError={() => setImagePreview(null)}
/>
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Position
</label>
<select
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="home">Home</option>
<option value="rooms">Rooms</option>
<option value="about">About</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Display Order
</label>
<input
type="number"
value={formData.display_order}
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Link URL
</label>
<input
type="url"
value={formData.link}
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
{editingBanner && (
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="rounded border-gray-300 text-[#d4af37] focus:ring-[#d4af37]"
/>
<span className="text-sm font-medium text-gray-700">Active</span>
</label>
</div>
)}
<div className="flex justify-end space-x-4 pt-4">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-6 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227]"
>
{editingBanner ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, id: null })}
onConfirm={handleDelete}
title="Delete Banner"
message="Are you sure you want to delete this banner? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
/>
</div>
);
};
export default BannerManagementPage;

View File

@@ -97,13 +97,13 @@ const BookingManagementPage: React.FC = () => {
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Booking Management</h1>
<p className="text-gray-500 mt-1">Manage bookings</p>
<div className="space-y-8">
<div className="animate-fade-in">
<h1 className="enterprise-section-title">Booking Management</h1>
<p className="enterprise-section-subtitle mt-2">Manage and track all hotel bookings</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
@@ -112,13 +112,13 @@ const BookingManagementPage: React.FC = () => {
placeholder="Search by booking number, guest name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="enterprise-input pl-10"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="enterprise-input"
>
<option value="">All statuses</option>
<option value="pending">Pending confirmation</option>
@@ -130,34 +130,20 @@ const BookingManagementPage: React.FC = () => {
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<div className="enterprise-card overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<table className="enterprise-table">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Booking Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Room
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Check-in/out
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Total Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
<th>Booking Number</th>
<th>Customer</th>
<th>Room</th>
<th>Check-in/out</th>
<th>Total Price</th>
<th>Status</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody>
{bookings.map((booking) => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
@@ -242,11 +228,14 @@ const BookingManagementPage: React.FC = () => {
{/* Detail Modal */}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Booking Details</h2>
<button onClick={() => setShowDetailModal(false)} className="text-gray-500 hover:text-gray-700">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="enterprise-card p-8 w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-scale-in">
<div className="flex justify-between items-center mb-6 pb-4 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Booking Details</h2>
<button
onClick={() => setShowDetailModal(false)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
</button>
</div>
@@ -296,10 +285,10 @@ const BookingManagementPage: React.FC = () => {
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<div className="mt-8 flex justify-end">
<button
onClick={() => setShowDetailModal(false)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
className="btn-enterprise-secondary"
>
Close
</button>

View File

@@ -0,0 +1,357 @@
import React, { useEffect, useState } from 'react';
import { Shield, SlidersHorizontal, Info, Save, Globe } from 'lucide-react';
import { toast } from 'react-toastify';
import adminPrivacyService, {
CookieIntegrationSettings,
CookieIntegrationSettingsResponse,
CookiePolicySettings,
CookiePolicySettingsResponse,
} from '../../services/api/adminPrivacyService';
import { Loading } from '../../components/common';
const CookieSettingsPage: React.FC = () => {
const [policy, setPolicy] = useState<CookiePolicySettings>({
analytics_enabled: true,
marketing_enabled: true,
preferences_enabled: true,
});
const [integrations, setIntegrations] = useState<CookieIntegrationSettings>({
ga_measurement_id: '',
fb_pixel_id: '',
});
const [policyMeta, setPolicyMeta] = useState<
Pick<CookiePolicySettingsResponse, 'updated_at' | 'updated_by'> | null
>(null);
const [integrationMeta, setIntegrationMeta] = useState<
Pick<CookieIntegrationSettingsResponse, 'updated_at' | 'updated_by'> | null
>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const loadSettings = async () => {
try {
setLoading(true);
const [policyRes, integrationRes] = await Promise.all([
adminPrivacyService.getCookiePolicy(),
adminPrivacyService.getIntegrations(),
]);
setPolicy(policyRes.data);
setPolicyMeta({
updated_at: policyRes.updated_at,
updated_by: policyRes.updated_by,
});
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
} catch (error: any) {
toast.error(error.message || 'Failed to load cookie & integration settings');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadSettings();
}, []);
const handleToggle = (key: keyof CookiePolicySettings) => {
setPolicy((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const handleSave = async () => {
try {
setSaving(true);
const [policyRes, integrationRes] = await Promise.all([
adminPrivacyService.updateCookiePolicy(policy),
adminPrivacyService.updateIntegrations(integrations),
]);
setPolicy(policyRes.data);
setPolicyMeta({
updated_at: policyRes.updated_at,
updated_by: policyRes.updated_by,
});
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
toast.success('Cookie policy and integrations updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update cookie settings');
} finally {
setSaving(false);
}
};
const handleSaveIntegrations = async () => {
try {
setSaving(true);
const integrationRes = await adminPrivacyService.updateIntegrations(
integrations
);
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
toast.success('Integration IDs updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update integration IDs');
} finally {
setSaving(false);
}
};
if (loading) {
return <Loading fullScreen={false} text="Loading cookie policy..." />;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Shield className="w-6 h-6 text-amber-500" />
<h1 className="enterprise-section-title">Cookie & Privacy Controls</h1>
</div>
<p className="enterprise-section-subtitle max-w-2xl">
Define which cookie categories are allowed in the application. These
settings control which types of cookies your users can consent to.
</p>
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn-enterprise-primary inline-flex items-center gap-2"
>
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
{saving ? 'Saving...' : 'Save changes'}
</button>
</div>
{/* Info card */}
<div className="enterprise-card flex gap-4 p-4 sm:p-5">
<div className="mt-1">
<Info className="w-5 h-5 text-amber-500" />
</div>
<div className="space-y-1 text-sm text-gray-700">
<p className="font-semibold text-gray-900">
How these settings affect the guest experience
</p>
<p>
Disabling a category here prevents it from being offered to guests as
part of the cookie consent flow. For example, if marketing cookies are
disabled, the website should not load marketing pixels even if a guest
previously opted in.
</p>
{policyMeta?.updated_at && (
<p className="text-xs text-gray-500">
Last updated on{' '}
{new Date(policyMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}{' '}
{policyMeta.updated_by ? `by ${policyMeta.updated_by}` : ''}
</p>
)}
</div>
</div>
{/* Toggles */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-emerald-500" />
Analytics cookies
</p>
<p className="text-xs text-gray-500 mt-1">
Anonymous traffic and performance measurement (e.g. page views,
conversion funnels).
</p>
</div>
<button
type="button"
onClick={() => handleToggle('analytics_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.analytics_enabled ? 'bg-emerald-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.analytics_enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, analytics tracking scripts should not be executed,
regardless of user consent.
</p>
</div>
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-pink-500" />
Marketing cookies
</p>
<p className="text-xs text-gray-500 mt-1">
Personalised offers, remarketing campaigns, and external ad
networks.
</p>
</div>
<button
type="button"
onClick={() => handleToggle('marketing_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.marketing_enabled ? 'bg-pink-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.marketing_enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, do not load any marketing pixels or share data with ad
platforms.
</p>
</div>
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-indigo-500" />
Preference cookies
</p>
<p className="text-xs text-gray-500 mt-1">
Remember guest choices like language, currency, and layout.
</p>
</div>
<button
type="button"
onClick={() => handleToggle('preferences_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.preferences_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.preferences_enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, the application should avoid persisting non-essential
preferences client-side.
</p>
</div>
</div>
{/* Integration IDs */}
<div className="enterprise-card p-5 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
<div>
<p className="font-semibold text-gray-900">
Third-party integrations (IDs only)
</p>
<p className="text-xs text-gray-500">
Configure IDs for supported analytics and marketing platforms. The
application will only load these when both the policy and user consent
allow it.
</p>
</div>
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
{integrationMeta?.updated_at && (
<p className="text-[11px] text-gray-500">
Last changed{' '}
{new Date(integrationMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}{' '}
{integrationMeta.updated_by ? `by ${integrationMeta.updated_by}` : ''}
</p>
)}
<button
type="button"
onClick={handleSaveIntegrations}
disabled={saving}
className="btn-enterprise-secondary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs"
>
<Save className="w-3.5 h-3.5" />
{saving ? 'Saving IDs...' : 'Save integration IDs'}
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-800">
Google Analytics 4 Measurement ID
</label>
<input
type="text"
value={integrations.ga_measurement_id || ''}
onChange={(e) =>
setIntegrations((prev) => ({
...prev,
ga_measurement_id: e.target.value || undefined,
}))
}
placeholder="G-XXXXXXXXXX"
className="enterprise-input text-sm"
/>
<p className="text-[11px] text-gray-500">
Example: <code className="font-mono">G-ABCDE12345</code>. This is used to
load GA4 via gtag.js when analytics cookies are allowed.
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-800">
Meta (Facebook) Pixel ID
</label>
<input
type="text"
value={integrations.fb_pixel_id || ''}
onChange={(e) =>
setIntegrations((prev) => ({
...prev,
fb_pixel_id: e.target.value || undefined,
}))
}
placeholder="123456789012345"
className="enterprise-input text-sm"
/>
<p className="text-[11px] text-gray-500">
Numeric ID from your Meta Pixel. The application will only fire pixel
events when marketing cookies are allowed.
</p>
</div>
</div>
</div>
</div>
);
};
export default CookieSettingsPage;

View File

@@ -5,81 +5,108 @@ import {
Hotel,
DollarSign,
Calendar,
TrendingUp
TrendingUp,
RefreshCw,
TrendingDown
} from 'lucide-react';
import { reportService, ReportData } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import { Loading, EmptyState } from '../../components/common';
import { formatCurrency, formatDate } from '../../utils/format';
import { useAsync } from '../../hooks/useAsync';
const DashboardPage: React.FC = () => {
const [stats, setStats] = useState<ReportData | null>(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
});
useEffect(() => {
fetchDashboardData();
}, [dateRange]);
const fetchDashboardData = async () => {
try {
setLoading(true);
const response = await reportService.getReports({
from: dateRange.from,
to: dateRange.to,
});
setStats(response.data);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load dashboard data');
} finally {
setLoading(false);
}
const response = await reportService.getReports({
from: dateRange.from,
to: dateRange.to,
});
return response.data;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
const { data: stats, loading, error, execute } = useAsync<ReportData>(
fetchDashboardData,
{
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load dashboard data');
}
}
);
useEffect(() => {
execute();
}, [dateRange]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRefresh = () => {
execute();
};
if (loading) {
return <Loading />;
return <Loading fullScreen text="Loading dashboard..." />;
}
if (error || !stats) {
return (
<div className="space-y-6">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: handleRefresh
}}
/>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-8">
{/* Header */}
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1">Hotel operations overview</p>
<h1 className="enterprise-section-title">Dashboard</h1>
<p className="enterprise-section-subtitle mt-2">Hotel operations overview and analytics</p>
</div>
{/* Date Range Filter */}
<div className="flex gap-3 items-center">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<span className="text-gray-500">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex gap-3 items-center">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="enterprise-input text-sm"
/>
<span className="text-gray-500 font-medium">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="enterprise-input text-sm"
/>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="btn-enterprise-primary flex items-center gap-2 text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Revenue */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
@@ -91,15 +118,16 @@ const DashboardPage: React.FC = () => {
<DollarSign className="w-6 h-6 text-green-600" />
</div>
</div>
{/* Trend indicator - can be enhanced with actual comparison data */}
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+12.5%</span>
<span className="text-gray-500 ml-2">compared to last month</span>
<span className="text-green-600 font-medium">Active</span>
<span className="text-gray-500 ml-2">All time revenue</span>
</div>
</div>
{/* Total Bookings */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
@@ -112,14 +140,14 @@ const DashboardPage: React.FC = () => {
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+8.2%</span>
<span className="text-gray-500 ml-2">compared to last month</span>
<span className="text-gray-500">
{stats.total_bookings > 0 ? 'Total bookings recorded' : 'No bookings yet'}
</span>
</div>
</div>
{/* Available Rooms */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
@@ -139,7 +167,7 @@ const DashboardPage: React.FC = () => {
</div>
{/* Total Customers */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Customers</p>
@@ -152,9 +180,9 @@ const DashboardPage: React.FC = () => {
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+15.3%</span>
<span className="text-gray-500 ml-2">new customers</span>
<span className="text-gray-500">
Unique customers with bookings
</span>
</div>
</div>
</div>
@@ -162,72 +190,87 @@ const DashboardPage: React.FC = () => {
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Daily Revenue</h2>
<BarChart3 className="w-5 h-5 text-gray-400" />
<div className="enterprise-card p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Daily Revenue</h2>
<div className="p-2 bg-blue-100 rounded-lg">
<BarChart3 className="w-5 h-5 text-blue-600" />
</div>
</div>
{stats?.revenue_by_date && stats.revenue_by_date.length > 0 ? (
<div className="space-y-3">
{stats.revenue_by_date.slice(0, 7).map((item, index) => (
<div key={index} className="flex items-center">
<span className="text-sm text-gray-600 w-24">
{new Date(item.date).toLocaleDateString('en-US')}
</span>
<div className="flex-1 mx-3">
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
<div
className="bg-blue-500 h-4 rounded-full transition-all"
style={{
width: `${Math.min((item.revenue / (stats.revenue_by_date?.[0]?.revenue || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
{formatCurrency(item.revenue)}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
)}
</div>
{/* Bookings by Status */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Status</h2>
{stats?.bookings_by_status ? (
<div className="space-y-4">
{Object.entries(stats.bookings_by_status).map(([status, count]) => {
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
confirmed: 'bg-blue-500',
checked_in: 'bg-green-500',
checked_out: 'bg-gray-500',
cancelled: 'bg-red-500',
};
const statusLabels: Record<string, string> = {
pending: 'Pending confirmation',
confirmed: 'Confirmed',
checked_in: 'Checked in',
checked_out: 'Checked out',
cancelled: 'Cancelled',
};
{stats.revenue_by_date.slice(0, 7).map((item, index) => {
const maxRevenue = Math.max(...stats.revenue_by_date!.map(r => r.revenue));
return (
<div key={status} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${statusColors[status]}`} />
<span className="text-gray-700">{statusLabels[status]}</span>
<div key={index} className="flex items-center">
<span className="text-sm text-gray-600 w-24">
{formatDate(item.date, 'short')}
</span>
<div className="flex-1 mx-3">
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
<div
className="bg-blue-500 h-4 rounded-full transition-all"
style={{
width: `${Math.min((item.revenue / (maxRevenue || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<span className="font-semibold text-gray-900">{count}</span>
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
{formatCurrency(item.revenue, 'VND')}
</span>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
<EmptyState
title="No Revenue Data"
description="No revenue data available for the selected date range"
/>
)}
</div>
{/* Bookings by Status */}
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Booking Status</h2>
</div>
{stats?.bookings_by_status && Object.keys(stats.bookings_by_status).length > 0 ? (
<div className="space-y-4">
{Object.entries(stats.bookings_by_status)
.filter(([_, count]) => count > 0)
.map(([status, count]) => {
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
confirmed: 'bg-blue-500',
checked_in: 'bg-green-500',
checked_out: 'bg-gray-500',
cancelled: 'bg-red-500',
};
const statusLabels: Record<string, string> = {
pending: 'Pending confirmation',
confirmed: 'Confirmed',
checked_in: 'Checked in',
checked_out: 'Checked out',
cancelled: 'Cancelled',
};
return (
<div key={status} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${statusColors[status] || 'bg-gray-500'}`} />
<span className="text-gray-700">{statusLabels[status] || status}</span>
</div>
<span className="font-semibold text-gray-900">{count}</span>
</div>
);
})}
</div>
) : (
<EmptyState
title="No Booking Data"
description="No booking status data available"
/>
)}
</div>
</div>
@@ -235,51 +278,57 @@ const DashboardPage: React.FC = () => {
{/* Top Rooms and Services */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Rooms */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Booked Rooms</h2>
<div className="enterprise-card p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Top Booked Rooms</h2>
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
<div className="space-y-3">
{stats.top_rooms.map((room, index) => (
<div key={room.room_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-8 h-8 bg-blue-500 text-white rounded-full font-bold">
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-blue-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-blue-200 hover:shadow-md">
<div className="flex items-center gap-4">
<span className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl font-bold shadow-lg shadow-blue-500/30">
{index + 1}
</span>
<div>
<p className="font-medium text-gray-900">Room {room.room_number}</p>
<p className="text-sm text-gray-500">{room.bookings} bookings</p>
<p className="text-sm text-gray-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
</div>
</div>
<span className="font-semibold text-green-600">
{formatCurrency(room.revenue)}
{formatCurrency(room.revenue, 'VND')}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
<EmptyState
title="No Room Data"
description="No room booking data available for the selected date range"
/>
)}
</div>
{/* Service Usage */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Services Used</h2>
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Services Used</h2>
{stats?.service_usage && stats.service_usage.length > 0 ? (
<div className="space-y-3">
{stats.service_usage.map((service) => (
<div key={service.service_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div key={service.service_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-purple-200 hover:shadow-md">
<div>
<p className="font-medium text-gray-900">{service.service_name}</p>
<p className="text-sm text-gray-500">{service.usage_count} times used</p>
<p className="text-sm text-gray-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
</div>
<span className="font-semibold text-purple-600">
{formatCurrency(service.total_revenue)}
{formatCurrency(service.total_revenue, 'VND')}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
<EmptyState
title="No Service Data"
description="No service usage data available for the selected date range"
/>
)}
</div>
</div>

View File

@@ -0,0 +1,376 @@
import React, { useState, useEffect } from 'react';
import {
Calendar,
DollarSign,
Users,
Hotel,
TrendingUp,
Download,
Filter,
BarChart3,
PieChart
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import { reportService, ReportData } from '../../services/api/reportService';
import { formatCurrency, formatDate } from '../../utils/format';
const ReportsPage: React.FC = () => {
const [dateRange, setDateRange] = useState({
from: '',
to: '',
});
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'monthly' | 'yearly' | ''>('');
const fetchReports = async (): Promise<ReportData> => {
const params: any = {};
if (dateRange.from) params.from = dateRange.from;
if (dateRange.to) params.to = dateRange.to;
if (reportType) params.type = reportType;
const response = await reportService.getReports(params);
return response.data;
};
const {
data: reportData,
loading,
error,
execute: refetchReports
} = useAsync<ReportData>(fetchReports, {
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load reports');
}
});
const handleExport = async () => {
try {
const params: any = {};
if (dateRange.from) params.from = dateRange.from;
if (dateRange.to) params.to = dateRange.to;
if (reportType) params.type = reportType;
const blob = await reportService.exportReport(params);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `report-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Report exported successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to export report');
}
};
const handleFilter = () => {
refetchReports();
};
if (loading && !reportData) {
return <Loading fullScreen text="Loading reports..." />;
}
if (error && !reportData) {
return (
<div className="container mx-auto px-4 py-8">
<EmptyState
title="Unable to Load Reports"
description={error.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: refetchReports
}}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Reports & Analytics</h1>
<p className="text-gray-600">View comprehensive reports and statistics</p>
</div>
<button
onClick={handleExport}
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
>
<Download className="w-5 h-5" />
<span>Export CSV</span>
</button>
</div>
{/* Filters */}
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-2 mb-4">
<Filter className="w-5 h-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
From Date
</label>
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
To Date
</label>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Report Type
</label>
<select
value={reportType}
onChange={(e) => setReportType(e.target.value as any)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="">All</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div className="flex items-end">
<button
onClick={handleFilter}
className="w-full px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
>
Apply Filters
</button>
</div>
</div>
</div>
{reportData && (
<>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-blue-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Calendar className="w-6 h-6 text-blue-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Bookings
</h3>
<p className="text-3xl font-bold text-gray-800">
{reportData.total_bookings || 0}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-green-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<DollarSign className="w-6 h-6 text-green-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Revenue
</h3>
<p className="text-3xl font-bold text-gray-800">
{formatCurrency(reportData.total_revenue || 0)}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-purple-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-purple-100 rounded-lg">
<Users className="w-6 h-6 text-purple-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Customers
</h3>
<p className="text-3xl font-bold text-gray-800">
{reportData.total_customers || 0}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-orange-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-orange-100 rounded-lg">
<Hotel className="w-6 h-6 text-orange-600" />
</div>
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Available Rooms
</h3>
<p className="text-3xl font-bold text-gray-800">
{reportData.available_rooms || 0}
</p>
<p className="text-sm text-gray-500 mt-1">
{reportData.occupied_rooms || 0} occupied
</p>
</div>
</div>
{/* Bookings by Status */}
{reportData.bookings_by_status && (
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<div className="flex items-center space-x-2 mb-6">
<PieChart className="w-5 h-5 text-gray-500" />
<h2 className="text-xl font-bold text-gray-900">Bookings by Status</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{Object.entries(reportData.bookings_by_status).map(([status, count]) => (
<div key={status} className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-gray-800">{count}</p>
<p className="text-sm text-gray-600 capitalize mt-1">{status.replace('_', ' ')}</p>
</div>
))}
</div>
</div>
)}
{/* Revenue by Date */}
{reportData.revenue_by_date && reportData.revenue_by_date.length > 0 && (
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<div className="flex items-center space-x-2 mb-6">
<BarChart3 className="w-5 h-5 text-gray-500" />
<h2 className="text-xl font-bold text-gray-900">Revenue by Date</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Bookings
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.revenue_by_date.map((item, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(new Date(item.date), 'short')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.bookings}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(item.revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Top Rooms */}
{reportData.top_rooms && reportData.top_rooms.length > 0 && (
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-6">Top Performing Rooms</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Room Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Bookings
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.top_rooms.map((room) => (
<tr key={room.room_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{room.room_number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{room.bookings}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(room.revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Service Usage */}
{reportData.service_usage && reportData.service_usage.length > 0 && (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-xl font-bold text-gray-900 mb-6">Service Usage</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Service Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Usage Count
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Total Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.service_usage.map((service) => (
<tr key={service.service_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{service.service_name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{service.usage_count}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(service.total_revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
};
export default ReportsPage;

View File

@@ -8,3 +8,4 @@ export { default as ReviewManagementPage } from './ReviewManagementPage';
export { default as PromotionManagementPage } from './PromotionManagementPage';
export { default as CheckInPage } from './CheckInPage';
export { default as CheckOutPage } from './CheckOutPage';
export { default as AuditLogsPage } from './AuditLogsPage';

View File

@@ -61,35 +61,42 @@ const LoginPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br
from-blue-50 to-indigo-100 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8"
from-gray-50 via-gray-100 to-gray-50
flex items-center justify-center py-12 px-4
sm:px-6 lg:px-8 relative overflow-hidden"
>
<div className="max-w-md w-full space-y-8">
{/* Luxury background pattern */}
<div className="absolute inset-0 opacity-5" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
<div className="max-w-md w-full space-y-8 relative z-10">
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-blue-600 rounded-full">
<Hotel className="w-12 h-12 text-white" />
<div className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900">
Login
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
Welcome Back
</h2>
<p className="mt-2 text-sm text-gray-600">
Welcome back to Hotel Booking
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
Sign in to Luxury Hotel
</p>
</div>
{/* Login Form */}
<div className="bg-white rounded-lg shadow-xl p-8">
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
<form onSubmit={handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200
text-red-700 px-4 py-3 rounded-lg
text-sm"
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
text-red-700 px-4 py-3 rounded-sm
text-sm font-light"
>
{error}
</div>
@@ -100,7 +107,7 @@ const LoginPage: React.FC = () => {
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
text-gray-700 mb-2 tracking-wide"
>
Email
</label>
@@ -115,18 +122,16 @@ const LoginPage: React.FC = () => {
id="email"
type="email"
autoComplete="email"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${errors.email
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
className={`luxury-input pl-10 ${
errors.email
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: ''
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
<p className="mt-1 text-sm text-red-600 font-light">
{errors.email.message}
</p>
)}
@@ -137,7 +142,7 @@ const LoginPage: React.FC = () => {
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
text-gray-700 mb-2 tracking-wide"
>
Password
</label>
@@ -152,34 +157,31 @@ const LoginPage: React.FC = () => {
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className={`block w-full pl-10 pr-10 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${errors.password
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
className={`luxury-input pl-10 pr-10 ${
errors.password
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: ''
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
pr-3 flex items-center transition-colors
hover:text-[#d4af37]"
>
{showPassword ? (
<EyeOff className="h-5 w-5
text-gray-400 hover:text-gray-600"
text-gray-400"
/>
) : (
<Eye className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
<p className="mt-1 text-sm text-red-600 font-light">
{errors.password.message}
</p>
)}
@@ -192,14 +194,14 @@ const LoginPage: React.FC = () => {
{...register('rememberMe')}
id="rememberMe"
type="checkbox"
className="h-4 w-4 text-blue-600
focus:ring-blue-500 border-gray-300
rounded cursor-pointer"
className="h-4 w-4 text-[#d4af37]
focus:ring-[#d4af37]/50 border-gray-300
rounded-sm cursor-pointer accent-[#d4af37]"
/>
<label
htmlFor="rememberMe"
className="ml-2 block text-sm
text-gray-700 cursor-pointer"
text-gray-700 cursor-pointer font-light tracking-wide"
>
Remember me
</label>
@@ -208,8 +210,8 @@ const LoginPage: React.FC = () => {
<Link
to="/forgot-password"
className="text-sm font-medium
text-blue-600 hover:text-blue-500
transition-colors"
text-[#d4af37] hover:text-[#c9a227]
transition-colors tracking-wide"
>
Forgot password?
</Link>
@@ -219,27 +221,20 @@ const LoginPage: React.FC = () => {
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg shadow-sm
text-sm font-medium text-white
bg-blue-600 hover:bg-blue-700
focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
className="btn-luxury-primary w-full flex items-center
justify-center py-3 px-4 text-sm relative"
>
{isLoading ? (
<>
<Loader2 className="animate-spin -ml-1
mr-2 h-5 w-5"
mr-2 h-5 w-5 relative z-10"
/>
Processing...
<span className="relative z-10">Processing...</span>
</>
) : (
<>
<LogIn className="-ml-1 mr-2 h-5 w-5" />
Login
<LogIn className="-ml-1 mr-2 h-5 w-5 relative z-10" />
<span className="relative z-10">Sign In</span>
</>
)}
</button>
@@ -247,12 +242,12 @@ const LoginPage: React.FC = () => {
{/* Register Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
<p className="text-sm text-gray-600 font-light tracking-wide">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-blue-600
hover:text-blue-500 transition-colors"
className="font-medium text-[#d4af37]
hover:text-[#c9a227] transition-colors"
>
Register now
</Link>
@@ -261,19 +256,19 @@ const LoginPage: React.FC = () => {
</div>
{/* Footer Info */}
<div className="text-center text-sm text-gray-500">
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
<p>
By logging in, you agree to our{' '}
<Link
to="/terms"
className="text-blue-600 hover:underline"
className="text-[#d4af37] hover:underline"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="/privacy"
className="text-blue-600 hover:underline"
className="text-[#d4af37] hover:underline"
>
Privacy Policy
</Link>

View File

@@ -96,27 +96,33 @@ const RegisterPage: React.FC = () => {
return (
<div
className="min-h-screen bg-gradient-to-br
from-purple-50 to-pink-100 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8"
from-gray-50 via-gray-100 to-gray-50 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden"
>
<div className="max-w-md w-full space-y-8">
{/* Luxury background pattern */}
<div className="absolute inset-0 opacity-5" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
<div className="max-w-md w-full space-y-8 relative z-10">
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-purple-600 rounded-full">
<Hotel className="w-12 h-12 text-white" />
<div className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900">
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
Create Account
</h2>
<p className="mt-2 text-sm text-gray-600">
Create a new account to book hotel rooms
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
Join Luxury Hotel for exclusive benefits
</p>
</div>
{/* Register Form */}
<div className="bg-white rounded-lg shadow-xl p-8">
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-5"
@@ -124,9 +130,9 @@ const RegisterPage: React.FC = () => {
{/* Error Message */}
{error && (
<div
className="bg-red-50 border border-red-200
text-red-700 px-4 py-3 rounded-lg
text-sm"
className="bg-red-50/80 backdrop-blur-sm border border-red-200
text-red-700 px-4 py-3 rounded-sm
text-sm font-light"
>
{error}
</div>
@@ -137,7 +143,7 @@ const RegisterPage: React.FC = () => {
<label
htmlFor="name"
className="block text-sm font-medium
text-gray-700 mb-2"
text-gray-700 mb-2 tracking-wide"
>
Full Name
</label>
@@ -154,20 +160,16 @@ const RegisterPage: React.FC = () => {
id="name"
type="text"
autoComplete="name"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.name
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
className={`luxury-input pl-10 ${
errors.name
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: ''
}`}
placeholder="John Doe"
/>
</div>
{errors.name && (
<p className="mt-1 text-sm text-red-600">
<p className="mt-1 text-sm text-red-600 font-light">
{errors.name.message}
</p>
)}
@@ -178,7 +180,7 @@ const RegisterPage: React.FC = () => {
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
text-gray-700 mb-2 tracking-wide"
>
Email
</label>
@@ -195,20 +197,16 @@ const RegisterPage: React.FC = () => {
id="email"
type="email"
autoComplete="email"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.email
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
className={`luxury-input pl-10 ${
errors.email
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: ''
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
<p className="mt-1 text-sm text-red-600 font-light">
{errors.email.message}
</p>
)}
@@ -219,7 +217,7 @@ const RegisterPage: React.FC = () => {
<label
htmlFor="phone"
className="block text-sm font-medium
text-gray-700 mb-2"
text-gray-700 mb-2 tracking-wide"
>
Phone Number (Optional)
</label>
@@ -236,20 +234,16 @@ const RegisterPage: React.FC = () => {
id="phone"
type="tel"
autoComplete="tel"
className={`block w-full pl-10 pr-3 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.phone
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
className={`luxury-input pl-10 ${
errors.phone
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: ''
}`}
placeholder="0123456789"
/>
</div>
{errors.phone && (
<p className="mt-1 text-sm text-red-600">
<p className="mt-1 text-sm text-red-600 font-light">
{errors.phone.message}
</p>
)}
@@ -260,7 +254,7 @@ const RegisterPage: React.FC = () => {
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
text-gray-700 mb-2 tracking-wide"
>
Password
</label>
@@ -277,38 +271,33 @@ const RegisterPage: React.FC = () => {
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
className={`block w-full pl-10 pr-10 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.password
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
className={`luxury-input pl-10 pr-10 ${
errors.password
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: ''
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
pr-3 flex items-center transition-colors
hover:text-[#d4af37]"
>
{showPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
className="h-5 w-5 text-gray-400"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
className="h-5 w-5 text-gray-400"
/>
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
<p className="mt-1 text-sm text-red-600 font-light">
{errors.password.message}
</p>
)}
@@ -322,7 +311,13 @@ const RegisterPage: React.FC = () => {
>
<div
className={`h-full transition-all
duration-300 ${passwordStrength.color}`}
duration-300 ${
passwordStrength.strength >= 4
? 'bg-[#d4af37]'
: passwordStrength.strength >= 3
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{
width: `${
(passwordStrength.strength / 5) * 100
@@ -331,7 +326,7 @@ const RegisterPage: React.FC = () => {
/>
</div>
<span className="text-xs font-medium
text-gray-600"
text-gray-600 tracking-wide"
>
{passwordStrength.label}
</span>
@@ -369,7 +364,7 @@ const RegisterPage: React.FC = () => {
<label
htmlFor="confirmPassword"
className="block text-sm font-medium
text-gray-700 mb-2"
text-gray-700 mb-2 tracking-wide"
>
Confirm Password
</label>
@@ -388,15 +383,11 @@ const RegisterPage: React.FC = () => {
showConfirmPassword ? 'text' : 'password'
}
autoComplete="new-password"
className={`block w-full pl-10 pr-10 py-3
border rounded-lg focus:outline-none
focus:ring-2 transition-colors
${
errors.confirmPassword
? 'border-red-300 focus:ring-red-500'
: 'border-gray-300 ' +
'focus:ring-purple-500'
}`}
className={`luxury-input pl-10 pr-10 ${
errors.confirmPassword
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: ''
}`}
placeholder="••••••••"
/>
<button
@@ -405,23 +396,22 @@ const RegisterPage: React.FC = () => {
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
pr-3 flex items-center transition-colors
hover:text-[#d4af37]"
>
{showConfirmPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
className="h-5 w-5 text-gray-400"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
className="h-5 w-5 text-gray-400"
/>
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
<p className="mt-1 text-sm text-red-600 font-light">
{errors.confirmPassword.message}
</p>
)}
@@ -431,28 +421,20 @@ const RegisterPage: React.FC = () => {
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg shadow-sm
text-sm font-medium text-white
bg-purple-600 hover:bg-purple-700
focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-purple-500
disabled:opacity-50
disabled:cursor-not-allowed
transition-colors"
className="btn-luxury-primary w-full flex items-center
justify-center py-3 px-4 text-sm relative"
>
{isLoading ? (
<>
<Loader2 className="animate-spin -ml-1
mr-2 h-5 w-5"
mr-2 h-5 w-5 relative z-10"
/>
Processing...
<span className="relative z-10">Processing...</span>
</>
) : (
<>
<UserPlus className="-ml-1 mr-2 h-5 w-5" />
Register
<UserPlus className="-ml-1 mr-2 h-5 w-5 relative z-10" />
<span className="relative z-10">Register</span>
</>
)}
</button>
@@ -460,12 +442,12 @@ const RegisterPage: React.FC = () => {
{/* Login Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
<p className="text-sm text-gray-600 font-light tracking-wide">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-purple-600
hover:text-purple-500 transition-colors"
className="font-medium text-[#d4af37]
hover:text-[#c9a227] transition-colors"
>
Login now
</Link>
@@ -474,19 +456,19 @@ const RegisterPage: React.FC = () => {
</div>
{/* Footer Info */}
<div className="text-center text-sm text-gray-500">
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
<p>
By registering, you agree to our{' '}
<Link
to="/terms"
className="text-purple-600 hover:underline"
className="text-[#d4af37] hover:underline"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="/privacy"
className="text-purple-600 hover:underline"
className="text-[#d4af37] hover:underline"
>
Privacy Policy
</Link>
@@ -502,13 +484,13 @@ const PasswordRequirement: React.FC<{
met: boolean;
text: string;
}> = ({ met, text }) => (
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-2 text-xs font-light">
{met ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-[#d4af37]" />
) : (
<XCircle className="h-4 w-4 text-gray-300" />
)}
<span className={met ? 'text-green-600' : 'text-gray-500'}>
<span className={met ? 'text-[#c9a227] font-medium' : 'text-gray-500'}>
{text}
</span>
</div>

View File

@@ -4,243 +4,250 @@ import {
Hotel,
DollarSign,
Calendar,
Activity
Activity,
TrendingDown
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import dashboardService, { CustomerDashboardStats } from '../../services/api/dashboardService';
import { toast } from 'react-toastify';
import { formatCurrency, formatDate, formatRelativeTime } from '../../utils/format';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
const DashboardPage: React.FC = () => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
const navigate = useNavigate();
const fetchDashboardData = async (): Promise<CustomerDashboardStats> => {
const response = await dashboardService.getCustomerDashboardStats();
return response.data;
};
const { data: stats, loading, error, execute } = useAsync<CustomerDashboardStats>(
fetchDashboardData,
{
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load dashboard data');
}
}
);
const handleRefresh = () => {
execute();
};
if (loading) {
return <Loading fullScreen text="Loading dashboard..." />;
}
if (error || !stats) {
return (
<div className="container mx-auto px-4 py-8">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: handleRefresh
}}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
<div className="mb-10 animate-fade-in">
<h1 className="enterprise-section-title mb-2">
Dashboard
</h1>
<p className="text-gray-600">
Overview of your activity
<p className="enterprise-section-subtitle">
Overview of your activity and bookings
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-4 gap-6 mb-8"
>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
{/* Total Bookings */}
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Calendar className="w-6 h-6
text-blue-600"
/>
<Calendar className="w-6 h-6 text-blue-600" />
</div>
<span className="text-sm text-green-600
font-medium"
>
+12%
</span>
{stats.booking_change_percentage !== 0 && (
<span className={`text-sm font-medium flex items-center gap-1 ${
stats.booking_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{stats.booking_change_percentage > 0 ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
{Math.abs(stats.booking_change_percentage).toFixed(1)}%
</span>
)}
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Bookings
</h3>
<p className="text-3xl font-bold text-gray-800">
45
{stats.total_bookings}
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
{/* Total Spending */}
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<DollarSign className="w-6 h-6
text-green-600"
/>
<DollarSign className="w-6 h-6 text-green-600" />
</div>
<span className="text-sm text-green-600
font-medium"
>
+8%
</span>
{stats.spending_change_percentage !== 0 && (
<span className={`text-sm font-medium flex items-center gap-1 ${
stats.spending_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{stats.spending_change_percentage > 0 ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
{Math.abs(stats.spending_change_percentage).toFixed(1)}%
</span>
)}
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Spending
</h3>
<p className="text-3xl font-bold text-gray-800">
{formatCurrency(12450)}
{formatCurrency(stats.total_spending, 'VND')}
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
{/* Currently Staying */}
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-purple-100 rounded-lg">
<Hotel className="w-6 h-6 text-purple-600" />
</div>
<span className="text-sm text-green-600
font-medium"
>
Active
</span>
{stats.currently_staying > 0 && (
<span className="text-sm text-green-600 font-medium">
Active
</span>
)}
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Currently Staying
</h3>
<p className="text-3xl font-bold text-gray-800">
2
{stats.currently_staying}
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<div className="flex items-center
justify-between mb-4"
>
{/* Upcoming Bookings Count */}
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-orange-100 rounded-lg">
<TrendingUp className="w-6 h-6
text-orange-600"
/>
<TrendingUp className="w-6 h-6 text-orange-600" />
</div>
<span className="text-sm text-green-600
font-medium"
>
+15%
</span>
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
Reward Points
<h3 className="text-gray-500 text-sm font-medium mb-1">
Upcoming Bookings
</h3>
<p className="text-3xl font-bold text-gray-800">
1,250
{stats.upcoming_bookings.length}
</p>
</div>
</div>
{/* Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2
gap-6"
>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<h2 className="text-xl font-semibold
text-gray-800 mb-4"
>
{/* Recent Activity & Upcoming Bookings */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activity */}
<div className="enterprise-card p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
Recent Activity
</h2>
<div className="space-y-4">
{[
{
action: 'Booking',
room: 'Room 201',
time: '2 hours ago'
},
{
action: 'Check-in',
room: 'Room 105',
time: '1 day ago'
},
{
action: 'Check-out',
room: 'Room 302',
time: '3 days ago'
},
].map((activity, index) => (
<div key={index}
className="flex items-center space-x-4
pb-4 border-b border-gray-200
last:border-0"
>
<div className="p-2 bg-blue-100
rounded-lg"
{stats.recent_activity && stats.recent_activity.length > 0 ? (
<div className="space-y-4">
{stats.recent_activity.map((activity, index) => (
<div
key={activity.booking_id || index}
className="flex items-center space-x-4 pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
onClick={() => navigate(`/bookings/${activity.booking_id}`)}
>
<Activity className="w-5 h-5
text-blue-600"
/>
<div className="p-2 bg-blue-100 rounded-lg">
<Activity className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1">
<p className="font-medium text-gray-800">
{activity.action}
</p>
<p className="text-sm text-gray-500">
{activity.room?.room_number || activity.booking_number}
</p>
</div>
<span className="text-sm text-gray-400">
{formatRelativeTime(new Date(activity.created_at))}
</span>
</div>
<div className="flex-1">
<p className="font-medium text-gray-800">
{activity.action}
</p>
<p className="text-sm text-gray-500">
{activity.room}
</p>
</div>
<span className="text-sm text-gray-400">
{activity.time}
</span>
</div>
))}
</div>
))}
</div>
) : (
<EmptyState
title="No Recent Activity"
description="Your recent bookings and activities will appear here"
action={{
label: 'View All Bookings',
onClick: () => navigate('/bookings')
}}
/>
)}
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<h2 className="text-xl font-semibold
text-gray-800 mb-4"
>
{/* Upcoming Bookings */}
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
Upcoming Bookings
</h2>
<div className="space-y-4">
{[
{
room: 'Room 401',
date: '20/11/2025',
status: 'Confirmed'
},
{
room: 'Room 203',
date: '25/11/2025',
status: 'Pending confirmation'
},
].map((booking, index) => (
<div key={index}
className="flex items-center
justify-between pb-4 border-b
border-gray-200 last:border-0"
>
<div>
<p className="font-medium text-gray-800">
{booking.room}
</p>
<p className="text-sm text-gray-500">
{booking.date}
</p>
</div>
<span className={`px-3 py-1 rounded-full
text-xs font-medium
${booking.status === 'Confirmed'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
{stats.upcoming_bookings && stats.upcoming_bookings.length > 0 ? (
<div className="space-y-4">
{stats.upcoming_bookings.map((booking) => (
<div
key={booking.id}
className="flex items-center justify-between pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
onClick={() => navigate(`/bookings/${booking.id}`)}
>
{booking.status}
</span>
</div>
))}
</div>
<div>
<p className="font-medium text-gray-800">
Room {booking.room?.room_number || 'N/A'}
</p>
<p className="text-sm text-gray-500">
{formatDate(booking.check_in_date, 'medium')}
</p>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(booking.total_price, 'VND')}
</p>
</div>
<span className={`enterprise-badge ${
booking.status === 'confirmed'
? 'bg-green-100 text-green-800 shadow-sm shadow-green-500/20'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800 shadow-sm shadow-yellow-500/20'
: 'bg-gray-100 text-gray-800 shadow-sm'
}`}>
{booking.status.charAt(0).toUpperCase() + booking.status.slice(1).replace('_', ' ')}
</span>
</div>
))}
</div>
) : (
<EmptyState
title="No Upcoming Bookings"
description="You don't have any upcoming bookings yet"
action={{
label: 'Browse Rooms',
onClick: () => navigate('/rooms')
}}
/>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,543 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
User,
Mail,
Phone,
Save,
Loader2,
CheckCircle,
AlertCircle,
Lock,
Camera
} from 'lucide-react';
import { toast } from 'react-toastify';
import authService from '../../services/api/authService';
import useAuthStore from '../../store/useAuthStore';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import { useGlobalLoading } from '../../contexts/GlobalLoadingContext';
// Validation schema
const profileValidationSchema = yup.object().shape({
name: yup
.string()
.required('Full name is required')
.min(2, 'Full name must be at least 2 characters')
.max(100, 'Full name cannot exceed 100 characters'),
email: yup
.string()
.required('Email is required')
.email('Invalid email address'),
phone: yup
.string()
.required('Phone number is required')
.matches(
/^[0-9]{10,11}$/,
'Phone number must have 10-11 digits'
),
});
const passwordValidationSchema = yup.object().shape({
currentPassword: yup
.string()
.required('Current password is required'),
newPassword: yup
.string()
.required('New password is required')
.min(6, 'Password must be at least 6 characters'),
confirmPassword: yup
.string()
.required('Please confirm your password')
.oneOf([yup.ref('newPassword')], 'Passwords must match'),
});
type ProfileFormData = yup.InferType<typeof profileValidationSchema>;
type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
const ProfilePage: React.FC = () => {
const { userInfo, setUser } = useAuthStore();
const { setLoading } = useGlobalLoading();
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
// Fetch profile data
const fetchProfile = async () => {
const response = await authService.getProfile();
if (response.status === 'success' || response.success) {
const user = response.data?.user || response.data;
if (user) {
setUser(user);
return user;
}
}
throw new Error('Failed to load profile');
};
const {
data: profileData,
loading: loadingProfile,
error: profileError,
execute: refetchProfile
} = useAsync(fetchProfile, {
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load profile');
}
});
// Profile form
const {
register: registerProfile,
handleSubmit: handleSubmitProfile,
formState: { errors: profileErrors },
reset: resetProfile,
} = useForm<ProfileFormData>({
resolver: yupResolver(profileValidationSchema),
defaultValues: {
name: userInfo?.name || '',
email: userInfo?.email || '',
phone: userInfo?.phone || '',
},
});
// Password form
const {
register: registerPassword,
handleSubmit: handleSubmitPassword,
formState: { errors: passwordErrors },
reset: resetPassword,
} = useForm<PasswordFormData>({
resolver: yupResolver(passwordValidationSchema),
});
// Update form when profile data loads
useEffect(() => {
if (profileData || userInfo) {
const data = profileData || userInfo;
resetProfile({
name: data?.name || '',
email: data?.email || '',
phone: data?.phone || '',
});
if (data?.avatar) {
setAvatarPreview(data.avatar);
}
}
}, [profileData, userInfo, resetProfile]);
// Handle profile update
const onSubmitProfile = async (data: ProfileFormData) => {
try {
setLoading(true, 'Updating profile...');
// Check if updateProfile exists in authService, otherwise use userService
if ('updateProfile' in authService) {
const response = await (authService as any).updateProfile({
full_name: data.name,
email: data.email,
phone_number: data.phone,
});
if (response.status === 'success' || response.success) {
const updatedUser = response.data?.user || response.data;
if (updatedUser) {
setUser(updatedUser);
toast.success('Profile updated successfully!');
refetchProfile();
}
}
} else {
// Fallback: use userService if updateProfile doesn't exist
const { updateUser } = await import('../../services/api/userService');
const response = await updateUser(userInfo!.id, {
full_name: data.name,
email: data.email,
phone_number: data.phone,
});
if (response.success || response.status === 'success') {
const updatedUser = response.data?.user || response.data;
if (updatedUser) {
setUser({
id: updatedUser.id,
name: updatedUser.full_name || updatedUser.name,
email: updatedUser.email,
phone: updatedUser.phone_number || updatedUser.phone,
avatar: updatedUser.avatar,
role: updatedUser.role,
});
toast.success('Profile updated successfully!');
refetchProfile();
}
}
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
error.message ||
'Failed to update profile';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
// Handle password change
const onSubmitPassword = async (data: PasswordFormData) => {
try {
setLoading(true, 'Changing password...');
// Use updateProfile with password fields if available
if ('updateProfile' in authService) {
const response = await (authService as any).updateProfile({
currentPassword: data.currentPassword,
password: data.newPassword,
});
if (response.status === 'success' || response.success) {
toast.success('Password changed successfully!');
resetPassword();
}
} else {
// Fallback: use userService
const { updateUser } = await import('../../services/api/userService');
const response = await updateUser(userInfo!.id, {
password: data.newPassword,
});
if (response.success || response.status === 'success') {
toast.success('Password changed successfully!');
resetPassword();
}
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
error.message ||
'Failed to change password';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
// Handle avatar upload (placeholder - would need backend support)
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('Image size must be less than 2MB');
return;
}
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
// TODO: Upload to backend
toast.info('Avatar upload feature coming soon');
}
};
if (loadingProfile) {
return <Loading fullScreen text="Loading profile..." />;
}
if (profileError && !userInfo) {
return (
<div className="container mx-auto px-4 py-8">
<EmptyState
title="Unable to Load Profile"
description={profileError.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: refetchProfile
}}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-8 animate-fade-in">
<h1 className="enterprise-section-title mb-2">
Profile Settings
</h1>
<p className="enterprise-section-subtitle">
Manage your account information and preferences
</p>
</div>
{/* Tabs */}
<div className="mb-6 border-b border-gray-200">
<div className="flex space-x-8">
<button
onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'profile'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Profile Information
</button>
<button
onClick={() => setActiveTab('password')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'password'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Change Password
</button>
</div>
</div>
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="enterprise-card animate-slide-up">
<form onSubmit={handleSubmitProfile(onSubmitProfile)} className="space-y-6">
{/* Avatar Section */}
<div className="flex items-center space-x-6 pb-6 border-b border-gray-200">
<div className="relative">
{avatarPreview || userInfo?.avatar ? (
<img
src={avatarPreview || userInfo?.avatar}
alt="Profile"
className="w-24 h-24 rounded-full object-cover ring-4 ring-[#d4af37]/20"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center ring-4 ring-[#d4af37]/20">
<User className="w-12 h-12 text-white" />
</div>
)}
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 p-2 bg-[#d4af37] rounded-full cursor-pointer hover:bg-[#c9a227] transition-colors shadow-lg"
>
<Camera className="w-4 h-4 text-white" />
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</label>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{userInfo?.name || 'User'}
</h3>
<p className="text-sm text-gray-500">{userInfo?.email}</p>
<p className="text-xs text-gray-400 mt-1">
{userInfo?.role?.charAt(0).toUpperCase() + userInfo?.role?.slice(1)}
</p>
</div>
</div>
{/* Full Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="w-4 h-4 inline mr-2" />
Full Name
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerProfile('name')}
type="text"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
profileErrors.name ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your full name"
/>
{profileErrors.name && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{profileErrors.name.message}
</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail className="w-4 h-4 inline mr-2" />
Email Address
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerProfile('email')}
type="email"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
profileErrors.email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your email"
/>
{profileErrors.email && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{profileErrors.email.message}
</p>
)}
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone className="w-4 h-4 inline mr-2" />
Phone Number
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerProfile('phone')}
type="tel"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
profileErrors.phone ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your phone number"
/>
{profileErrors.phone && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{profileErrors.phone.message}
</p>
)}
</div>
{/* Submit Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
type="submit"
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
>
<Save className="w-4 h-4" />
<span>Save Changes</span>
</button>
</div>
</form>
</div>
)}
{/* Password Tab */}
{activeTab === 'password' && (
<div className="enterprise-card animate-slide-up">
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-6">
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900 mb-1">
Password Requirements
</h4>
<ul className="text-xs text-blue-700 space-y-1">
<li> At least 6 characters long</li>
<li> Use a combination of letters and numbers for better security</li>
</ul>
</div>
</div>
</div>
{/* Current Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Lock className="w-4 h-4 inline mr-2" />
Current Password
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerPassword('currentPassword')}
type="password"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
passwordErrors.currentPassword ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your current password"
/>
{passwordErrors.currentPassword && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordErrors.currentPassword.message}
</p>
)}
</div>
{/* New Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Lock className="w-4 h-4 inline mr-2" />
New Password
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerPassword('newPassword')}
type="password"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
passwordErrors.newPassword ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your new password"
/>
{passwordErrors.newPassword && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordErrors.newPassword.message}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Lock className="w-4 h-4 inline mr-2" />
Confirm New Password
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerPassword('confirmPassword')}
type="password"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
passwordErrors.confirmPassword ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Confirm your new password"
/>
{passwordErrors.confirmPassword && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordErrors.confirmPassword.message}
</p>
)}
</div>
{/* Submit Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
type="submit"
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
>
<Lock className="w-4 h-4" />
<span>Change Password</span>
</button>
</div>
</form>
</div>
)}
</div>
);
};
export default ProfilePage;

View File

@@ -66,23 +66,24 @@ const RoomListPage: React.FC = () => {
}, [searchParams]);
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50/30 to-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
className="inline-flex items-center gap-2 btn-enterprise-secondary mb-8 animate-fade-in"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to home</span>
</Link>
<div className="mb-10">
<h1 className="text-3xl text-center font-bold text-gray-900">
<div className="mb-10 text-center animate-fade-in">
<h1 className="enterprise-section-title">
Room List
</h1>
<p className="enterprise-section-subtitle mt-2">
Browse our available accommodations
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
@@ -102,29 +103,34 @@ const RoomListPage: React.FC = () => {
)}
{error && !loading && (
<div className="bg-red-50 border border-red-200
rounded-lg p-6 text-center"
<div className="enterprise-card p-8 text-center animate-fade-in
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
>
<svg
className="w-12 h-12 text-red-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0
9 9 0 0118 0z"
/>
</svg>
<p className="text-red-800 font-medium">{error}</p>
<div className="inline-flex items-center justify-center w-16 h-16
bg-red-100 rounded-full mb-4">
<svg
className="w-8 h-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0
9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-red-800 font-semibold text-lg mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-600
text-white rounded-lg hover:bg-red-700
transition-colors"
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
text-white rounded-lg font-semibold
hover:from-red-700 hover:to-red-800
transition-all duration-300 shadow-lg shadow-red-500/30
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
>
Try Again
</button>
@@ -132,26 +138,28 @@ const RoomListPage: React.FC = () => {
)}
{!loading && !error && rooms.length === 0 && (
<div className="bg-white rounded-lg shadow-md
p-12 text-center"
<div className="enterprise-card p-12 text-center animate-fade-in"
>
<svg
className="w-24 h-24 text-gray-300 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14
0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1
4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 className="text-xl font-semibold
text-gray-800 mb-2"
<div className="inline-flex items-center justify-center w-24 h-24
bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl mb-6">
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14
0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1
4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
<h3 className="text-xl font-bold
text-gray-900 mb-2"
>
No matching rooms found
</h3>
@@ -160,8 +168,7 @@ const RoomListPage: React.FC = () => {
</p>
<button
onClick={() => window.location.href = '/rooms'}
className="px-6 py-2 bg-blue-600 text-white
rounded-lg hover:bg-blue-700 transition-colors"
className="btn-enterprise-primary"
>
Clear Filters
</button>