282 lines
9.1 KiB
TypeScript
282 lines
9.1 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import {
|
|
Users,
|
|
MapPin,
|
|
DollarSign,
|
|
ArrowLeft,
|
|
} from 'lucide-react';
|
|
import { getRoomById, type Room } from
|
|
'../../services/api/roomService';
|
|
import RoomGallery from '../../components/rooms/RoomGallery';
|
|
import RoomAmenities from '../../components/rooms/RoomAmenities';
|
|
import ReviewSection from '../../components/rooms/ReviewSection';
|
|
import RatingStars from '../../components/rooms/RatingStars';
|
|
|
|
const RoomDetailPage: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [room, setRoom] = useState<Room | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
fetchRoomDetail(Number(id));
|
|
}
|
|
}, [id]);
|
|
|
|
const fetchRoomDetail = async (roomId: number) => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const response = await getRoomById(roomId);
|
|
|
|
// backend uses `status: 'success'` (not `success`), accept both
|
|
if ((response as any).success || (response as any).status === 'success') {
|
|
if (response.data && response.data.room) {
|
|
setRoom(response.data.room);
|
|
} else {
|
|
throw new Error('Failed to fetch room details');
|
|
}
|
|
} else {
|
|
throw new Error('Failed to fetch room details');
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error fetching room:', err);
|
|
const message =
|
|
err.response?.data?.message ||
|
|
'Unable to load room information';
|
|
setError(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
|
<div className="animate-pulse space-y-6">
|
|
<div className="h-96 bg-gray-300 rounded-lg" />
|
|
<div className="h-8 bg-gray-300 rounded w-1/3" />
|
|
<div className="h-4 bg-gray-300 rounded w-2/3" />
|
|
<div className="h-32 bg-gray-300 rounded" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !room) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
|
<div className="bg-red-50 border border-red-200
|
|
rounded-lg p-8 text-center"
|
|
>
|
|
<p className="text-red-800 font-medium mb-4">
|
|
{error || 'Room not found'}
|
|
</p>
|
|
<button
|
|
onClick={() => navigate('/rooms')}
|
|
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"
|
|
>
|
|
Back to Room List
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const roomType = room.room_type;
|
|
const formattedPrice = new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
}).format(roomType?.base_price || 0);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Back Button */}
|
|
<Link
|
|
to="/rooms"
|
|
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"
|
|
>
|
|
<ArrowLeft className="w-5 h-5" />
|
|
<span>Back to room list</span>
|
|
</Link>
|
|
|
|
{/* Image Gallery */}
|
|
<div className="mb-8">
|
|
<RoomGallery
|
|
images={roomType?.images || []}
|
|
roomName={roomType?.name || 'Room'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Room Information */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-12">
|
|
{/* Main Info */}
|
|
<div className="lg:col-span-8 space-y-6">
|
|
{/* Title & Basic Info */}
|
|
<div>
|
|
<h1 className="text-4xl font-bold
|
|
text-gray-900 mb-4"
|
|
>
|
|
{roomType?.name}
|
|
</h1>
|
|
|
|
<div className="flex flex-wrap items-center
|
|
gap-6 text-gray-600 mb-4"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="w-5 h-5" />
|
|
<span>
|
|
Room {room.room_number} - Floor {room.floor}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-5 h-5" />
|
|
<span>
|
|
{roomType?.capacity || 0} guests
|
|
</span>
|
|
</div>
|
|
|
|
{room.average_rating != null && (
|
|
<div className="flex items-center gap-2">
|
|
<RatingStars
|
|
rating={Number(room.average_rating)}
|
|
size="sm"
|
|
showNumber
|
|
/>
|
|
<span className="text-sm text-gray-500">
|
|
({room.total_reviews || 0} đánh giá)
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Badge */}
|
|
<div
|
|
className={`inline-block px-4 py-2
|
|
rounded-full text-sm font-semibold
|
|
${
|
|
room.status === 'available'
|
|
? 'bg-green-100 text-green-800'
|
|
: room.status === 'occupied'
|
|
? 'bg-red-100 text-red-800'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{room.status === 'available'
|
|
? 'Available'
|
|
: room.status === 'occupied'
|
|
? 'Booked'
|
|
: 'Maintenance'}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{roomType?.description && (
|
|
<div>
|
|
<h2 className="text-2xl font-bold
|
|
text-gray-900 mb-4"
|
|
>
|
|
Room Description
|
|
</h2>
|
|
<p className="text-gray-700 leading-relaxed">
|
|
{roomType.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Amenities */}
|
|
<div>
|
|
<h2 className="text-2xl font-bold
|
|
text-gray-900 mb-4"
|
|
>
|
|
Amenities
|
|
</h2>
|
|
<RoomAmenities
|
|
amenities={
|
|
(room.amenities && room.amenities.length > 0)
|
|
? room.amenities
|
|
: (roomType?.amenities || [])
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Booking Card */}
|
|
<aside className="lg:col-span-4">
|
|
<div className="bg-white rounded-xl shadow-md p-6 sticky top-6">
|
|
<div className="flex items-baseline gap-3 mb-4">
|
|
<DollarSign className="w-5 h-5 text-gray-600" />
|
|
<div>
|
|
<div className="text-3xl font-extrabold text-indigo-600">
|
|
{formattedPrice}
|
|
</div>
|
|
<div className="text-sm text-gray-500">/ night</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Link
|
|
to={`/booking/${room.id}`}
|
|
className={`block w-full py-3 text-center font-semibold rounded-md transition-colors ${
|
|
room.status === 'available'
|
|
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
|
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
|
}`}
|
|
onClick={(e) => {
|
|
if (room.status !== 'available') e.preventDefault();
|
|
}}
|
|
>
|
|
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
|
</Link>
|
|
</div>
|
|
|
|
{room.status === 'available' && (
|
|
<p className="text-sm text-gray-500 text-center mt-3">
|
|
No immediate charge — pay at the hotel
|
|
</p>
|
|
)}
|
|
|
|
<hr className="my-4" />
|
|
|
|
<div className="text-sm text-gray-700 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span>Room Type</span>
|
|
<strong>{roomType?.name}</strong>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span>Guests</span>
|
|
<span>{roomType?.capacity} guests</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span>Rooms</span>
|
|
<span>1</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
{/* Reviews Section */}
|
|
<div className="mb-12">
|
|
<ReviewSection roomId={room.id} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RoomDetailPage;
|