Files
Hotel-Booking/Frontend/src/pages/customer/MyBookingsPage.tsx
Iliyan Angelov 0c59fe1173 updates
2025-11-17 18:26:30 +02:00

641 lines
21 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Calendar,
MapPin,
Users,
CreditCard,
Eye,
XCircle,
AlertCircle,
CheckCircle,
Clock,
DoorOpen,
DoorClosed,
Loader2,
Search,
Filter,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getMyBookings,
cancelBooking,
type Booking,
} from '../../services/api/bookingService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import EmptyState from '../../components/common/EmptyState';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const MyBookingsPage: React.FC = () => {
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const { formatCurrency } = useFormatCurrency();
const [bookings, setBookings] = useState<Booking[]>([]);
const [filteredBookings, setFilteredBookings] =
useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancellingId, setCancellingId] =
useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] =
useState<string>('all');
// Redirect if not authenticated
useEffect(() => {
if (!isAuthenticated) {
toast.error('Please login to view your bookings');
navigate('/login', {
state: { from: '/bookings' }
});
}
}, [isAuthenticated, navigate]);
// Fetch bookings
useEffect(() => {
if (isAuthenticated) {
fetchBookings();
}
}, [isAuthenticated]);
// Filter bookings
useEffect(() => {
let filtered = [...bookings];
// Filter by status
if (statusFilter !== 'all') {
filtered = filtered.filter(
(b) => b.status === statusFilter
);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(b) =>
b.booking_number.toLowerCase().includes(query) ||
b.room?.room_type?.name
.toLowerCase()
.includes(query) ||
b.room?.room_number
.toString()
.includes(query)
);
}
setFilteredBookings(filtered);
}, [bookings, statusFilter, searchQuery]);
const fetchBookings = async () => {
try {
setLoading(true);
setError(null);
const response = await getMyBookings();
if (
response.success &&
response.data?.bookings
) {
setBookings(response.data.bookings);
} else {
throw new Error(
'Unable to load bookings list'
);
}
} catch (err: any) {
console.error('Error fetching bookings:', err);
const message =
err.response?.data?.message ||
'Unable to load bookings list';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const handleCancelBooking = async (
bookingId: number,
bookingNumber: string
) => {
const confirmed = window.confirm(
`Are you sure you want to cancel booking ${bookingNumber}?\n\n` +
`⚠️ Note:\n` +
`- You will be charged 20% of the order value\n` +
`- The remaining 80% will be refunded\n` +
`- Room status will be updated to "available"`
);
if (!confirmed) return;
try {
setCancellingId(bookingId);
const response = await cancelBooking(bookingId);
if (response.success) {
toast.success(
`✅ Successfully cancelled booking ${bookingNumber}!`
);
// Update local state
setBookings((prev) =>
prev.map((b) =>
b.id === bookingId
? { ...b, status: 'cancelled' }
: b
)
);
} else {
throw new Error(
response.message ||
'Unable to cancel booking'
);
}
} catch (err: any) {
console.error('Error cancelling booking:', err);
const message =
err.response?.data?.message ||
'Unable to cancel booking. Please try again.';
toast.error(message);
} finally {
setCancellingId(null);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const formatPrice = (price: number) => formatCurrency(price);
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
icon: Clock,
color: 'bg-yellow-100 text-yellow-800',
text: 'Pending confirmation',
};
case 'confirmed':
return {
icon: CheckCircle,
color: 'bg-green-100 text-green-800',
text: 'Confirmed',
};
case 'cancelled':
return {
icon: XCircle,
color: 'bg-red-100 text-red-800',
text: 'Cancelled',
};
case 'checked_in':
return {
icon: DoorOpen,
color: 'bg-blue-100 text-blue-800',
text: 'Checked in',
};
case 'checked_out':
return {
icon: DoorClosed,
color: 'bg-gray-100 text-gray-800',
text: 'Checked out',
};
default:
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: status,
};
}
};
const canCancelBooking = (booking: Booking) => {
// Only allow cancellation of pending bookings
return booking.status === 'pending';
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900
mb-2"
>
My Bookings
</h1>
<p className="text-gray-600">
Manage and track your bookings
</p>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-md
p-4 mb-6"
>
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search
className="absolute left-3 top-1/2
-translate-y-1/2 w-5 h-5
text-gray-400"
/>
<input
type="text"
placeholder="Search by booking number, room name..."
value={searchQuery}
onChange={(e) =>
setSearchQuery(e.target.value)
}
className="w-full pl-10 pr-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
/>
</div>
</div>
{/* Status Filter */}
<div className="md:w-64">
<div className="relative">
<Filter
className="absolute left-3 top-1/2
-translate-y-1/2 w-5 h-5
text-gray-400"
/>
<select
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value)
}
className="w-full pl-10 pr-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500
appearance-none bg-white"
>
<option value="all">All statuses</option>
<option value="pending">Pending confirmation</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">
Checked in
</option>
<option value="checked_out">
Checked out
</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
{/* Results count */}
<div className="mt-3 text-sm text-gray-600">
Showing {filteredBookings.length} /
{bookings.length} bookings
</div>
</div>
{/* Error State */}
{error && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-6 mb-6 flex items-start gap-3"
>
<AlertCircle
className="w-6 h-6 text-red-500
flex-shrink-0 mt-0.5"
/>
<div>
<p className="text-red-700 font-medium">
{error}
</p>
<button
onClick={fetchBookings}
className="mt-2 text-sm text-red-600
hover:text-red-800 underline"
>
Try again
</button>
</div>
</div>
)}
{/* Bookings List */}
{filteredBookings.length === 0 ? (
<EmptyState
icon={Calendar}
title={
searchQuery || statusFilter !== 'all'
? 'No bookings found'
: 'No bookings yet'
}
description={
searchQuery || statusFilter !== 'all'
? 'Try changing filters or search keywords'
: 'Start booking to enjoy your vacation'
}
>
{!searchQuery && statusFilter === 'all' ? (
<Link
to="/rooms"
className="inline-flex items-center
gap-2 px-6 py-3 bg-indigo-600
text-white rounded-lg
hover:bg-indigo-700
transition-colors font-semibold"
>
View room list
</Link>
) : (
<button
onClick={() => {
setSearchQuery('');
setStatusFilter('all');
}}
className="px-6 py-3 bg-gray-600
text-white rounded-lg
hover:bg-gray-700 transition-colors
font-semibold"
>
Clear filters
</button>
)}
</EmptyState>
) : (
<div className="space-y-4">
{filteredBookings.map((booking) => {
const statusConfig = getStatusConfig(
booking.status
);
const StatusIcon = statusConfig.icon;
const room = booking.room;
const roomType = room?.room_type;
return (
<div
key={booking.id}
className="bg-white rounded-lg shadow-md
hover:shadow-lg transition-shadow
overflow-hidden"
>
<div className="p-6">
<div className="flex flex-col
lg:flex-row gap-6"
>
{/* Room Image */}
{((room?.images && room.images.length > 0)
? room.images[0]
: roomType?.images?.[0]) && (
<div className="lg:w-48 flex-shrink-0">
<img
src={(room?.images && room.images.length > 0)
? room.images[0]
: (roomType?.images?.[0] || '')}
alt={roomType.name}
className="w-full h-48 lg:h-full
object-cover rounded-lg"
/>
</div>
)}
{/* Booking Info */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start
justify-between gap-4 mb-3"
>
<div>
<h3 className="text-xl font-bold
text-gray-900 mb-1"
>
{roomType?.name || 'N/A'}
</h3>
<p className="text-sm
text-gray-600"
>
<MapPin
className="w-4 h-4 inline
mr-1"
/>
Room {room?.room_number} -
Floor {room?.floor}
</p>
</div>
{/* Status Badge */}
<div
className={`flex items-center
gap-2 px-3 py-1.5 rounded-full
text-sm font-medium
${statusConfig.color}`}
>
<StatusIcon
className="w-4 h-4"
/>
{statusConfig.text}
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-1
sm:grid-cols-2 gap-3 mb-4"
>
{/* Booking Number */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
Booking number
</p>
<p className="font-medium
text-gray-900 font-mono"
>
{booking.booking_number}
</p>
</div>
{/* Check-in */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<Calendar
className="w-3 h-3 inline
mr-1"
/>
Check-in date
</p>
<p className="font-medium
text-gray-900"
>
{formatDate(
booking.check_in_date
)}
</p>
</div>
{/* Check-out */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<Calendar
className="w-3 h-3 inline
mr-1"
/>
Check-out date
</p>
<p className="font-medium
text-gray-900"
>
{formatDate(
booking.check_out_date
)}
</p>
</div>
{/* Guest Count */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<Users
className="w-3 h-3 inline
mr-1"
/>
Guests
</p>
<p className="font-medium
text-gray-900"
>
{booking.guest_count} guest(s)
</p>
</div>
{/* Payment Method */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
<CreditCard
className="w-3 h-3 inline
mr-1"
/>
Payment
</p>
<p className="font-medium
text-gray-900"
>
{booking.payment_method === 'cash'
? 'On-site'
: 'Bank transfer'}
</p>
</div>
{/* Total Price */}
<div>
<p className="text-xs text-gray-500
mb-1"
>
Total price
</p>
<p className="font-bold
text-indigo-600 text-lg"
>
{formatPrice(booking.total_price)}
</p>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3
pt-4 border-t"
>
{/* View Details */}
<Link
to={`/bookings/${booking.id}`}
className="inline-flex items-center
gap-2 px-4 py-2
bg-indigo-600 text-white
rounded-lg hover:bg-indigo-700
transition-colors font-medium
text-sm"
>
<Eye className="w-4 h-4" />
View details
</Link>
{/* Cancel Booking */}
{canCancelBooking(booking) && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCancelBooking(
booking.id,
booking.booking_number
);
}}
disabled={
cancellingId === booking.id
}
className="inline-flex
items-center gap-2 px-4 py-2
bg-red-600 text-white
rounded-lg hover:bg-red-700
transition-colors font-medium
text-sm disabled:bg-gray-400
disabled:cursor-not-allowed cursor-pointer"
>
{cancellingId === booking.id ? (
<>
<Loader2
className="w-4 h-4
animate-spin"
/>
Cancelling...
</>
) : (
<>
<XCircle
className="w-4 h-4"
/>
Cancel booking
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default MyBookingsPage;