Files
Hotel-Booking/Frontend/src/pages/customer/RoomListPage.tsx
Iliyan Angelov b818d645a9 updates
2025-12-07 20:36:17 +02:00

391 lines
17 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { getRooms } from '../../features/rooms/services/roomService';
import type { Room } from '../../features/rooms/services/roomService';
import RoomFilter from '../../features/rooms/components/RoomFilter';
import RoomCard from '../../features/rooms/components/RoomCard';
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
import Pagination from '../../shared/components/Pagination';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp, Tag, X, CheckCircle } from 'lucide-react';
import { logger } from '../../shared/utils/logger';
import { toast } from 'react-toastify';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterRef = useRef<HTMLDivElement>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 10,
totalPages: 1,
});
const abortControllerRef = useRef<AbortController | null>(null);
const [activePromotion, setActivePromotion] = useState<any>(null);
const [showPromotionBanner, setShowPromotionBanner] = useState(false);
// Check for active promotion from URL or sessionStorage
useEffect(() => {
const promoCode = searchParams.get('promo');
// Check sessionStorage first (from homepage promotion click)
try {
const storedPromotion = sessionStorage.getItem('activePromotion');
if (storedPromotion) {
const promo = JSON.parse(storedPromotion);
setActivePromotion(promo);
setShowPromotionBanner(true);
return;
}
} catch (error) {
console.warn('Failed to read promotion from sessionStorage:', error);
}
// Check URL params (fallback)
if (promoCode) {
setActivePromotion({
code: promoCode,
title: 'Special Offer',
discount: searchParams.get('discount') || '',
});
setShowPromotionBanner(true);
}
}, [searchParams]);
const handleDismissPromotion = () => {
setShowPromotionBanner(false);
try {
sessionStorage.removeItem('activePromotion');
} catch (error) {
console.warn('Failed to remove promotion from sessionStorage:', error);
}
};
useEffect(() => {
if (isFilterOpen && filterRef.current && window.innerWidth < 1280) {
setTimeout(() => {
filterRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}, [isFilterOpen]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
const fetchRooms = async () => {
setLoading(true);
setError(null);
try {
const params = {
type: searchParams.get('type') || undefined,
minPrice: searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined,
maxPrice: searchParams.get('maxPrice')
? Number(searchParams.get('maxPrice'))
: undefined,
capacity: searchParams.get('capacity')
? Number(searchParams.get('capacity'))
: undefined,
page: searchParams.get('page')
? Number(searchParams.get('page'))
: 1,
limit: 12,
};
const response = await getRooms(params);
if ((response.status === 'success' || response.success) && response.data) {
setRooms(response.data.rooms || []);
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
throw new Error('Failed to fetch rooms');
}
} catch (err: any) {
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
// Clear data when API connection fails
setRooms([]);
setPagination({
total: 0,
page: 1,
limit: 12,
totalPages: 0,
});
logger.error('Error fetching rooms', err);
setError('Unable to load room list. Please try again.');
} finally {
setLoading(false);
}
};
fetchRooms();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [searchParams]);
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
{}
{/* Promotion Banner */}
{showPromotionBanner && activePromotion && (
<div className="w-full bg-gradient-to-r from-[var(--luxury-gold)]/20 via-[var(--luxury-gold-light)]/15 to-[var(--luxury-gold)]/20 border-b border-[var(--luxury-gold)]/30">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4">
<div className="flex items-center justify-between gap-4 bg-gradient-to-r from-[#1a1a1a] to-[#0f0f0f] border border-[var(--luxury-gold)]/40 rounded-lg p-4 backdrop-blur-xl shadow-lg">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 bg-[var(--luxury-gold)]/20 rounded-lg border border-[var(--luxury-gold)]/40">
<Tag className="w-5 h-5 text-[var(--luxury-gold)]" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-[var(--luxury-gold)]">
Active Promotion: {activePromotion.code || activePromotion.title}
</span>
</div>
{activePromotion.discount && (
<p className="text-xs text-gray-300">
{activePromotion.discount} - {activePromotion.description || 'Valid on bookings'}
</p>
)}
<p className="text-xs text-gray-400 mt-1">
The promotion code will be automatically applied when you book a room
</p>
</div>
</div>
<button
onClick={handleDismissPromotion}
className="p-2 hover:bg-[var(--luxury-gold)]/10 rounded-lg transition-colors"
aria-label="Dismiss promotion"
>
<X className="w-5 h-5 text-gray-400 hover:text-white" />
</button>
</div>
</div>
</div>
)}
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[var(--luxury-gold)]/10 pt-6 sm:pt-7 md:pt-8">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-6 sm:py-7 md:py-8 lg:py-10">
{}
<Link
to="/"
className="inline-flex items-center gap-2
bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)]
text-[#0f0f0f] hover:from-[var(--luxury-gold-light)] hover:to-[var(--luxury-gold)]
active:scale-95
mb-4 sm:mb-5 md:mb-6 transition-all duration-300
group font-medium tracking-wide text-sm
px-4 py-2 rounded-sm
shadow-lg shadow-[var(--luxury-gold)]/30 hover:shadow-xl hover:shadow-[var(--luxury-gold)]/40
touch-manipulation"
>
<ArrowLeft className="w-4 h-4 sm:w-4 sm:h-4 group-hover:-translate-x-1 transition-transform" />
<span>Back to home</span>
</Link>
{}
<div className="text-center max-w-3xl mx-auto px-2">
<div className="inline-flex items-center justify-center gap-2 mb-3 sm:mb-4">
<div className="p-2 sm:p-2.5 bg-[var(--luxury-gold)]/10 rounded-lg border border-[var(--luxury-gold)]/30 backdrop-blur-sm">
<Hotel className="w-5 h-5 sm:w-5 sm:h-5 text-[var(--luxury-gold)]" />
</div>
</div>
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold
text-white mb-2 sm:mb-3 tracking-tight leading-tight px-2
bg-gradient-to-r from-white via-[var(--luxury-gold)] to-white
bg-clip-text text-transparent"
>
Our Rooms & Suites
</h1>
<p className="text-gray-400 font-light tracking-wide text-xs sm:text-sm md:text-base max-w-xl mx-auto px-2 sm:px-4 leading-relaxed">
Discover our collection of luxurious accommodations,
each designed to provide an exceptional stay
</p>
</div>
</div>
</div>
{}
<div className="w-full py-4 sm:py-5 md:py-6 lg:py-8">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
<div className="grid grid-cols-1 xl:grid-cols-12 gap-3 sm:gap-4 md:gap-5 lg:gap-6 xl:gap-7">
{}
<div className="xl:hidden order-1 mb-4">
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className="w-full bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
border border-[var(--luxury-gold)]/30 rounded-xl p-4
backdrop-blur-xl shadow-lg shadow-[var(--luxury-gold)]/10
flex items-center justify-between gap-3
hover:border-[var(--luxury-gold)]/50 hover:shadow-xl hover:shadow-[var(--luxury-gold)]/20
transition-all duration-300 touch-manipulation"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-[var(--luxury-gold)]/10 rounded-lg border border-[var(--luxury-gold)]/30">
<Filter className="w-5 h-5 text-[var(--luxury-gold)]" />
</div>
<span className="text-white font-medium tracking-wide text-base">
Filters
</span>
</div>
{isFilterOpen ? (
<ChevronUp className="w-5 h-5 text-[var(--luxury-gold)]" />
) : (
<ChevronDown className="w-5 h-5 text-[var(--luxury-gold)]" />
)}
</button>
</div>
{}
<aside
ref={filterRef}
className={`xl:col-span-3 order-2 xl:order-1 mb-4 sm:mb-5 md:mb-6 xl:mb-0 transition-all duration-300 ${
isFilterOpen ? 'block' : 'hidden xl:block'
}`}
>
<div className="xl:sticky xl:top-4 xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto">
<RoomFilter />
</div>
</aside>
{}
<main className="xl:col-span-9 order-3 xl:order-2">
{loading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
gap-3 sm:gap-4 md:gap-5 lg:gap-6"
>
{Array.from({ length: 6 }).map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{error && !loading && (
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-6 sm:p-8 md:p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10"
>
<div className="inline-flex items-center justify-center w-14 h-14 sm:w-16 sm:h-16 md:w-20 md:h-20
bg-red-500/20 rounded-full mb-4 sm:mb-5 md:mb-6 border border-red-500/30"
>
<svg
className="w-7 h-7 sm:w-8 sm:h-8 md:w-10 md:h-10 text-red-400"
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-300 font-light text-sm sm:text-base md:text-lg mb-4 sm:mb-5 md:mb-6 tracking-wide px-2">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-5 sm:px-6 md:px-8 py-2.5 sm:py-3 bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)]
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[var(--luxury-gold-light)] hover:to-[var(--luxury-gold)] active:scale-95
transition-all duration-300 shadow-lg shadow-[var(--luxury-gold)]/30
touch-manipulation min-h-[44px]"
>
Try Again
</button>
</div>
)}
{!loading && !error && rooms.length === 0 && (
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
border border-[var(--luxury-gold)]/20 rounded-xl p-8 sm:p-10 md:p-12 lg:p-16 text-center
backdrop-blur-xl shadow-2xl shadow-[var(--luxury-gold)]/5"
>
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24
bg-[var(--luxury-gold)]/10 rounded-2xl mb-4 sm:mb-5 md:mb-6 lg:mb-8 border border-[var(--luxury-gold)]/30"
>
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 text-[var(--luxury-gold)]" />
</div>
<h3 className="text-lg sm:text-xl md:text-2xl font-serif font-semibold
text-white mb-3 sm:mb-4 tracking-wide px-2"
>
No matching rooms found
</h3>
<p className="text-gray-400 font-light tracking-wide mb-5 sm:mb-6 md:mb-8 text-sm sm:text-base md:text-lg px-2">
Please try adjusting the filters or search differently
</p>
<button
onClick={() => window.location.href = '/rooms'}
className="px-5 sm:px-6 md:px-8 py-2.5 sm:py-3 bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)]
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[var(--luxury-gold-light)] hover:to-[var(--luxury-gold)] active:scale-95
transition-all duration-300 shadow-lg shadow-[var(--luxury-gold)]/30
touch-manipulation min-h-[44px]"
>
Clear Filters
</button>
</div>
)}
{!loading && !error && rooms.length > 0 && (
<>
{}
<div className="mb-3 sm:mb-4 md:mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-3">
<p className="text-gray-400 font-light tracking-wide text-xs sm:text-sm md:text-base">
Showing <span className="text-[var(--luxury-gold)] font-medium">{rooms.length}</span> of{' '}
<span className="text-[var(--luxury-gold)] font-medium">{pagination.total}</span> rooms
</p>
</div>
{}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
gap-3 sm:gap-4 md:gap-5 lg:gap-6 mb-4 sm:mb-5 md:mb-6"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
{pagination.totalPages > 1 && (
<div className="mt-4 sm:mt-5 md:mt-6 pt-3 sm:pt-4 border-t border-[var(--luxury-gold)]/20">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</div>
)}
</>
)}
</main>
</div>
</div>
</div>
</div>
);
};
export default RoomListPage;