update
This commit is contained in:
244
Frontend/src/pages/AboutPage.tsx
Normal file
244
Frontend/src/pages/AboutPage.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Hotel,
|
||||
Award,
|
||||
Users,
|
||||
Heart,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Star,
|
||||
Shield,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] text-white py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto text-center animate-fade-in">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-2xl opacity-30"></div>
|
||||
<Hotel className="relative w-20 h-20 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight">
|
||||
About Luxury Hotel
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 font-light leading-relaxed">
|
||||
Where Excellence Meets Unforgettable Experiences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Our Story Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Our Story
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
</div>
|
||||
<div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up">
|
||||
<p>
|
||||
Welcome to Luxury Hotel, where timeless elegance meets modern sophistication.
|
||||
Since our founding, we have been dedicated to providing exceptional hospitality
|
||||
and creating unforgettable memories for our guests.
|
||||
</p>
|
||||
<p>
|
||||
Nestled in the heart of the city, our hotel combines classic architecture with
|
||||
contemporary amenities, offering a perfect blend of comfort and luxury. Every
|
||||
detail has been carefully curated to ensure your stay exceeds expectations.
|
||||
</p>
|
||||
<p>
|
||||
Our commitment to excellence extends beyond our beautiful rooms and facilities.
|
||||
We believe in creating meaningful connections with our guests, understanding
|
||||
their needs, and delivering personalized service that makes each visit special.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Values Section */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Our Values
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'Passion',
|
||||
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
title: 'Excellence',
|
||||
description: 'We strive for excellence in every aspect of our service, from the smallest detail to the grandest gesture.'
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Integrity',
|
||||
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Service',
|
||||
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
|
||||
}
|
||||
].map((value, index) => (
|
||||
<div
|
||||
key={value.title}
|
||||
className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-lg flex items-center justify-center mb-4">
|
||||
<value.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{value.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{value.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Why Choose Us
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: Star,
|
||||
title: 'Premium Accommodations',
|
||||
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: '24/7 Service',
|
||||
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
title: 'Award-Winning',
|
||||
description: 'Recognized for excellence in hospitality and guest satisfaction.'
|
||||
}
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="text-center p-6 animate-slide-up"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<feature.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
Get In Touch
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
<p className="text-gray-600 mt-4">
|
||||
We'd love to hear from you. Contact us for reservations or inquiries.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<MapPin className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Address
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
123 Luxury Street<br />
|
||||
City, State 12345<br />
|
||||
Country
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Phone className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Phone
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<a href="tel:+1234567890" className="hover:text-[#d4af37] transition-colors">
|
||||
+1 (234) 567-890
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Mail className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Email
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<a href="mailto:info@luxuryhotel.com" className="hover:text-[#d4af37] transition-colors">
|
||||
info@luxuryhotel.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mt-12 animate-fade-in">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center space-x-2 px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
|
||||
>
|
||||
<span>Explore Our Rooms</span>
|
||||
<Hotel className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
|
||||
@@ -137,44 +137,50 @@ const HomePage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Banner Section */}
|
||||
<section className="container mx-auto px-4 pb-8">
|
||||
<>
|
||||
{/* Banner Section - Full Width, breaks out of container */}
|
||||
<section
|
||||
className="relative w-screen -mt-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)'
|
||||
}}
|
||||
>
|
||||
{isLoadingBanners ? (
|
||||
<BannerSkeleton />
|
||||
) : (
|
||||
<BannerCarousel banners={banners} />
|
||||
<div className="animate-fade-in">
|
||||
<BannerCarousel banners={banners}>
|
||||
<SearchRoomForm className="overlay" />
|
||||
</BannerCarousel>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Search Section */}
|
||||
<section className="container mx-auto px-4 py-8">
|
||||
<SearchRoomForm />
|
||||
</section>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100/50 to-gray-50">
|
||||
|
||||
{/* Featured Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-12">
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2
|
||||
className="text-3xl font-bold
|
||||
text-gray-900"
|
||||
>
|
||||
<h2 className="luxury-section-title">
|
||||
Featured Rooms
|
||||
</h2>
|
||||
<p className="luxury-section-subtitle">
|
||||
Discover our most popular accommodations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
text-indigo-600 hover:text-indigo-700
|
||||
font-semibold transition-colors"
|
||||
btn-luxury-secondary group text-white"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -193,21 +199,25 @@ const HomePage: React.FC = () => {
|
||||
{/* Error State */}
|
||||
{error && !isLoadingRooms && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-6 text-center"
|
||||
className="luxury-card p-8 text-center animate-fade-in
|
||||
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16
|
||||
bg-red-100 rounded-full mb-4">
|
||||
<AlertCircle
|
||||
className="w-8 h-8 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-red-800 font-serif font-semibold text-lg mb-2 tracking-tight">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-600
|
||||
text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
|
||||
text-white rounded-sm font-medium tracking-wide
|
||||
hover:from-red-700 hover:to-red-800
|
||||
transition-all duration-300 shadow-lg shadow-red-500/30
|
||||
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
@@ -228,10 +238,9 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-gray-100 rounded-lg
|
||||
p-12 text-center"
|
||||
className="luxury-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
<p className="text-gray-600 text-lg font-light tracking-wide">
|
||||
No featured rooms available
|
||||
</p>
|
||||
</div>
|
||||
@@ -239,16 +248,13 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{featuredRooms.length > 0 && (
|
||||
<div className="mt-8 text-center md:hidden">
|
||||
<div className="mt-10 text-center md:hidden animate-slide-up">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-2
|
||||
bg-indigo-600 text-white px-6 py-3
|
||||
rounded-lg hover:bg-indigo-700
|
||||
transition-colors font-semibold"
|
||||
className="btn-luxury-primary inline-flex items-center gap-2"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<span className="relative z-10">View All Rooms</span>
|
||||
<ArrowRight className="w-5 h-5 relative z-10" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -257,28 +263,27 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Newest Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-12">
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2
|
||||
className="text-3xl font-bold
|
||||
text-gray-900"
|
||||
>
|
||||
<h2 className="luxury-section-title">
|
||||
Newest Rooms
|
||||
</h2>
|
||||
<p className="luxury-section-subtitle">
|
||||
Explore our latest additions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
text-indigo-600 hover:text-indigo-700
|
||||
font-semibold transition-colors"
|
||||
btn-luxury-secondary group text-white"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -308,10 +313,9 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-gray-100 rounded-lg
|
||||
p-12 text-center"
|
||||
className="luxury-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
<p className="text-gray-600 text-lg font-light tracking-wide">
|
||||
No new rooms available
|
||||
</p>
|
||||
</div>
|
||||
@@ -319,16 +323,13 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{newestRooms.length > 0 && (
|
||||
<div className="mt-8 text-center md:hidden">
|
||||
<div className="mt-10 text-center md:hidden animate-slide-up">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-2
|
||||
bg-indigo-600 text-white px-6 py-3
|
||||
rounded-lg hover:bg-indigo-700
|
||||
transition-colors font-semibold"
|
||||
className="btn-luxury-primary inline-flex items-center gap-2"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<span className="relative z-10">View All Rooms</span>
|
||||
<ArrowRight className="w-5 h-5 relative z-10" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -337,73 +338,79 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section
|
||||
className="container mx-auto px-4 py-12
|
||||
bg-white rounded-xl shadow-sm mx-4"
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-3
|
||||
gap-8"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-indigo-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">🏨</span>
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
<div className="luxury-card-gold p-12 animate-fade-in relative overflow-hidden">
|
||||
{/* Decorative gold accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e]"></div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="text-center group">
|
||||
<div
|
||||
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-sm flex items-center justify-center mx-auto mb-6
|
||||
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
|
||||
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
|
||||
transition-all duration-300"
|
||||
>
|
||||
<span className="text-4xl">🏨</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-serif font-semibold mb-3
|
||||
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
|
||||
>
|
||||
Easy Booking
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
Search and book rooms with just a few clicks
|
||||
</p>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
Easy Booking
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Search and book rooms with just a few clicks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">💰</span>
|
||||
<div className="text-center group">
|
||||
<div
|
||||
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-sm flex items-center justify-center mx-auto mb-6
|
||||
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
|
||||
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
|
||||
transition-all duration-300"
|
||||
>
|
||||
<span className="text-4xl">💰</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-serif font-semibold mb-3
|
||||
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
|
||||
>
|
||||
Best Prices
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
Best price guarantee in the market
|
||||
</p>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
Best Prices
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Best price guarantee in the market
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-blue-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">🎧</span>
|
||||
<div className="text-center group">
|
||||
<div
|
||||
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-sm flex items-center justify-center mx-auto mb-6
|
||||
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
|
||||
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
|
||||
transition-all duration-300"
|
||||
>
|
||||
<span className="text-4xl">🎧</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-serif font-semibold mb-3
|
||||
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
|
||||
>
|
||||
24/7 Support
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
Support team always ready to serve
|
||||
</p>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
24/7 Support
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Support team always ready to serve
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
446
Frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
446
Frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Calendar,
|
||||
User,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../../services/api/auditService';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
const AuditLogsPage: React.FC = () => {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
const [filters, setFilters] = useState<AuditLogFilters>({
|
||||
page: 1,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(prev => ({ ...prev, page: currentPage }));
|
||||
}, [currentPage]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await auditService.getAuditLogs({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setLogs(response.data.logs);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit logs:', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load audit logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: keyof AuditLogFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = (searchTerm: string) => {
|
||||
handleFilterChange('search', searchTerm || undefined);
|
||||
};
|
||||
|
||||
const handleViewDetails = (log: AuditLog) => {
|
||||
setSelectedLog(log);
|
||||
setShowDetails(true);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-5 h-5 text-orange-500" />;
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const baseClasses = "px-2 py-1 rounded-full text-xs font-medium";
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
case 'failed':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-orange-100 text-orange-800`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && logs.length === 0) {
|
||||
return <Loading fullScreen text="Loading audit logs..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Audit Logs</h1>
|
||||
<p className="text-gray-600">View all system activity and actions</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actions, types..."
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by action"
|
||||
value={filters.action || ''}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Resource Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by type"
|
||||
value={filters.resource_type || ''}
|
||||
onChange={(e) => handleFilterChange('resource_type', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.start_date || ''}
|
||||
onChange={(e) => handleFilterChange('start_date', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.end_date || ''}
|
||||
onChange={(e) => handleFilterChange('end_date', e.target.value || undefined)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Logs</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalItems}</p>
|
||||
</div>
|
||||
<FileText className="w-8 h-8 text-[#d4af37]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Current Page</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{currentPage}</p>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
{logs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No Audit Logs Found"
|
||||
description="No audit logs match your current filters."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
#{log.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{log.action}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{log.resource_type}</div>
|
||||
{log.resource_id && (
|
||||
<div className="text-xs text-gray-500">ID: {log.resource_id}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{log.user ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{log.user.full_name}</div>
|
||||
<div className="text-xs text-gray-500">{log.user.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">System</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(log.status)}
|
||||
<span className={getStatusBadge(log.status)}>
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{log.ip_address || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleViewDetails(log)}
|
||||
className="text-[#d4af37] hover:text-[#c9a227] flex items-center space-x-1"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>View</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
{showDetails && selectedLog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Audit Log Details</h2>
|
||||
<button
|
||||
onClick={() => setShowDetails(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900">#{selectedLog.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Status</label>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
{getStatusIcon(selectedLog.status)}
|
||||
<span className={getStatusBadge(selectedLog.status)}>
|
||||
{selectedLog.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Action</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.action}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Resource Type</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_type}</p>
|
||||
</div>
|
||||
{selectedLog.resource_id && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Resource ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_id}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Date</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{formatDate(selectedLog.created_at)}</p>
|
||||
</div>
|
||||
{selectedLog.user && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">User</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.user.full_name}</p>
|
||||
<p className="text-xs text-gray-500">{selectedLog.user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">User ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.user_id}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedLog.ip_address && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">IP Address</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{selectedLog.ip_address}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.request_id && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Request ID</label>
|
||||
<p className="mt-1 text-sm text-gray-900 font-mono text-xs">{selectedLog.request_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLog.user_agent && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">User Agent</label>
|
||||
<p className="mt-1 text-sm text-gray-900 break-all">{selectedLog.user_agent}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.error_message && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-red-700">Error Message</label>
|
||||
<p className="mt-1 text-sm text-red-900 bg-red-50 p-3 rounded">{selectedLog.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.details && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Details</label>
|
||||
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-x-auto">
|
||||
{JSON.stringify(selectedLog.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDetails(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLogsPage;
|
||||
|
||||
677
Frontend/src/pages/admin/BannerManagementPage.tsx
Normal file
677
Frontend/src/pages/admin/BannerManagementPage.tsx
Normal file
@@ -0,0 +1,677 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Image as ImageIcon, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { ConfirmationDialog } from '../../components/common';
|
||||
import bannerServiceModule from '../../services/api/bannerService';
|
||||
import type { Banner } from '../../services/api/bannerService';
|
||||
|
||||
// Extract functions from default export - workaround for TypeScript cache issue
|
||||
// All functions are properly exported in bannerService.ts
|
||||
const {
|
||||
getAllBanners,
|
||||
createBanner,
|
||||
updateBanner,
|
||||
deleteBanner,
|
||||
uploadBannerImage,
|
||||
} = bannerServiceModule as any;
|
||||
|
||||
const BannerManagementPage: React.FC = () => {
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; id: number | null }>({
|
||||
show: false,
|
||||
id: null,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
position: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
image_url: '',
|
||||
link: '',
|
||||
position: 'home',
|
||||
display_order: 0,
|
||||
is_active: true,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
});
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [useFileUpload, setUseFileUpload] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBanners();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchBanners = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getAllBanners({
|
||||
position: filters.position || undefined,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
|
||||
let allBanners = response.data?.banners || [];
|
||||
|
||||
// Filter by search if provided
|
||||
if (filters.search) {
|
||||
allBanners = allBanners.filter((banner: Banner) =>
|
||||
banner.title.toLowerCase().includes(filters.search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setBanners(allBanners);
|
||||
setTotalPages(Math.ceil(allBanners.length / itemsPerPage));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load banners');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setImageFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image immediately
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const response = await uploadBannerImage(file);
|
||||
if (response.status === 'success' || response.success) {
|
||||
setFormData({ ...formData, image_url: response.data.image_url });
|
||||
toast.success('Image uploaded successfully');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to upload image');
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate image URL or file
|
||||
if (!formData.image_url && !imageFile) {
|
||||
toast.error('Please upload an image or provide an image URL');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If there's a file but no URL yet, upload it first
|
||||
let imageUrl = formData.image_url;
|
||||
if (imageFile && !imageUrl) {
|
||||
setUploadingImage(true);
|
||||
const uploadResponse = await uploadBannerImage(imageFile);
|
||||
if (uploadResponse.status === 'success' || uploadResponse.success) {
|
||||
imageUrl = uploadResponse.data.image_url;
|
||||
} else {
|
||||
throw new Error('Failed to upload image');
|
||||
}
|
||||
setUploadingImage(false);
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
image_url: imageUrl,
|
||||
start_date: formData.start_date || undefined,
|
||||
end_date: formData.end_date || undefined,
|
||||
};
|
||||
|
||||
if (editingBanner) {
|
||||
await updateBanner(editingBanner.id, submitData);
|
||||
toast.success('Banner updated successfully');
|
||||
} else {
|
||||
await createBanner(submitData);
|
||||
toast.success('Banner created successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchBanners();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (banner: Banner) => {
|
||||
setEditingBanner(banner);
|
||||
setFormData({
|
||||
title: banner.title || '',
|
||||
description: '',
|
||||
image_url: banner.image_url || '',
|
||||
link: banner.link || '',
|
||||
position: banner.position || 'home',
|
||||
display_order: banner.display_order || 0,
|
||||
is_active: banner.is_active ?? true,
|
||||
start_date: banner.start_date ? banner.start_date.split('T')[0] : '',
|
||||
end_date: banner.end_date ? banner.end_date.split('T')[0] : '',
|
||||
});
|
||||
setImageFile(null);
|
||||
// Normalize image URL for preview (handle both relative and absolute URLs)
|
||||
const previewUrl = banner.image_url
|
||||
? (banner.image_url.startsWith('http')
|
||||
? banner.image_url
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${banner.image_url}`)
|
||||
: null;
|
||||
setImagePreview(previewUrl);
|
||||
setUseFileUpload(false); // When editing, show URL by default
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.id) return;
|
||||
|
||||
try {
|
||||
await deleteBanner(deleteConfirm.id);
|
||||
toast.success('Banner deleted successfully');
|
||||
setDeleteConfirm({ show: false, id: null });
|
||||
fetchBanners();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to delete banner');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
image_url: '',
|
||||
link: '',
|
||||
position: 'home',
|
||||
display_order: 0,
|
||||
is_active: true,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
});
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setUseFileUpload(true);
|
||||
setEditingBanner(null);
|
||||
};
|
||||
|
||||
const toggleActive = async (banner: Banner) => {
|
||||
try {
|
||||
await updateBanner(banner.id, {
|
||||
is_active: !banner.is_active,
|
||||
});
|
||||
toast.success(`Banner ${!banner.is_active ? 'activated' : 'deactivated'}`);
|
||||
fetchBanners();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to update banner');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && banners.length === 0) {
|
||||
return <Loading fullScreen text="Loading banners..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Banner Management</h1>
|
||||
<p className="text-gray-600">Manage promotional banners and advertisements</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>Add Banner</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
value={filters.position}
|
||||
onChange={(e) => setFilters({ ...filters, position: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="">All Positions</option>
|
||||
<option value="home">Home</option>
|
||||
<option value="rooms">Rooms</option>
|
||||
<option value="about">About</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banners Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Image
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Position
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Order
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{banners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||
No banners found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
banners.map((banner) => (
|
||||
<tr key={banner.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{banner.image_url ? (
|
||||
<img
|
||||
src={banner.image_url}
|
||||
alt={banner.title}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<ImageIcon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">{banner.title}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
||||
{banner.position}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{banner.display_order}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => toggleActive(banner)}
|
||||
className={`flex items-center space-x-1 px-2 py-1 rounded text-xs font-medium ${
|
||||
banner.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{banner.is_active ? (
|
||||
<>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>Active</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
<span>Inactive</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(banner)}
|
||||
className="text-[#d4af37] hover:text-[#c9a227]"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, id: banner.id })}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{editingBanner ? 'Edit Banner' : 'Create Banner'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
{/* Image Upload/URL Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Banner Image *
|
||||
</label>
|
||||
|
||||
{/* Toggle between file upload and URL */}
|
||||
<div className="flex space-x-4 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseFileUpload(true);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setFormData({ ...formData, image_url: '' });
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
useFileUpload
|
||||
? 'bg-[#d4af37] text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseFileUpload(false);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
!useFileUpload
|
||||
? 'bg-[#d4af37] text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Use URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useFileUpload ? (
|
||||
<div>
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
{uploadingImage ? (
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<Loader2 className="w-8 h-8 text-[#d4af37] animate-spin mb-2" />
|
||||
<p className="text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : imagePreview ? (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setFormData({ ...formData, image_url: '' });
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<ImageIcon className="w-10 h-10 mb-2 text-gray-400" />
|
||||
<p className="mb-2 text-sm text-gray-500">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileChange}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="url"
|
||||
required={!imageFile}
|
||||
value={formData.image_url}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, image_url: e.target.value });
|
||||
setImagePreview(e.target.value || null);
|
||||
}}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
{imagePreview && (
|
||||
<div className="mt-3 relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover rounded-lg border border-gray-300"
|
||||
onError={() => setImagePreview(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
value={formData.position}
|
||||
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="home">Home</option>
|
||||
<option value="rooms">Rooms</option>
|
||||
<option value="about">About</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Display Order
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.display_order}
|
||||
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Link URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.link}
|
||||
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{editingBanner && (
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded border-gray-300 text-[#d4af37] focus:ring-[#d4af37]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end space-x-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227]"
|
||||
>
|
||||
{editingBanner ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, id: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Banner"
|
||||
message="Are you sure you want to delete this banner? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerManagementPage;
|
||||
|
||||
@@ -97,13 +97,13 @@ const BookingManagementPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Booking Management</h1>
|
||||
<p className="text-gray-500 mt-1">Manage bookings</p>
|
||||
<div className="space-y-8">
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="enterprise-section-title">Booking Management</h1>
|
||||
<p className="enterprise-section-subtitle mt-2">Manage and track all hotel bookings</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
@@ -112,13 +112,13 @@ const BookingManagementPage: React.FC = () => {
|
||||
placeholder="Search by booking number, guest name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="enterprise-input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="enterprise-input"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="pending">Pending confirmation</option>
|
||||
@@ -130,34 +130,20 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<div className="enterprise-card overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<table className="enterprise-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Booking Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Customer
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Room
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Check-in/out
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Total Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
<th>Booking Number</th>
|
||||
<th>Customer</th>
|
||||
<th>Room</th>
|
||||
<th>Check-in/out</th>
|
||||
<th>Total Price</th>
|
||||
<th>Status</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody>
|
||||
{bookings.map((booking) => (
|
||||
<tr key={booking.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -242,11 +228,14 @@ const BookingManagementPage: React.FC = () => {
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetailModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Booking Details</h2>
|
||||
<button onClick={() => setShowDetailModal(false)} className="text-gray-500 hover:text-gray-700">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="enterprise-card p-8 w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-scale-in">
|
||||
<div className="flex justify-between items-center mb-6 pb-4 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Booking Details</h2>
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -296,10 +285,10 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
className="btn-enterprise-secondary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
357
Frontend/src/pages/admin/CookieSettingsPage.tsx
Normal file
357
Frontend/src/pages/admin/CookieSettingsPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Shield, SlidersHorizontal, Info, Save, Globe } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import adminPrivacyService, {
|
||||
CookieIntegrationSettings,
|
||||
CookieIntegrationSettingsResponse,
|
||||
CookiePolicySettings,
|
||||
CookiePolicySettingsResponse,
|
||||
} from '../../services/api/adminPrivacyService';
|
||||
import { Loading } from '../../components/common';
|
||||
|
||||
const CookieSettingsPage: React.FC = () => {
|
||||
const [policy, setPolicy] = useState<CookiePolicySettings>({
|
||||
analytics_enabled: true,
|
||||
marketing_enabled: true,
|
||||
preferences_enabled: true,
|
||||
});
|
||||
const [integrations, setIntegrations] = useState<CookieIntegrationSettings>({
|
||||
ga_measurement_id: '',
|
||||
fb_pixel_id: '',
|
||||
});
|
||||
const [policyMeta, setPolicyMeta] = useState<
|
||||
Pick<CookiePolicySettingsResponse, 'updated_at' | 'updated_by'> | null
|
||||
>(null);
|
||||
const [integrationMeta, setIntegrationMeta] = useState<
|
||||
Pick<CookieIntegrationSettingsResponse, 'updated_at' | 'updated_by'> | null
|
||||
>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [policyRes, integrationRes] = await Promise.all([
|
||||
adminPrivacyService.getCookiePolicy(),
|
||||
adminPrivacyService.getIntegrations(),
|
||||
]);
|
||||
setPolicy(policyRes.data);
|
||||
setPolicyMeta({
|
||||
updated_at: policyRes.updated_at,
|
||||
updated_by: policyRes.updated_by,
|
||||
});
|
||||
setIntegrations(integrationRes.data || {});
|
||||
setIntegrationMeta({
|
||||
updated_at: integrationRes.updated_at,
|
||||
updated_by: integrationRes.updated_by,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to load cookie & integration settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleToggle = (key: keyof CookiePolicySettings) => {
|
||||
setPolicy((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const [policyRes, integrationRes] = await Promise.all([
|
||||
adminPrivacyService.updateCookiePolicy(policy),
|
||||
adminPrivacyService.updateIntegrations(integrations),
|
||||
]);
|
||||
setPolicy(policyRes.data);
|
||||
setPolicyMeta({
|
||||
updated_at: policyRes.updated_at,
|
||||
updated_by: policyRes.updated_by,
|
||||
});
|
||||
setIntegrations(integrationRes.data || {});
|
||||
setIntegrationMeta({
|
||||
updated_at: integrationRes.updated_at,
|
||||
updated_by: integrationRes.updated_by,
|
||||
});
|
||||
toast.success('Cookie policy and integrations updated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update cookie settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveIntegrations = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const integrationRes = await adminPrivacyService.updateIntegrations(
|
||||
integrations
|
||||
);
|
||||
setIntegrations(integrationRes.data || {});
|
||||
setIntegrationMeta({
|
||||
updated_at: integrationRes.updated_at,
|
||||
updated_by: integrationRes.updated_by,
|
||||
});
|
||||
toast.success('Integration IDs updated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update integration IDs');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen={false} text="Loading cookie policy..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-amber-500" />
|
||||
<h1 className="enterprise-section-title">Cookie & Privacy Controls</h1>
|
||||
</div>
|
||||
<p className="enterprise-section-subtitle max-w-2xl">
|
||||
Define which cookie categories are allowed in the application. These
|
||||
settings control which types of cookies your users can consent to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-enterprise-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
|
||||
{saving ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info card */}
|
||||
<div className="enterprise-card flex gap-4 p-4 sm:p-5">
|
||||
<div className="mt-1">
|
||||
<Info className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-700">
|
||||
<p className="font-semibold text-gray-900">
|
||||
How these settings affect the guest experience
|
||||
</p>
|
||||
<p>
|
||||
Disabling a category here prevents it from being offered to guests as
|
||||
part of the cookie consent flow. For example, if marketing cookies are
|
||||
disabled, the website should not load marketing pixels even if a guest
|
||||
previously opted in.
|
||||
</p>
|
||||
{policyMeta?.updated_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Last updated on{' '}
|
||||
{new Date(policyMeta.updated_at).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}{' '}
|
||||
{policyMeta.updated_by ? `by ${policyMeta.updated_by}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="enterprise-card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-emerald-500" />
|
||||
Analytics cookies
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Anonymous traffic and performance measurement (e.g. page views,
|
||||
conversion funnels).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle('analytics_enabled')}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
|
||||
policy.analytics_enabled ? 'bg-emerald-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
policy.analytics_enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
When disabled, analytics tracking scripts should not be executed,
|
||||
regardless of user consent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-pink-500" />
|
||||
Marketing cookies
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Personalised offers, remarketing campaigns, and external ad
|
||||
networks.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle('marketing_enabled')}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
|
||||
policy.marketing_enabled ? 'bg-pink-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
policy.marketing_enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
When disabled, do not load any marketing pixels or share data with ad
|
||||
platforms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-indigo-500" />
|
||||
Preference cookies
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Remember guest choices like language, currency, and layout.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle('preferences_enabled')}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
|
||||
policy.preferences_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
policy.preferences_enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
When disabled, the application should avoid persisting non-essential
|
||||
preferences client-side.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integration IDs */}
|
||||
<div className="enterprise-card p-5 space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
Third-party integrations (IDs only)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Configure IDs for supported analytics and marketing platforms. The
|
||||
application will only load these when both the policy and user consent
|
||||
allow it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 md:items-end">
|
||||
{integrationMeta?.updated_at && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Last changed{' '}
|
||||
{new Date(integrationMeta.updated_at).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}{' '}
|
||||
{integrationMeta.updated_by ? `by ${integrationMeta.updated_by}` : ''}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveIntegrations}
|
||||
disabled={saving}
|
||||
className="btn-enterprise-secondary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{saving ? 'Saving IDs...' : 'Save integration IDs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-800">
|
||||
Google Analytics 4 Measurement ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={integrations.ga_measurement_id || ''}
|
||||
onChange={(e) =>
|
||||
setIntegrations((prev) => ({
|
||||
...prev,
|
||||
ga_measurement_id: e.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="G-XXXXXXXXXX"
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Example: <code className="font-mono">G-ABCDE12345</code>. This is used to
|
||||
load GA4 via gtag.js when analytics cookies are allowed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-800">
|
||||
Meta (Facebook) Pixel ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={integrations.fb_pixel_id || ''}
|
||||
onChange={(e) =>
|
||||
setIntegrations((prev) => ({
|
||||
...prev,
|
||||
fb_pixel_id: e.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="123456789012345"
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Numeric ID from your Meta Pixel. The application will only fire pixel
|
||||
events when marketing cookies are allowed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieSettingsPage;
|
||||
|
||||
|
||||
@@ -5,81 +5,108 @@ import {
|
||||
Hotel,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
TrendingUp
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { reportService, ReportData } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { formatCurrency, formatDate } from '../../utils/format';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [stats, setStats] = useState<ReportData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
to: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [dateRange]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await reportService.getReports({
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
});
|
||||
setStats(response.data);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const response = await reportService.getReports({
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
const { data: stats, loading, error, execute } = useAsync<ReportData>(
|
||||
fetchDashboardData,
|
||||
{
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load dashboard data');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
execute();
|
||||
}, [dateRange]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
return <Loading fullScreen text="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EmptyState
|
||||
title="Unable to Load Dashboard"
|
||||
description={error?.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: handleRefresh
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Hotel operations overview</p>
|
||||
<h1 className="enterprise-section-title">Dashboard</h1>
|
||||
<p className="enterprise-section-subtitle mt-2">Hotel operations overview and analytics</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-500">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
<span className="text-gray-500 font-medium">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="enterprise-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="btn-enterprise-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* Total Revenue */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
|
||||
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
|
||||
@@ -91,15 +118,16 @@ const DashboardPage: React.FC = () => {
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Trend indicator - can be enhanced with actual comparison data */}
|
||||
<div className="flex items-center mt-4 text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
||||
<span className="text-green-600 font-medium">+12.5%</span>
|
||||
<span className="text-gray-500 ml-2">compared to last month</span>
|
||||
<span className="text-green-600 font-medium">Active</span>
|
||||
<span className="text-gray-500 ml-2">All time revenue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Bookings */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
|
||||
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
|
||||
@@ -112,14 +140,14 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-4 text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
||||
<span className="text-green-600 font-medium">+8.2%</span>
|
||||
<span className="text-gray-500 ml-2">compared to last month</span>
|
||||
<span className="text-gray-500">
|
||||
{stats.total_bookings > 0 ? 'Total bookings recorded' : 'No bookings yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Rooms */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
|
||||
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
|
||||
@@ -139,7 +167,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Total Customers */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
|
||||
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Customers</p>
|
||||
@@ -152,9 +180,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-4 text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
||||
<span className="text-green-600 font-medium">+15.3%</span>
|
||||
<span className="text-gray-500 ml-2">new customers</span>
|
||||
<span className="text-gray-500">
|
||||
Unique customers with bookings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,72 +190,87 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Chart */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Daily Revenue</h2>
|
||||
<BarChart3 className="w-5 h-5 text-gray-400" />
|
||||
<div className="enterprise-card p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Daily Revenue</h2>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
{stats?.revenue_by_date && stats.revenue_by_date.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{stats.revenue_by_date.slice(0, 7).map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="text-sm text-gray-600 w-24">
|
||||
{new Date(item.date).toLocaleDateString('en-US')}
|
||||
</span>
|
||||
<div className="flex-1 mx-3">
|
||||
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-4 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min((item.revenue / (stats.revenue_by_date?.[0]?.revenue || 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
|
||||
{formatCurrency(item.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Status</h2>
|
||||
{stats?.bookings_by_status ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(stats.bookings_by_status).map(([status, count]) => {
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-500',
|
||||
confirmed: 'bg-blue-500',
|
||||
checked_in: 'bg-green-500',
|
||||
checked_out: 'bg-gray-500',
|
||||
cancelled: 'bg-red-500',
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pending confirmation',
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked in',
|
||||
checked_out: 'Checked out',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
{stats.revenue_by_date.slice(0, 7).map((item, index) => {
|
||||
const maxRevenue = Math.max(...stats.revenue_by_date!.map(r => r.revenue));
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${statusColors[status]}`} />
|
||||
<span className="text-gray-700">{statusLabels[status]}</span>
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="text-sm text-gray-600 w-24">
|
||||
{formatDate(item.date, 'short')}
|
||||
</span>
|
||||
<div className="flex-1 mx-3">
|
||||
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-4 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min((item.revenue / (maxRevenue || 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{count}</span>
|
||||
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
|
||||
{formatCurrency(item.revenue, 'VND')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
<EmptyState
|
||||
title="No Revenue Data"
|
||||
description="No revenue data available for the selected date range"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Booking Status</h2>
|
||||
</div>
|
||||
{stats?.bookings_by_status && Object.keys(stats.bookings_by_status).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(stats.bookings_by_status)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([status, count]) => {
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-500',
|
||||
confirmed: 'bg-blue-500',
|
||||
checked_in: 'bg-green-500',
|
||||
checked_out: 'bg-gray-500',
|
||||
cancelled: 'bg-red-500',
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pending confirmation',
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked in',
|
||||
checked_out: 'Checked out',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${statusColors[status] || 'bg-gray-500'}`} />
|
||||
<span className="text-gray-700">{statusLabels[status] || status}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Booking Data"
|
||||
description="No booking status data available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,51 +278,57 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Top Rooms and Services */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Rooms */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Booked Rooms</h2>
|
||||
<div className="enterprise-card p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Top Booked Rooms</h2>
|
||||
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{stats.top_rooms.map((room, index) => (
|
||||
<div key={room.room_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center justify-center w-8 h-8 bg-blue-500 text-white rounded-full font-bold">
|
||||
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-blue-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-blue-200 hover:shadow-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl font-bold shadow-lg shadow-blue-500/30">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Room {room.room_number}</p>
|
||||
<p className="text-sm text-gray-500">{room.bookings} bookings</p>
|
||||
<p className="text-sm text-gray-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrency(room.revenue)}
|
||||
{formatCurrency(room.revenue, 'VND')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
<EmptyState
|
||||
title="No Room Data"
|
||||
description="No room booking data available for the selected date range"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Usage */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Services Used</h2>
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Services Used</h2>
|
||||
{stats?.service_usage && stats.service_usage.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{stats.service_usage.map((service) => (
|
||||
<div key={service.service_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div key={service.service_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-purple-200 hover:shadow-md">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{service.service_name}</p>
|
||||
<p className="text-sm text-gray-500">{service.usage_count} times used</p>
|
||||
<p className="text-sm text-gray-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
|
||||
</div>
|
||||
<span className="font-semibold text-purple-600">
|
||||
{formatCurrency(service.total_revenue)}
|
||||
{formatCurrency(service.total_revenue, 'VND')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
<EmptyState
|
||||
title="No Service Data"
|
||||
description="No service usage data available for the selected date range"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
376
Frontend/src/pages/admin/ReportsPage.tsx
Normal file
376
Frontend/src/pages/admin/ReportsPage.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Users,
|
||||
Hotel,
|
||||
TrendingUp,
|
||||
Download,
|
||||
Filter,
|
||||
BarChart3,
|
||||
PieChart
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { reportService, ReportData } from '../../services/api/reportService';
|
||||
import { formatCurrency, formatDate } from '../../utils/format';
|
||||
|
||||
const ReportsPage: React.FC = () => {
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: '',
|
||||
to: '',
|
||||
});
|
||||
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'monthly' | 'yearly' | ''>('');
|
||||
|
||||
const fetchReports = async (): Promise<ReportData> => {
|
||||
const params: any = {};
|
||||
if (dateRange.from) params.from = dateRange.from;
|
||||
if (dateRange.to) params.to = dateRange.to;
|
||||
if (reportType) params.type = reportType;
|
||||
|
||||
const response = await reportService.getReports(params);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const {
|
||||
data: reportData,
|
||||
loading,
|
||||
error,
|
||||
execute: refetchReports
|
||||
} = useAsync<ReportData>(fetchReports, {
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load reports');
|
||||
}
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (dateRange.from) params.from = dateRange.from;
|
||||
if (dateRange.to) params.to = dateRange.to;
|
||||
if (reportType) params.type = reportType;
|
||||
|
||||
const blob = await reportService.exportReport(params);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `report-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Report exported successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to export report');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilter = () => {
|
||||
refetchReports();
|
||||
};
|
||||
|
||||
if (loading && !reportData) {
|
||||
return <Loading fullScreen text="Loading reports..." />;
|
||||
}
|
||||
|
||||
if (error && !reportData) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<EmptyState
|
||||
title="Unable to Load Reports"
|
||||
description={error.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: refetchReports
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Reports & Analytics</h1>
|
||||
<p className="text-gray-600">View comprehensive reports and statistics</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
From Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
To Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Report Type
|
||||
</label>
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value as any)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleFilter}
|
||||
className="w-full px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reportData && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-blue-500">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Calendar className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Bookings
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{reportData.total_bookings || 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-green-500">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Revenue
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{formatCurrency(reportData.total_revenue || 0)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-purple-500">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Users className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Customers
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{reportData.total_customers || 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-orange-500">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<Hotel className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Available Rooms
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{reportData.available_rooms || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{reportData.occupied_rooms || 0} occupied
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
{reportData.bookings_by_status && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
<PieChart className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Bookings by Status</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{Object.entries(reportData.bookings_by_status).map(([status, count]) => (
|
||||
<div key={status} className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-gray-800">{count}</p>
|
||||
<p className="text-sm text-gray-600 capitalize mt-1">{status.replace('_', ' ')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue by Date */}
|
||||
{reportData.revenue_by_date && reportData.revenue_by_date.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
<BarChart3 className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Revenue by Date</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Bookings
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Revenue
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportData.revenue_by_date.map((item, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatDate(new Date(item.date), 'short')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.bookings}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatCurrency(item.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Rooms */}
|
||||
{reportData.top_rooms && reportData.top_rooms.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Top Performing Rooms</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Room Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Bookings
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Revenue
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportData.top_rooms.map((room) => (
|
||||
<tr key={room.room_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{room.room_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{room.bookings}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatCurrency(room.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Usage */}
|
||||
{reportData.service_usage && reportData.service_usage.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Service Usage</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Service Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Usage Count
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Total Revenue
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportData.service_usage.map((service) => (
|
||||
<tr key={service.service_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{service.service_name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{service.usage_count}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatCurrency(service.total_revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
|
||||
@@ -8,3 +8,4 @@ export { default as ReviewManagementPage } from './ReviewManagementPage';
|
||||
export { default as PromotionManagementPage } from './PromotionManagementPage';
|
||||
export { default as CheckInPage } from './CheckInPage';
|
||||
export { default as CheckOutPage } from './CheckOutPage';
|
||||
export { default as AuditLogsPage } from './AuditLogsPage';
|
||||
|
||||
@@ -61,35 +61,42 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br
|
||||
from-blue-50 to-indigo-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
from-gray-50 via-gray-100 to-gray-50
|
||||
flex items-center justify-center py-12 px-4
|
||||
sm:px-6 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Luxury background pattern */}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-blue-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
<div className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Login
|
||||
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Welcome back to Hotel Booking
|
||||
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
|
||||
Sign in to Luxury Hotel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-lg
|
||||
text-sm"
|
||||
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
text-sm font-light"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -100,7 +107,7 @@ const LoginPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
@@ -115,18 +122,16 @@ const LoginPage: React.FC = () => {
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${errors.email
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -137,7 +142,7 @@ const LoginPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
@@ -152,34 +157,31 @@ const LoginPage: React.FC = () => {
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={`block w-full pl-10 pr-10 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${errors.password
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
}`}
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5
|
||||
text-gray-400 hover:text-gray-600"
|
||||
text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -192,14 +194,14 @@ const LoginPage: React.FC = () => {
|
||||
{...register('rememberMe')}
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600
|
||||
focus:ring-blue-500 border-gray-300
|
||||
rounded cursor-pointer"
|
||||
className="h-4 w-4 text-[#d4af37]
|
||||
focus:ring-[#d4af37]/50 border-gray-300
|
||||
rounded-sm cursor-pointer accent-[#d4af37]"
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="ml-2 block text-sm
|
||||
text-gray-700 cursor-pointer"
|
||||
text-gray-700 cursor-pointer font-light tracking-wide"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
@@ -208,8 +210,8 @@ const LoginPage: React.FC = () => {
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm font-medium
|
||||
text-blue-600 hover:text-blue-500
|
||||
transition-colors"
|
||||
text-[#d4af37] hover:text-[#c9a227]
|
||||
transition-colors tracking-wide"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
@@ -219,27 +221,20 @@ const LoginPage: React.FC = () => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-transparent rounded-lg shadow-sm
|
||||
text-sm font-medium text-white
|
||||
bg-blue-600 hover:bg-blue-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2 focus:ring-blue-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-3 px-4 text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
mr-2 h-5 w-5 relative z-10"
|
||||
/>
|
||||
Processing...
|
||||
<span className="relative z-10">Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="-ml-1 mr-2 h-5 w-5" />
|
||||
Login
|
||||
<LogIn className="-ml-1 mr-2 h-5 w-5 relative z-10" />
|
||||
<span className="relative z-10">Sign In</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -247,12 +242,12 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 font-light tracking-wide">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600
|
||||
hover:text-blue-500 transition-colors"
|
||||
className="font-medium text-[#d4af37]
|
||||
hover:text-[#c9a227] transition-colors"
|
||||
>
|
||||
Register now
|
||||
</Link>
|
||||
@@ -261,19 +256,19 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
|
||||
<p>
|
||||
By logging in, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
|
||||
@@ -96,27 +96,33 @@ const RegisterPage: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br
|
||||
from-purple-50 to-pink-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
from-gray-50 via-gray-100 to-gray-50 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Luxury background pattern */}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-purple-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
<div className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
|
||||
Create Account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Create a new account to book hotel rooms
|
||||
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
|
||||
Join Luxury Hotel for exclusive benefits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-5"
|
||||
@@ -124,9 +130,9 @@ const RegisterPage: React.FC = () => {
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-lg
|
||||
text-sm"
|
||||
className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
text-sm font-light"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -137,7 +143,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
@@ -154,20 +160,16 @@ const RegisterPage: React.FC = () => {
|
||||
id="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.name
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.name
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -178,7 +180,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
@@ -195,20 +197,16 @@ const RegisterPage: React.FC = () => {
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -219,7 +217,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
@@ -236,20 +234,16 @@ const RegisterPage: React.FC = () => {
|
||||
id="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.phone
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.phone
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="0123456789"
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -260,7 +254,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
@@ -277,38 +271,33 @@ const RegisterPage: React.FC = () => {
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
className={`block w-full pl-10 pr-10 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -322,7 +311,13 @@ const RegisterPage: React.FC = () => {
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all
|
||||
duration-300 ${passwordStrength.color}`}
|
||||
duration-300 ${
|
||||
passwordStrength.strength >= 4
|
||||
? 'bg-[#d4af37]'
|
||||
: passwordStrength.strength >= 3
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{
|
||||
width: `${
|
||||
(passwordStrength.strength / 5) * 100
|
||||
@@ -331,7 +326,7 @@ const RegisterPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium
|
||||
text-gray-600"
|
||||
text-gray-600 tracking-wide"
|
||||
>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
@@ -369,7 +364,7 @@ const RegisterPage: React.FC = () => {
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
@@ -388,15 +383,11 @@ const RegisterPage: React.FC = () => {
|
||||
showConfirmPassword ? 'text' : 'password'
|
||||
}
|
||||
autoComplete="new-password"
|
||||
className={`block w-full pl-10 pr-10 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
@@ -405,23 +396,22 @@ const RegisterPage: React.FC = () => {
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -431,28 +421,20 @@ const RegisterPage: React.FC = () => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-transparent rounded-lg shadow-sm
|
||||
text-sm font-medium text-white
|
||||
bg-purple-600 hover:bg-purple-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2 focus:ring-purple-500
|
||||
disabled:opacity-50
|
||||
disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-3 px-4 text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
mr-2 h-5 w-5 relative z-10"
|
||||
/>
|
||||
Processing...
|
||||
<span className="relative z-10">Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Register
|
||||
<UserPlus className="-ml-1 mr-2 h-5 w-5 relative z-10" />
|
||||
<span className="relative z-10">Register</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -460,12 +442,12 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 font-light tracking-wide">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-purple-600
|
||||
hover:text-purple-500 transition-colors"
|
||||
className="font-medium text-[#d4af37]
|
||||
hover:text-[#c9a227] transition-colors"
|
||||
>
|
||||
Login now
|
||||
</Link>
|
||||
@@ -474,19 +456,19 @@ const RegisterPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
|
||||
<p>
|
||||
By registering, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-purple-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-purple-600 hover:underline"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
@@ -502,13 +484,13 @@ const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
}> = ({ met, text }) => (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 text-xs font-light">
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<CheckCircle2 className="h-4 w-4 text-[#d4af37]" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-gray-300" />
|
||||
)}
|
||||
<span className={met ? 'text-green-600' : 'text-gray-500'}>
|
||||
<span className={met ? 'text-[#c9a227] font-medium' : 'text-gray-500'}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,243 +4,250 @@ import {
|
||||
Hotel,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Activity
|
||||
Activity,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dashboardService, { CustomerDashboardStats } from '../../services/api/dashboardService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { formatCurrency, formatDate, formatRelativeTime } from '../../utils/format';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchDashboardData = async (): Promise<CustomerDashboardStats> => {
|
||||
const response = await dashboardService.getCustomerDashboardStats();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const { data: stats, loading, error, execute } = useAsync<CustomerDashboardStats>(
|
||||
fetchDashboardData,
|
||||
{
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load dashboard data');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<EmptyState
|
||||
title="Unable to Load Dashboard"
|
||||
description={error?.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: handleRefresh
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
<div className="mb-10 animate-fade-in">
|
||||
<h1 className="enterprise-section-title mb-2">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Overview of your activity
|
||||
<p className="enterprise-section-subtitle">
|
||||
Overview of your activity and bookings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-4 gap-6 mb-8"
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
{/* Total Bookings */}
|
||||
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Calendar className="w-6 h-6
|
||||
text-blue-600"
|
||||
/>
|
||||
<Calendar className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+12%
|
||||
</span>
|
||||
{stats.booking_change_percentage !== 0 && (
|
||||
<span className={`text-sm font-medium flex items-center gap-1 ${
|
||||
stats.booking_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stats.booking_change_percentage > 0 ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
{Math.abs(stats.booking_change_percentage).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Bookings
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
45
|
||||
{stats.total_bookings}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
{/* Total Spending */}
|
||||
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<DollarSign className="w-6 h-6
|
||||
text-green-600"
|
||||
/>
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+8%
|
||||
</span>
|
||||
{stats.spending_change_percentage !== 0 && (
|
||||
<span className={`text-sm font-medium flex items-center gap-1 ${
|
||||
stats.spending_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stats.spending_change_percentage > 0 ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
{Math.abs(stats.spending_change_percentage).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Total Spending
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{formatCurrency(12450)}
|
||||
{formatCurrency(stats.total_spending, 'VND')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
{/* Currently Staying */}
|
||||
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Hotel className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
{stats.currently_staying > 0 && (
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Currently Staying
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
2
|
||||
{stats.currently_staying}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
{/* Upcoming Bookings Count */}
|
||||
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<TrendingUp className="w-6 h-6
|
||||
text-orange-600"
|
||||
/>
|
||||
<TrendingUp className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+15%
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
Reward Points
|
||||
<h3 className="text-gray-500 text-sm font-medium mb-1">
|
||||
Upcoming Bookings
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
1,250
|
||||
{stats.upcoming_bookings.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2
|
||||
gap-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<h2 className="text-xl font-semibold
|
||||
text-gray-800 mb-4"
|
||||
>
|
||||
{/* Recent Activity & Upcoming Bookings */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activity */}
|
||||
<div className="enterprise-card p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
action: 'Booking',
|
||||
room: 'Room 201',
|
||||
time: '2 hours ago'
|
||||
},
|
||||
{
|
||||
action: 'Check-in',
|
||||
room: 'Room 105',
|
||||
time: '1 day ago'
|
||||
},
|
||||
{
|
||||
action: 'Check-out',
|
||||
room: 'Room 302',
|
||||
time: '3 days ago'
|
||||
},
|
||||
].map((activity, index) => (
|
||||
<div key={index}
|
||||
className="flex items-center space-x-4
|
||||
pb-4 border-b border-gray-200
|
||||
last:border-0"
|
||||
>
|
||||
<div className="p-2 bg-blue-100
|
||||
rounded-lg"
|
||||
{stats.recent_activity && stats.recent_activity.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{stats.recent_activity.map((activity, index) => (
|
||||
<div
|
||||
key={activity.booking_id || index}
|
||||
className="flex items-center space-x-4 pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/bookings/${activity.booking_id}`)}
|
||||
>
|
||||
<Activity className="w-5 h-5
|
||||
text-blue-600"
|
||||
/>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-800">
|
||||
{activity.action}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{activity.room?.room_number || activity.booking_number}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatRelativeTime(new Date(activity.created_at))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-800">
|
||||
{activity.action}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{activity.room}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{activity.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Recent Activity"
|
||||
description="Your recent bookings and activities will appear here"
|
||||
action={{
|
||||
label: 'View All Bookings',
|
||||
onClick: () => navigate('/bookings')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<h2 className="text-xl font-semibold
|
||||
text-gray-800 mb-4"
|
||||
>
|
||||
{/* Upcoming Bookings */}
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
|
||||
Upcoming Bookings
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
room: 'Room 401',
|
||||
date: '20/11/2025',
|
||||
status: 'Confirmed'
|
||||
},
|
||||
{
|
||||
room: 'Room 203',
|
||||
date: '25/11/2025',
|
||||
status: 'Pending confirmation'
|
||||
},
|
||||
].map((booking, index) => (
|
||||
<div key={index}
|
||||
className="flex items-center
|
||||
justify-between pb-4 border-b
|
||||
border-gray-200 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
{booking.room}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{booking.date}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full
|
||||
text-xs font-medium
|
||||
${booking.status === 'Confirmed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
{stats.upcoming_bookings && stats.upcoming_bookings.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{stats.upcoming_bookings.map((booking) => (
|
||||
<div
|
||||
key={booking.id}
|
||||
className="flex items-center justify-between pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||
>
|
||||
{booking.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
Room {booking.room?.room_number || 'N/A'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatDate(booking.check_in_date, 'medium')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{formatCurrency(booking.total_price, 'VND')}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`enterprise-badge ${
|
||||
booking.status === 'confirmed'
|
||||
? 'bg-green-100 text-green-800 shadow-sm shadow-green-500/20'
|
||||
: booking.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800 shadow-sm shadow-yellow-500/20'
|
||||
: 'bg-gray-100 text-gray-800 shadow-sm'
|
||||
}`}>
|
||||
{booking.status.charAt(0).toUpperCase() + booking.status.slice(1).replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Upcoming Bookings"
|
||||
description="You don't have any upcoming bookings yet"
|
||||
action={{
|
||||
label: 'Browse Rooms',
|
||||
onClick: () => navigate('/rooms')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
543
Frontend/src/pages/customer/ProfilePage.tsx
Normal file
543
Frontend/src/pages/customer/ProfilePage.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Save,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Camera
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import authService from '../../services/api/authService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { useGlobalLoading } from '../../contexts/GlobalLoadingContext';
|
||||
|
||||
// Validation schema
|
||||
const profileValidationSchema = yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.required('Full name is required')
|
||||
.min(2, 'Full name must be at least 2 characters')
|
||||
.max(100, 'Full name cannot exceed 100 characters'),
|
||||
email: yup
|
||||
.string()
|
||||
.required('Email is required')
|
||||
.email('Invalid email address'),
|
||||
phone: yup
|
||||
.string()
|
||||
.required('Phone number is required')
|
||||
.matches(
|
||||
/^[0-9]{10,11}$/,
|
||||
'Phone number must have 10-11 digits'
|
||||
),
|
||||
});
|
||||
|
||||
const passwordValidationSchema = yup.object().shape({
|
||||
currentPassword: yup
|
||||
.string()
|
||||
.required('Current password is required'),
|
||||
newPassword: yup
|
||||
.string()
|
||||
.required('New password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required('Please confirm your password')
|
||||
.oneOf([yup.ref('newPassword')], 'Passwords must match'),
|
||||
});
|
||||
|
||||
type ProfileFormData = yup.InferType<typeof profileValidationSchema>;
|
||||
type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { userInfo, setUser } = useAuthStore();
|
||||
const { setLoading } = useGlobalLoading();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
|
||||
// Fetch profile data
|
||||
const fetchProfile = async () => {
|
||||
const response = await authService.getProfile();
|
||||
if (response.status === 'success' || response.success) {
|
||||
const user = response.data?.user || response.data;
|
||||
if (user) {
|
||||
setUser(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to load profile');
|
||||
};
|
||||
|
||||
const {
|
||||
data: profileData,
|
||||
loading: loadingProfile,
|
||||
error: profileError,
|
||||
execute: refetchProfile
|
||||
} = useAsync(fetchProfile, {
|
||||
immediate: true,
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Unable to load profile');
|
||||
}
|
||||
});
|
||||
|
||||
// Profile form
|
||||
const {
|
||||
register: registerProfile,
|
||||
handleSubmit: handleSubmitProfile,
|
||||
formState: { errors: profileErrors },
|
||||
reset: resetProfile,
|
||||
} = useForm<ProfileFormData>({
|
||||
resolver: yupResolver(profileValidationSchema),
|
||||
defaultValues: {
|
||||
name: userInfo?.name || '',
|
||||
email: userInfo?.email || '',
|
||||
phone: userInfo?.phone || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Password form
|
||||
const {
|
||||
register: registerPassword,
|
||||
handleSubmit: handleSubmitPassword,
|
||||
formState: { errors: passwordErrors },
|
||||
reset: resetPassword,
|
||||
} = useForm<PasswordFormData>({
|
||||
resolver: yupResolver(passwordValidationSchema),
|
||||
});
|
||||
|
||||
// Update form when profile data loads
|
||||
useEffect(() => {
|
||||
if (profileData || userInfo) {
|
||||
const data = profileData || userInfo;
|
||||
resetProfile({
|
||||
name: data?.name || '',
|
||||
email: data?.email || '',
|
||||
phone: data?.phone || '',
|
||||
});
|
||||
if (data?.avatar) {
|
||||
setAvatarPreview(data.avatar);
|
||||
}
|
||||
}
|
||||
}, [profileData, userInfo, resetProfile]);
|
||||
|
||||
// Handle profile update
|
||||
const onSubmitProfile = async (data: ProfileFormData) => {
|
||||
try {
|
||||
setLoading(true, 'Updating profile...');
|
||||
|
||||
// Check if updateProfile exists in authService, otherwise use userService
|
||||
if ('updateProfile' in authService) {
|
||||
const response = await (authService as any).updateProfile({
|
||||
full_name: data.name,
|
||||
email: data.email,
|
||||
phone_number: data.phone,
|
||||
});
|
||||
|
||||
if (response.status === 'success' || response.success) {
|
||||
const updatedUser = response.data?.user || response.data;
|
||||
if (updatedUser) {
|
||||
setUser(updatedUser);
|
||||
toast.success('Profile updated successfully!');
|
||||
refetchProfile();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use userService if updateProfile doesn't exist
|
||||
const { updateUser } = await import('../../services/api/userService');
|
||||
const response = await updateUser(userInfo!.id, {
|
||||
full_name: data.name,
|
||||
email: data.email,
|
||||
phone_number: data.phone,
|
||||
});
|
||||
|
||||
if (response.success || response.status === 'success') {
|
||||
const updatedUser = response.data?.user || response.data;
|
||||
if (updatedUser) {
|
||||
setUser({
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.full_name || updatedUser.name,
|
||||
email: updatedUser.email,
|
||||
phone: updatedUser.phone_number || updatedUser.phone,
|
||||
avatar: updatedUser.avatar,
|
||||
role: updatedUser.role,
|
||||
});
|
||||
toast.success('Profile updated successfully!');
|
||||
refetchProfile();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'Failed to update profile';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle password change
|
||||
const onSubmitPassword = async (data: PasswordFormData) => {
|
||||
try {
|
||||
setLoading(true, 'Changing password...');
|
||||
|
||||
// Use updateProfile with password fields if available
|
||||
if ('updateProfile' in authService) {
|
||||
const response = await (authService as any).updateProfile({
|
||||
currentPassword: data.currentPassword,
|
||||
password: data.newPassword,
|
||||
});
|
||||
|
||||
if (response.status === 'success' || response.success) {
|
||||
toast.success('Password changed successfully!');
|
||||
resetPassword();
|
||||
}
|
||||
} else {
|
||||
// Fallback: use userService
|
||||
const { updateUser } = await import('../../services/api/userService');
|
||||
const response = await updateUser(userInfo!.id, {
|
||||
password: data.newPassword,
|
||||
});
|
||||
|
||||
if (response.success || response.status === 'success') {
|
||||
toast.success('Password changed successfully!');
|
||||
resetPassword();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'Failed to change password';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle avatar upload (placeholder - would need backend support)
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Image size must be less than 2MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// TODO: Upload to backend
|
||||
toast.info('Avatar upload feature coming soon');
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingProfile) {
|
||||
return <Loading fullScreen text="Loading profile..." />;
|
||||
}
|
||||
|
||||
if (profileError && !userInfo) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<EmptyState
|
||||
title="Unable to Load Profile"
|
||||
description={profileError.message || 'Something went wrong. Please try again.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: refetchProfile
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<h1 className="enterprise-section-title mb-2">
|
||||
Profile Settings
|
||||
</h1>
|
||||
<p className="enterprise-section-subtitle">
|
||||
Manage your account information and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<div className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'profile'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Profile Information
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'password'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="enterprise-card animate-slide-up">
|
||||
<form onSubmit={handleSubmitProfile(onSubmitProfile)} className="space-y-6">
|
||||
{/* Avatar Section */}
|
||||
<div className="flex items-center space-x-6 pb-6 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
{avatarPreview || userInfo?.avatar ? (
|
||||
<img
|
||||
src={avatarPreview || userInfo?.avatar}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover ring-4 ring-[#d4af37]/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center ring-4 ring-[#d4af37]/20">
|
||||
<User className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="absolute bottom-0 right-0 p-2 bg-[#d4af37] rounded-full cursor-pointer hover:bg-[#c9a227] transition-colors shadow-lg"
|
||||
>
|
||||
<Camera className="w-4 h-4 text-white" />
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{userInfo?.name || 'User'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{userInfo?.email}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{userInfo?.role?.charAt(0).toUpperCase() + userInfo?.role?.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<User className="w-4 h-4 inline mr-2" />
|
||||
Full Name
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerProfile('name')}
|
||||
type="text"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
profileErrors.name ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
{profileErrors.name && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{profileErrors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Mail className="w-4 h-4 inline mr-2" />
|
||||
Email Address
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerProfile('email')}
|
||||
type="email"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
profileErrors.email ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
{profileErrors.email && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{profileErrors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Phone className="w-4 h-4 inline mr-2" />
|
||||
Phone Number
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerProfile('phone')}
|
||||
type="tel"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
profileErrors.phone ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your phone number"
|
||||
/>
|
||||
{profileErrors.phone && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{profileErrors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Tab */}
|
||||
{activeTab === 'password' && (
|
||||
<div className="enterprise-card animate-slide-up">
|
||||
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-6">
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">
|
||||
Password Requirements
|
||||
</h4>
|
||||
<ul className="text-xs text-blue-700 space-y-1">
|
||||
<li>• At least 6 characters long</li>
|
||||
<li>• Use a combination of letters and numbers for better security</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 inline mr-2" />
|
||||
Current Password
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerPassword('currentPassword')}
|
||||
type="password"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
passwordErrors.currentPassword ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your current password"
|
||||
/>
|
||||
{passwordErrors.currentPassword && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordErrors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 inline mr-2" />
|
||||
New Password
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerPassword('newPassword')}
|
||||
type="password"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
passwordErrors.newPassword ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
{passwordErrors.newPassword && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordErrors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 inline mr-2" />
|
||||
Confirm New Password
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...registerPassword('confirmPassword')}
|
||||
type="password"
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
|
||||
passwordErrors.confirmPassword ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Confirm your new password"
|
||||
/>
|
||||
{passwordErrors.confirmPassword && (
|
||||
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordErrors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>Change Password</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
|
||||
@@ -66,23 +66,24 @@ const RoomListPage: React.FC = () => {
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50/30 to-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
text-white px-3 py-2 rounded-md hover:bg-indigo-700
|
||||
disabled:bg-gray-400 mb-6 transition-colors"
|
||||
className="inline-flex items-center gap-2 btn-enterprise-secondary mb-8 animate-fade-in"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl text-center font-bold text-gray-900">
|
||||
<div className="mb-10 text-center animate-fade-in">
|
||||
<h1 className="enterprise-section-title">
|
||||
Room List
|
||||
</h1>
|
||||
<p className="enterprise-section-subtitle mt-2">
|
||||
Browse our available accommodations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
@@ -102,29 +103,34 @@ const RoomListPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="bg-red-50 border border-red-200
|
||||
rounded-lg p-6 text-center"
|
||||
<div className="enterprise-card p-8 text-center animate-fade-in
|
||||
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-red-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0
|
||||
9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-red-800 font-medium">{error}</p>
|
||||
<div className="inline-flex items-center justify-center w-16 h-16
|
||||
bg-red-100 rounded-full mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0
|
||||
9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-red-800 font-semibold text-lg mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-600
|
||||
text-white rounded-lg hover:bg-red-700
|
||||
transition-colors"
|
||||
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
|
||||
text-white rounded-lg font-semibold
|
||||
hover:from-red-700 hover:to-red-800
|
||||
transition-all duration-300 shadow-lg shadow-red-500/30
|
||||
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
@@ -132,26 +138,28 @@ const RoomListPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-12 text-center"
|
||||
<div className="enterprise-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<svg
|
||||
className="w-24 h-24 text-gray-300 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14
|
||||
0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1
|
||||
4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold
|
||||
text-gray-800 mb-2"
|
||||
<div className="inline-flex items-center justify-center w-24 h-24
|
||||
bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14
|
||||
0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1
|
||||
4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold
|
||||
text-gray-900 mb-2"
|
||||
>
|
||||
No matching rooms found
|
||||
</h3>
|
||||
@@ -160,8 +168,7 @@ const RoomListPage: React.FC = () => {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/rooms'}
|
||||
className="px-6 py-2 bg-blue-600 text-white
|
||||
rounded-lg hover:bg-blue-700 transition-colors"
|
||||
className="btn-enterprise-primary"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user