Files
Hotel-Booking/Frontend/src/pages/customer/RoomListPage.tsx
Iliyan Angelov a1bd576540 updates
2025-11-17 23:50:14 +02:00

284 lines
12 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { getRooms } from '../../services/api/roomService';
import type { Room } from '../../services/api/roomService';
import RoomFilter from '../../components/rooms/RoomFilter';
import RoomCard from '../../components/rooms/RoomCard';
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
import Pagination from '../../components/rooms/Pagination';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
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,
});
// Scroll to filter when opened on mobile
useEffect(() => {
if (isFilterOpen && filterRef.current && window.innerWidth < 1280) {
setTimeout(() => {
filterRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}, [isFilterOpen]);
// Fetch rooms based on URL params
useEffect(() => {
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.data) {
setRooms(response.data.rooms || []);
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
throw new Error('Failed to fetch rooms');
}
} catch (err) {
console.error('Error fetching rooms:', err);
setError('Unable to load room list. Please try again.');
} finally {
setLoading(false);
}
};
fetchRooms();
}, [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' }}>
{/* Full-width hero section */}
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/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">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37]
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-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/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>
{/* Page Header */}
<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-[#d4af37]/10 rounded-lg border border-[#d4af37]/30 backdrop-blur-sm">
<Hotel className="w-5 h-5 sm:w-5 sm:h-5 text-[#d4af37]" />
</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-[#d4af37] 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>
{/* Full-width content area */}
<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">
{/* Mobile Filter Toggle Button */}
<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-[#d4af37]/30 rounded-xl p-4
backdrop-blur-xl shadow-lg shadow-[#d4af37]/10
flex items-center justify-between gap-3
hover:border-[#d4af37]/50 hover:shadow-xl hover:shadow-[#d4af37]/20
transition-all duration-300 touch-manipulation"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-[#d4af37]/10 rounded-lg border border-[#d4af37]/30">
<Filter className="w-5 h-5 text-[#d4af37]" />
</div>
<span className="text-white font-medium tracking-wide text-base">
Filters
</span>
</div>
{isFilterOpen ? (
<ChevronUp className="w-5 h-5 text-[#d4af37]" />
) : (
<ChevronDown className="w-5 h-5 text-[#d4af37]" />
)}
</button>
</div>
{/* Filter Sidebar - Collapsible on mobile, sidebar on desktop */}
<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 Content - Full width on mobile, 9 columns on desktop */}
<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-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
transition-all duration-300 shadow-lg shadow-[#d4af37]/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-[#d4af37]/20 rounded-xl p-8 sm:p-10 md:p-12 lg:p-16 text-center
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/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-[#d4af37]/10 rounded-2xl mb-4 sm:mb-5 md:mb-6 lg:mb-8 border border-[#d4af37]/30"
>
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 text-[#d4af37]" />
</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-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
transition-all duration-300 shadow-lg shadow-[#d4af37]/30
touch-manipulation min-h-[44px]"
>
Clear Filters
</button>
</div>
)}
{!loading && !error && rooms.length > 0 && (
<>
{/* Results Count */}
<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-[#d4af37] font-medium">{rooms.length}</span> of{' '}
<span className="text-[#d4af37] font-medium">{pagination.total}</span> rooms
</p>
</div>
{/* Responsive grid: 1 col mobile, 2 cols tablet, 3 cols desktop */}
<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-[#d4af37]/20">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</div>
)}
</>
)}
</main>
</div>
</div>
</div>
</div>
);
};
export default RoomListPage;