update to python fastpi

This commit is contained in:
Iliyan Angelov
2025-11-16 15:59:05 +02:00
parent 93d4c1df80
commit 98ccd5b6ff
4464 changed files with 773233 additions and 13740 deletions

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SidebarAdmin } from '../components/layout';
const AdminLayout: React.FC = () => {
return (
<div className="flex h-screen bg-gray-100">
{/* Admin Sidebar */}
<SidebarAdmin />
{/* Admin Content Area */}
<div className="flex-1 overflow-auto">
<div className="p-6">
<Outlet />
</div>
</div>
</div>
);
};
export default AdminLayout;

View File

@@ -0,0 +1,410 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowRight,
AlertCircle,
} from 'lucide-react';
import {
BannerCarousel,
BannerSkeleton,
RoomCard,
RoomCardSkeleton,
SearchRoomForm,
} from '../components/rooms';
import {
bannerService,
roomService
} from '../services/api';
import type { Banner } from '../services/api/bannerService';
import type { Room } from '../services/api/roomService';
const HomePage: React.FC = () => {
const [banners, setBanners] = useState<Banner[]>([]);
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
const [newestRooms, setNewestRooms] = useState<Room[]>([]);
const [isLoadingBanners, setIsLoadingBanners] =
useState(true);
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch banners
useEffect(() => {
const fetchBanners = async () => {
try {
setIsLoadingBanners(true);
const response = await bannerService
.getBannersByPosition('home');
// Handle both response formats
if (
response.success ||
response.status === 'success'
) {
setBanners(response.data?.banners || []);
}
} catch (err: any) {
console.error('Error fetching banners:', err);
// Don't show error for banners, just use fallback
// Silently fail - banners are not critical for page functionality
} finally {
setIsLoadingBanners(false);
}
};
fetchBanners();
}, []);
// Fetch featured rooms
useEffect(() => {
const fetchFeaturedRooms = async () => {
try {
setIsLoadingRooms(true);
setError(null);
const response = await roomService.getFeaturedRooms({
featured: true,
limit: 6,
});
// Handle both response formats
if (
response.success ||
response.status === 'success'
) {
const rooms = response.data?.rooms || [];
setFeaturedRooms(rooms);
// If no rooms found but request succeeded, don't show error
if (rooms.length === 0) {
setError(null);
}
} else {
// Response didn't indicate success
setError(
response.message ||
'Unable to load room list'
);
}
} catch (err: any) {
console.error('Error fetching rooms:', err);
// Check if it's a rate limit error
if (err.response?.status === 429) {
setError(
'Too many requests. Please wait a moment and refresh the page.'
);
} else {
setError(
err.response?.data?.message ||
err.message ||
'Unable to load room list'
);
}
} finally {
setIsLoadingRooms(false);
}
};
fetchFeaturedRooms();
}, []);
// Fetch newest rooms
useEffect(() => {
const fetchNewestRooms = async () => {
try {
setIsLoadingNewest(true);
const response = await roomService.getRooms({
page: 1,
limit: 6,
sort: 'newest',
});
// Handle both response formats
if (
response.success ||
response.status === 'success'
) {
setNewestRooms(response.data?.rooms || []);
}
} catch (err: any) {
console.error('Error fetching newest rooms:', err);
// Silently fail for newest rooms section - not critical
} finally {
setIsLoadingNewest(false);
}
};
fetchNewestRooms();
}, []);
return (
<div className="min-h-screen bg-gray-50">
{/* Banner Section */}
<section className="container mx-auto px-4 pb-8">
{isLoadingBanners ? (
<BannerSkeleton />
) : (
<BannerCarousel banners={banners} />
)}
</section>
{/* Search Section */}
<section className="container mx-auto px-4 py-8">
<SearchRoomForm />
</section>
{/* Featured Rooms Section */}
<section className="container mx-auto px-4 py-12">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
Featured Rooms
</h2>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
{/* Loading State */}
{isLoadingRooms && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{/* Error State */}
{error && !isLoadingRooms && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-6 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium">
{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"
>
Try Again
</button>
</div>
)}
{/* Rooms Grid */}
{!isLoadingRooms && !error && (
<>
{featuredRooms.length > 0 ? (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{featuredRooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
>
<p className="text-gray-600 text-lg">
No featured rooms available
</p>
</div>
)}
{/* View All Button (Mobile) */}
{featuredRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<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"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
)}
</>
)}
</section>
{/* Newest Rooms Section */}
<section className="container mx-auto px-4 py-12">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
Newest Rooms
</h2>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
{/* Loading State */}
{isLoadingNewest && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{/* Rooms Grid */}
{!isLoadingNewest && (
<>
{newestRooms.length > 0 ? (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{newestRooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
>
<p className="text-gray-600 text-lg">
No new rooms available
</p>
</div>
)}
{/* View All Button (Mobile) */}
{newestRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<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"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
</Link>
</div>
)}
</>
)}
</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>
</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>
<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>
<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>
);
};
export default HomePage;

View File

@@ -0,0 +1,314 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const BookingManagementPage: React.FC = () => {
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchBookings();
}, [filters, currentPage]);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await bookingService.getAllBookings({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setBookings(response.data.bookings);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load bookings list');
} finally {
setLoading(false);
}
};
const handleUpdateStatus = async (id: number, status: string) => {
try {
await bookingService.updateBooking(id, { status } as any);
toast.success('Status updated successfully');
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const handleCancelBooking = async (id: number) => {
if (!window.confirm('Are you sure you want to cancel this booking?')) return;
try {
await bookingService.cancelBooking(id);
toast.success('Booking cancelled successfully');
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to cancel booking');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending confirmation' },
confirmed: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Confirmed' },
checked_in: { bg: 'bg-green-100', text: 'text-green-800', label: 'Checked in' },
checked_out: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Checked out' },
cancelled: { bg: 'bg-red-100', text: 'text-red-800', label: 'Cancelled' },
};
const badge = badges[status] || badges.pending;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
if (loading) {
return <Loading />;
}
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>
<div className="bg-white rounded-lg shadow-md p-4">
<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" />
<input
type="text"
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"
/>
</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"
>
<option value="">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 className="bg-white rounded-lg shadow-md overflow-hidden">
<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">
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>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{bookings.map((booking) => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-blue-600">{booking.booking_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{booking.guest_info?.full_name || booking.user?.name}</div>
<div className="text-xs text-gray-500">{booking.guest_info?.email || booking.user?.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
Room {booking.room?.room_number} - {booking.room?.room_type?.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Date(booking.check_in_date).toLocaleDateString('en-US')}
</div>
<div className="text-xs text-gray-500">
to {new Date(booking.check_out_date).toLocaleDateString('en-US')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{formatCurrency(booking.total_price)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(booking.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedBooking(booking);
setShowDetailModal(true);
}}
className="text-blue-600 hover:text-blue-900 mr-2"
title="View details"
>
<Eye className="w-5 h-5" />
</button>
{booking.status === 'pending' && (
<>
<button
onClick={() => handleUpdateStatus(booking.id, 'confirmed')}
className="text-green-600 hover:text-green-900 mr-2"
title="Confirm"
>
<CheckCircle className="w-5 h-5" />
</button>
<button
onClick={() => handleCancelBooking(booking.id)}
className="text-red-600 hover:text-red-900"
title="Cancel"
>
<XCircle className="w-5 h-5" />
</button>
</>
)}
{booking.status === 'confirmed' && (
<button
onClick={() => handleUpdateStatus(booking.id, 'checked_in')}
className="text-green-600 hover:text-green-900"
title="Check-in"
>
<CheckCircle className="w-5 h-5" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{/* 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">
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-500">Booking Number</label>
<p className="text-lg font-semibold">{selectedBooking.booking_number}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Status</label>
<div className="mt-1">{getStatusBadge(selectedBooking.status)}</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Customer Information</label>
<p className="text-gray-900">{selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</p>
<p className="text-gray-600">{selectedBooking.guest_info?.email || selectedBooking.user?.email}</p>
<p className="text-gray-600">{selectedBooking.guest_info?.phone || selectedBooking.user?.phone}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Room Information</label>
<p className="text-gray-900">Room {selectedBooking.room?.room_number} - {selectedBooking.room?.room_type?.name}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-500">Check-in Date</label>
<p className="text-gray-900">{new Date(selectedBooking.check_in_date).toLocaleDateString('en-US')}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Check-out Date</label>
<p className="text-gray-900">{new Date(selectedBooking.check_out_date).toLocaleDateString('en-US')}</p>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Number of Guests</label>
<p className="text-gray-900">{selectedBooking.guest_count} guest(s)</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Total Price</label>
<p className="text-2xl font-bold text-green-600">{formatCurrency(selectedBooking.total_price)}</p>
</div>
{selectedBooking.notes && (
<div>
<label className="text-sm font-medium text-gray-500">Notes</label>
<p className="text-gray-900">{selectedBooking.notes}</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowDetailModal(false)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BookingManagementPage;

View File

@@ -0,0 +1,402 @@
import React, { useState } from 'react';
import { Search, User, Hotel, CheckCircle, AlertCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
interface GuestInfo {
name: string;
id_number: string;
phone: string;
}
const CheckInPage: React.FC = () => {
const [bookingNumber, setBookingNumber] = useState('');
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [actualRoomNumber, setActualRoomNumber] = useState('');
const [guests, setGuests] = useState<GuestInfo[]>([{ name: '', id_number: '', phone: '' }]);
const [extraPersons, setExtraPersons] = useState(0);
const [children, setChildren] = useState(0);
const [additionalFee, setAdditionalFee] = useState(0);
const handleSearch = async () => {
if (!bookingNumber.trim()) {
toast.error('Please enter booking number');
return;
}
try {
setSearching(true);
const response = await bookingService.checkBookingByNumber(bookingNumber);
setBooking(response.data.booking);
setActualRoomNumber(response.data.booking.room?.room_number || '');
toast.success('Booking found');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Booking not found');
setBooking(null);
} finally {
setSearching(false);
}
};
const handleAddGuest = () => {
setGuests([...guests, { name: '', id_number: '', phone: '' }]);
};
const handleRemoveGuest = (index: number) => {
if (guests.length > 1) {
setGuests(guests.filter((_, i) => i !== index));
}
};
const handleGuestChange = (index: number, field: keyof GuestInfo, value: string) => {
const newGuests = [...guests];
newGuests[index][field] = value;
setGuests(newGuests);
};
const calculateAdditionalFee = () => {
// Logic to calculate additional fees: children and extra person
const extraPersonFee = extraPersons * 200000; // 200k/person
const childrenFee = children * 100000; // 100k/child
const total = extraPersonFee + childrenFee;
setAdditionalFee(total);
return total;
};
const handleCheckIn = async () => {
if (!booking) return;
// Validate
if (!actualRoomNumber.trim()) {
toast.error('Please enter actual room number');
return;
}
const mainGuest = guests[0];
if (!mainGuest.name || !mainGuest.id_number || !mainGuest.phone) {
toast.error('Please fill in all main guest information');
return;
}
try {
setLoading(true);
// Calculate additional fee
calculateAdditionalFee();
await bookingService.updateBooking(booking.id, {
status: 'checked_in',
// Can send additional data about guests, room_number, additional_fee
} as any);
toast.success('Check-in successful');
// Reset form
setBooking(null);
setBookingNumber('');
setActualRoomNumber('');
setGuests([{ name: '', id_number: '', phone: '' }]);
setExtraPersons(0);
setChildren(0);
setAdditionalFee(0);
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred during check-in');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Check-in</h1>
<p className="text-gray-500 mt-1">Customer check-in process</p>
</div>
</div>
{/* Search Booking */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
value={bookingNumber}
onChange={(e) => setBookingNumber(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter booking number"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
onClick={handleSearch}
disabled={searching}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{/* Booking Info */}
{booking && (
<>
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
2. Booking Information
</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Booking Number:</span>
<span className="font-semibold">{booking.booking_number}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Customer:</span>
<span className="font-semibold">{booking.user?.full_name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Email:</span>
<span>{booking.user?.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Phone:</span>
<span>{booking.user?.phone_number || 'N/A'}</span>
</div>
</div>
</div>
<div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Room Type:</span>
<span className="font-semibold">{booking.room?.room_type?.name || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Check-in:</span>
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Check-out:</span>
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Number of Guests:</span>
<span>{booking.guest_count} guest(s)</span>
</div>
</div>
</div>
</div>
{booking.status !== 'confirmed' && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<p className="text-sm text-yellow-800 font-medium">Warning</p>
<p className="text-sm text-yellow-700">
Booking status: <span className="font-semibold">{booking.status}</span>.
Only check-in confirmed bookings.
</p>
</div>
</div>
)}
</div>
{/* Assign Room */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Hotel className="w-5 h-5 text-blue-600" />
3. Assign Actual Room Number
</h2>
<div className="max-w-md">
<label className="block text-sm font-medium text-gray-700 mb-2">
Room Number <span className="text-red-500">*</span>
</label>
<input
type="text"
value={actualRoomNumber}
onChange={(e) => setActualRoomNumber(e.target.value)}
placeholder="e.g: 101, 202, 305"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
Enter the actual room number to assign to the guest
</p>
</div>
</div>
{/* Guest Information */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<User className="w-5 h-5 text-purple-600" />
4. Guest Information
</h2>
<div className="space-y-4">
{guests.map((guest, index) => (
<div key={index} className="p-4 border border-gray-200 rounded-lg">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium">
{index === 0 ? 'Main Guest' : `Guest ${index + 1}`}
{index === 0 && <span className="text-red-500 ml-1">*</span>}
</h3>
{index > 0 && (
<button
onClick={() => handleRemoveGuest(index)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-gray-700 mb-1">
Full Name {index === 0 && <span className="text-red-500">*</span>}
</label>
<input
type="text"
value={guest.name}
onChange={(e) => handleGuestChange(index, 'name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">
ID Number {index === 0 && <span className="text-red-500">*</span>}
</label>
<input
type="text"
value={guest.id_number}
onChange={(e) => handleGuestChange(index, 'id_number', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="001234567890"
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">
Phone Number {index === 0 && <span className="text-red-500">*</span>}
</label>
<input
type="tel"
value={guest.phone}
onChange={(e) => handleGuestChange(index, 'phone', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0912345678"
/>
</div>
</div>
</div>
))}
<button
onClick={handleAddGuest}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
+ Add Guest
</button>
</div>
</div>
{/* Additional Charges */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">5. Additional Fees (if any)</h2>
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Extra Persons
</label>
<input
type="number"
min="0"
value={extraPersons}
onChange={(e) => {
setExtraPersons(parseInt(e.target.value) || 0);
calculateAdditionalFee();
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">50/person</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Number of Children
</label>
<input
type="number"
min="0"
value={children}
onChange={(e) => {
setChildren(parseInt(e.target.value) || 0);
calculateAdditionalFee();
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">25/child</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Additional Fee
</label>
<div className="px-4 py-2 bg-gray-50 border border-gray-300 rounded-lg text-lg font-semibold text-blue-600">
{formatCurrency(calculateAdditionalFee())}
</div>
</div>
</div>
</div>
{/* Summary & Action */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-200">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900">Confirm Check-in</h3>
<p className="text-sm text-gray-600 mt-1">
Guest: <span className="font-medium">{booking.user?.full_name}</span> |
Room: <span className="font-medium">{actualRoomNumber || 'Not assigned'}</span>
{additionalFee > 0 && (
<> | Additional Fee: <span className="font-medium text-red-600">{formatCurrency(additionalFee)}</span></>
)}
</p>
</div>
<button
onClick={handleCheckIn}
disabled={!actualRoomNumber || !guests[0].name || booking.status !== 'confirmed'}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
>
<CheckCircle className="w-5 h-5" />
Confirm Check-in
</button>
</div>
</div>
</>
)}
{/* Empty State */}
{!booking && !searching && (
<div className="bg-gray-50 rounded-lg p-12 text-center">
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No booking selected
</h3>
<p className="text-gray-600">
Please enter booking number above to start check-in process
</p>
</div>
)}
</div>
);
};
export default CheckInPage;

View File

@@ -0,0 +1,448 @@
import React, { useState } from 'react';
import { Search, FileText, DollarSign, CreditCard, Printer, CheckCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
interface ServiceItem {
service_name: string;
quantity: number;
price: number;
total: number;
}
const CheckOutPage: React.FC = () => {
const [bookingNumber, setBookingNumber] = useState('');
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [services, setServices] = useState<ServiceItem[]>([]);
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'bank_transfer' | 'credit_card'>('cash');
const [discount, setDiscount] = useState(0);
const [showInvoice, setShowInvoice] = useState(false);
const handleSearch = async () => {
if (!bookingNumber.trim()) {
toast.error('Please enter booking number');
return;
}
try {
setSearching(true);
const response = await bookingService.checkBookingByNumber(bookingNumber);
const foundBooking = response.data.booking;
if (foundBooking.status !== 'checked_in') {
toast.warning('Only checked-in bookings can be checked out');
}
setBooking(foundBooking);
// Mock services data - in production will fetch from API
setServices([
{ service_name: 'Laundry', quantity: 2, price: 50000, total: 100000 },
{ service_name: 'Minibar', quantity: 1, price: 150000, total: 150000 },
]);
toast.success('Booking found');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Booking not found');
setBooking(null);
} finally {
setSearching(false);
}
};
const calculateRoomFee = () => {
if (!booking) return 0;
return booking.total_price || 0;
};
const calculateServiceFee = () => {
return services.reduce((sum, service) => sum + service.total, 0);
};
const calculateAdditionalFee = () => {
// Additional fees from check-in (children, extra person)
return 0; // In production will get from booking data
};
const calculateDeposit = () => {
// Deposit already paid
return booking?.total_price ? booking.total_price * 0.3 : 0;
};
const calculateSubtotal = () => {
return calculateRoomFee() + calculateServiceFee() + calculateAdditionalFee();
};
const calculateDiscount = () => {
return discount;
};
const calculateTotal = () => {
return calculateSubtotal() - calculateDiscount();
};
const calculateRemaining = () => {
return calculateTotal() - calculateDeposit();
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
};
const handleCheckOut = async () => {
if (!booking) return;
if (calculateRemaining() < 0) {
toast.error('Invalid refund amount');
return;
}
try {
setLoading(true);
// Update booking status
await bookingService.updateBooking(booking.id, {
status: 'checked_out',
} as any);
// Create payment record (if needed)
// await paymentService.createPayment({...});
toast.success('Check-out successful');
setShowInvoice(true);
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred during check-out');
} finally {
setLoading(false);
}
};
const handlePrintInvoice = () => {
window.print();
};
const resetForm = () => {
setBooking(null);
setBookingNumber('');
setServices([]);
setDiscount(0);
setPaymentMethod('cash');
setShowInvoice(false);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Check-out</h1>
<p className="text-gray-500 mt-1">Payment and check-out process</p>
</div>
</div>
{/* Search Booking */}
{!showInvoice && (
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
value={bookingNumber}
onChange={(e) => setBookingNumber(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter booking number or room number"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
onClick={handleSearch}
disabled={searching}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
)}
{/* Invoice */}
{booking && !showInvoice && (
<>
{/* Booking Info */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">2. Booking information</h2>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Booking number:</span>
<span className="font-semibold">{booking.booking_number}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Customer:</span>
<span className="font-semibold">{booking.user?.full_name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Room number:</span>
<span className="font-semibold">{booking.room?.room_number}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Check-in:</span>
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Check-out:</span>
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Nights:</span>
<span>
{booking.check_in_date && booking.check_out_date
? Math.ceil((new Date(booking.check_out_date).getTime() - new Date(booking.check_in_date).getTime()) / (1000 * 60 * 60 * 24))
: 0} night(s)
</span>
</div>
</div>
</div>
</div>
{/* Bill Details */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
3. Invoice details
</h2>
{/* Room Fee */}
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Room fee</h3>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between">
<span>{booking.room?.room_type?.name || 'Room'}</span>
<span className="font-semibold">{formatCurrency(calculateRoomFee())}</span>
</div>
</div>
</div>
{/* Service Fee */}
{services.length > 0 && (
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Services used</h3>
<div className="bg-gray-50 p-4 rounded-lg space-y-2">
{services.map((service, index) => (
<div key={index} className="flex justify-between text-sm">
<span>
{service.service_name} (x{service.quantity})
</span>
<span>{formatCurrency(service.total)}</span>
</div>
))}
<div className="pt-2 border-t border-gray-200 flex justify-between font-medium">
<span>Total services:</span>
<span>{formatCurrency(calculateServiceFee())}</span>
</div>
</div>
</div>
)}
{/* Additional Fee */}
{calculateAdditionalFee() > 0 && (
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Additional fees</h3>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between">
<span>Extra person/children fee</span>
<span className="font-semibold">{formatCurrency(calculateAdditionalFee())}</span>
</div>
</div>
</div>
)}
{/* Discount */}
<div className="mb-4">
<h3 className="font-medium text-gray-700 mb-2">Discount</h3>
<div className="flex gap-4">
<input
type="number"
value={discount}
onChange={(e) => setDiscount(parseFloat(e.target.value) || 0)}
placeholder="Enter discount amount"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Summary */}
<div className="border-t-2 border-gray-300 pt-4 space-y-2">
<div className="flex justify-between text-lg">
<span>Subtotal:</span>
<span className="font-semibold">{formatCurrency(calculateSubtotal())}</span>
</div>
{discount > 0 && (
<div className="flex justify-between text-red-600">
<span>Discount:</span>
<span>-{formatCurrency(discount)}</span>
</div>
)}
<div className="flex justify-between text-xl font-bold text-blue-600">
<span>Total:</span>
<span>{formatCurrency(calculateTotal())}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Deposit paid:</span>
<span>-{formatCurrency(calculateDeposit())}</span>
</div>
<div className="flex justify-between text-2xl font-bold text-green-600 pt-2 border-t border-gray-200">
<span>Remaining payment:</span>
<span>{formatCurrency(calculateRemaining())}</span>
</div>
</div>
</div>
{/* Payment Method */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-green-600" />
4. Payment method
</h2>
<div className="grid grid-cols-3 gap-4">
<button
onClick={() => setPaymentMethod('cash')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'cash'
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<DollarSign className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Cash</div>
</button>
<button
onClick={() => setPaymentMethod('bank_transfer')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'bank_transfer'
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<CreditCard className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Bank transfer</div>
</button>
<button
onClick={() => setPaymentMethod('credit_card')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'credit_card'
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<CreditCard className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Credit card</div>
</button>
</div>
</div>
{/* Action */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-6 rounded-lg border border-green-200">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900">Confirm check-out</h3>
<p className="text-sm text-gray-600 mt-1">
Total payment: <span className="font-bold text-green-600 text-lg">{formatCurrency(calculateRemaining())}</span>
</p>
</div>
<button
onClick={handleCheckOut}
disabled={booking.status !== 'checked_in'}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
>
<CheckCircle className="w-5 h-5" />
Confirm payment & Check-out
</button>
</div>
</div>
</>
)}
{/* Invoice Display */}
{showInvoice && booking && (
<div className="bg-white p-8 rounded-lg shadow-lg">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">PAYMENT INVOICE</h2>
<p className="text-gray-600 mt-1">Check-out successful</p>
</div>
<div className="border-t-2 border-b-2 border-gray-300 py-6 mb-6">
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-sm text-gray-600">Booking number:</p>
<p className="font-semibold">{booking.booking_number}</p>
</div>
<div>
<p className="text-sm text-gray-600">Check-out date:</p>
<p className="font-semibold">{new Date().toLocaleString('en-US')}</p>
</div>
<div>
<p className="text-sm text-gray-600">Customer:</p>
<p className="font-semibold">{booking.user?.full_name}</p>
</div>
<div>
<p className="text-sm text-gray-600">Payment method:</p>
<p className="font-semibold">
{paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'bank_transfer' ? 'Bank transfer' : 'Credit card'}
</p>
</div>
</div>
</div>
<div className="mb-6">
<div className="flex justify-between text-xl font-bold text-green-600 mb-4">
<span>Total payment:</span>
<span>{formatCurrency(calculateRemaining())}</span>
</div>
</div>
<div className="flex gap-4">
<button
onClick={handlePrintInvoice}
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
>
<Printer className="w-5 h-5" />
Print invoice
</button>
<button
onClick={resetForm}
className="flex-1 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
>
Complete
</button>
</div>
</div>
)}
{/* Empty State */}
{!booking && !searching && !showInvoice && (
<div className="bg-gray-50 rounded-lg p-12 text-center">
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No booking selected
</h3>
<p className="text-gray-600">
Please enter booking number to start check-out process
</p>
</div>
)}
</div>
);
};
export default CheckOutPage;

View File

@@ -0,0 +1,290 @@
import React, { useEffect, useState } from 'react';
import {
BarChart3,
Users,
Hotel,
DollarSign,
Calendar,
TrendingUp
} from 'lucide-react';
import { reportService, ReportData } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
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 formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1">Hotel operations overview</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>
</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="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{formatCurrency(stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-green-100 p-3 rounded-full">
<DollarSign className="w-6 h-6 text-green-600" />
</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">+12.5%</span>
<span className="text-gray-500 ml-2">compared to last month</span>
</div>
</div>
{/* Total Bookings */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{stats?.total_bookings || 0}
</p>
</div>
<div className="bg-blue-100 p-3 rounded-full">
<Calendar className="w-6 h-6 text-blue-600" />
</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>
</div>
</div>
{/* Available Rooms */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{stats?.available_rooms || 0}
</p>
</div>
<div className="bg-purple-100 p-3 rounded-full">
<Hotel className="w-6 h-6 text-purple-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<span className="text-gray-500">
{stats?.occupied_rooms || 0} rooms in use
</span>
</div>
</div>
{/* Total Customers */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Customers</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{stats?.total_customers || 0}
</p>
</div>
<div className="bg-orange-100 p-3 rounded-full">
<Users className="w-6 h-6 text-orange-600" />
</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>
</div>
</div>
</div>
{/* 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>
{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',
};
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>
<span className="font-semibold text-gray-900">{count}</span>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
)}
</div>
</div>
{/* 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>
{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">
{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>
</div>
</div>
<span className="font-semibold text-green-600">
{formatCurrency(room.revenue)}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
)}
</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>
{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>
<p className="font-medium text-gray-900">{service.service_name}</p>
<p className="text-sm text-gray-500">{service.usage_count} times used</p>
</div>
<span className="font-semibold text-purple-600">
{formatCurrency(service.total_revenue)}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
)}
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,195 @@
import React, { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
import { paymentService, Payment } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const PaymentManagementPage: React.FC = () => {
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
search: '',
method: '',
from: '',
to: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPayments();
}, [filters, currentPage]);
const fetchPayments = async () => {
try {
setLoading(true);
const response = await paymentService.getPayments({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setPayments(response.data.payments);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load payments list');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
const getMethodBadge = (method: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
cash: { bg: 'bg-green-100', text: 'text-green-800', label: 'Cash' },
bank_transfer: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Bank transfer' },
credit_card: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Credit card' },
};
const badge = badges[method] || badges.cash;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Payment Management</h1>
<p className="text-gray-500 mt-1">Track payment transactions</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-4 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" />
<input
type="text"
placeholder="Search..."
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"
/>
</div>
<select
value={filters.method}
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="bank_transfer">Bank transfer</option>
<option value="credit_card">Credit card</option>
</select>
<input
type="date"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="From date"
/>
<input
type="date"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="To date"
/>
</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">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Transaction ID
</th>
<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">
Method
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Payment Date
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{payments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{payment.transaction_id || `PAY-${payment.id}`}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-blue-600">{payment.booking?.booking_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{payment.booking?.user?.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getMethodBadge(payment.payment_method)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-green-600">
{formatCurrency(payment.amount)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(payment.payment_date || payment.createdAt).toLocaleDateString('en-US')}
</div>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{/* Summary Card */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg shadow-lg p-6 text-white">
<h3 className="text-lg font-semibold mb-2">Total Revenue</h3>
<p className="text-3xl font-bold">
{formatCurrency(payments.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-sm mt-2 opacity-90">Total {payments.length} transactions</p>
</div>
</div>
);
};
export default PaymentManagementPage;

View File

@@ -0,0 +1,488 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
import { promotionService, Promotion } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const PromotionManagementPage: React.FC = () => {
const [promotions, setPromotions] = useState<Promotion[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
type: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
code: '',
name: '',
description: '',
discount_type: 'percentage' as 'percentage' | 'fixed',
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active' as 'active' | 'inactive' | 'expired',
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPromotions();
}, [filters, currentPage]);
const fetchPromotions = async () => {
try {
setLoading(true);
const response = await promotionService.getPromotions({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setPromotions(response.data.promotions);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load promotions list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingPromotion) {
await promotionService.updatePromotion(editingPromotion.id, formData);
toast.success('Promotion updated successfully');
} else {
await promotionService.createPromotion(formData);
toast.success('Promotion added successfully');
}
setShowModal(false);
resetForm();
fetchPromotions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (promotion: Promotion) => {
setEditingPromotion(promotion);
setFormData({
code: promotion.code,
name: promotion.name,
description: promotion.description || '',
discount_type: promotion.discount_type,
discount_value: promotion.discount_value,
min_booking_amount: promotion.min_booking_amount || 0,
max_discount_amount: promotion.max_discount_amount || 0,
start_date: promotion.start_date?.split('T')[0] || '',
end_date: promotion.end_date?.split('T')[0] || '',
usage_limit: promotion.usage_limit || 0,
status: promotion.status,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this promotion?')) return;
try {
await promotionService.deletePromotion(id);
toast.success('Promotion deleted successfully');
fetchPromotions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete promotion');
}
};
const resetForm = () => {
setEditingPromotion(null);
setFormData({
code: '',
name: '',
description: '',
discount_type: 'percentage',
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
active: { bg: 'bg-green-100', text: 'text-green-800', label: 'Active' },
inactive: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Inactive' },
};
const badge = badges[status] || badges.active;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Promotion Management</h1>
<p className="text-gray-500 mt-1">Manage discount codes and promotion programs</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Add Promotion
</button>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="flex-1">
<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 code or 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"
/>
</div>
</div>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All Types</option>
<option value="percentage">Percentage</option>
<option value="fixed">Fixed Amount</option>
</select>
<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"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<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">
Code
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Program Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Value
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Period
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Used
</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>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{promotions.map((promotion) => (
<tr key={promotion.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-blue-600" />
<span className="text-sm font-mono font-bold text-blue-600">{promotion.code}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">{promotion.name}</div>
<div className="text-xs text-gray-500">{promotion.description}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{promotion.discount_type === 'percentage'
? `${promotion.discount_value}%`
: formatCurrency(promotion.discount_value)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-xs text-gray-500">
{promotion.start_date ? new Date(promotion.start_date).toLocaleDateString('en-US') : 'N/A'}
{' → '}
{promotion.end_date ? new Date(promotion.end_date).toLocaleDateString('en-US') : 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{promotion.used_count || 0} / {promotion.usage_limit || '∞'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(promotion.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(promotion)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(promotion.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
{/* Modal */}
{showModal && (
<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">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Code <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="e.g: SUMMER2024"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Program Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="e.g: Summer Sale"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Detailed description of the program..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Discount Type <span className="text-red-500">*</span>
</label>
<select
value={formData.discount_type}
onChange={(e) => setFormData({ ...formData, discount_type: e.target.value as 'percentage' | 'fixed' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="percentage">Percentage (%)</option>
<option value="fixed">Fixed Amount (EUR)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Discount Value <span className="text-red-500">*</span>
</label>
<input
type="number"
value={formData.discount_value}
onChange={(e) => setFormData({ ...formData, discount_value: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
max={formData.discount_type === 'percentage' ? 100 : undefined}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Minimum Order Value (EUR)
</label>
<input
type="number"
value={formData.min_booking_amount}
onChange={(e) => setFormData({ ...formData, min_booking_amount: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Maximum Discount (EUR)
</label>
<input
type="number"
value={formData.max_discount_amount}
onChange={(e) => setFormData({ ...formData, max_discount_amount: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Usage Limit (0 = unlimited)
</label>
<input
type="number"
value={formData.usage_limit}
onChange={(e) => setFormData({ ...formData, usage_limit: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'active' | 'inactive' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingPromotion ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default PromotionManagementPage;

View File

@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { reviewService, Review } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const ReviewManagementPage: React.FC = () => {
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchReviews();
}, [filters, currentPage]);
const fetchReviews = async () => {
try {
setLoading(true);
const response = await reviewService.getReviews({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setReviews(response.data.reviews);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load reviews list');
} finally {
setLoading(false);
}
};
const handleApprove = async (id: number) => {
try {
await reviewService.approveReview(id);
toast.success('Review approved successfully');
fetchReviews();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to approve review');
}
};
const handleReject = async (id: number) => {
if (!window.confirm('Are you sure you want to reject this review?')) return;
try {
await reviewService.rejectReview(id);
toast.success('Review rejected successfully');
fetchReviews();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to reject review');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending' },
approved: { bg: 'bg-green-100', text: 'text-green-800', label: 'Approved' },
rejected: { bg: 'bg-red-100', text: 'text-red-800', label: 'Rejected' },
};
const badge = badges[status] || badges.pending;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
const renderStars = (rating: number) => {
return (
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<span key={star} className={star <= rating ? 'text-yellow-400' : 'text-gray-300'}>
</span>
))}
</div>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Review Management</h1>
<p className="text-gray-500 mt-1">Approve and manage customer reviews</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<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"
>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</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">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
User
</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">
Rating
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Comment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created Date
</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>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reviews.map((review) => (
<tr key={review.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{review.user?.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
Room {review.room?.room_number} - {review.room?.room_type?.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{renderStars(review.rating)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">{review.comment}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(review.created_at).toLocaleDateString('en-US')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(review.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{review.status === 'pending' && (
<>
<button
onClick={() => handleApprove(review.id)}
className="text-green-600 hover:text-green-900 mr-3"
title="Approve"
>
</button>
<button
onClick={() => handleReject(review.id)}
className="text-red-600 hover:text-red-900"
title="Reject"
>
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
</div>
);
};
export default ReviewManagementPage;

View File

@@ -0,0 +1,512 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon } from 'lucide-react';
import { roomService, Room } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import apiClient from '../../services/api/apiClient';
const RoomManagementPage: React.FC = () => {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
type: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
room_number: '',
floor: 1,
room_type_id: 1,
status: 'available' as 'available' | 'occupied' | 'maintenance',
featured: false,
});
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchRooms();
}, [filters, currentPage]);
const fetchRooms = async () => {
try {
setLoading(true);
const response = await roomService.getRooms({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setRooms(response.data.rooms);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load rooms list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingRoom) {
// Update room
await roomService.updateRoom(editingRoom.id, formData);
toast.success('Room updated successfully');
} else {
// Create room
await roomService.createRoom(formData);
toast.success('Room added successfully');
}
setShowModal(false);
resetForm();
fetchRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (room: Room) => {
setEditingRoom(room);
setFormData({
room_number: room.room_number,
floor: room.floor,
room_type_id: room.room_type_id,
status: room.status,
featured: room.featured,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this room?')) return;
try {
await roomService.deleteRoom(id);
toast.success('Room deleted successfully');
fetchRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete room');
}
};
const resetForm = () => {
setEditingRoom(null);
setFormData({
room_number: '',
floor: 1,
room_type_id: 1,
status: 'available',
featured: false,
});
setSelectedFiles([]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
setSelectedFiles(files);
}
};
const handleUploadImages = async () => {
if (!editingRoom || selectedFiles.length === 0) return;
try {
setUploadingImages(true);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
await apiClient.post(`/rooms/${editingRoom.id}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
toast.success('Images uploaded successfully');
setSelectedFiles([]);
fetchRooms();
// Refresh editing room data
const response = await roomService.getRoomById(editingRoom.id);
setEditingRoom(response.data.room);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to upload images');
} finally {
setUploadingImages(false);
}
};
const handleDeleteImage = async (imageUrl: string) => {
if (!editingRoom) return;
if (!window.confirm('Are you sure you want to delete this image?')) return;
try {
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
data: { imageUrl },
});
toast.success('Image deleted successfully');
fetchRooms();
// Refresh editing room data
const response = await roomService.getRoomById(editingRoom.id);
setEditingRoom(response.data.room);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete image');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
available: { bg: 'bg-green-100', text: 'text-green-800', label: 'Available' },
occupied: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Occupied' },
maintenance: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Maintenance' },
};
const badge = badges[status] || badges.available;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Room Management</h1>
<p className="text-gray-500 mt-1">Manage hotel room information</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
Add Room
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-3 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" />
<input
type="text"
placeholder="Search rooms..."
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"
/>
</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"
>
<option value="">All Statuses</option>
<option value="available">Available</option>
<option value="occupied">Occupied</option>
<option value="maintenance">Maintenance</option>
</select>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All Room Types</option>
<option value="1">Standard</option>
<option value="2">Deluxe</option>
<option value="3">Suite</option>
</select>
</div>
</div>
{/* Table */}
<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">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Room Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Room Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Floor
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price
</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">
Featured
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{rooms.map((room) => (
<tr key={room.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{room.room_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{room.room_type?.name || 'N/A'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">Floor {room.floor}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(room.room_type?.base_price || 0)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(room.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{room.featured ? (
<span className="text-yellow-500"></span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(room)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(room.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
{/* 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 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">
{editingRoom ? 'Update Room' : 'Add New Room'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Room Number
</label>
<input
type="text"
value={formData.room_number}
onChange={(e) => setFormData({ ...formData, room_number: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Floor
</label>
<input
type="number"
value={formData.floor}
onChange={(e) => setFormData({ ...formData, floor: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
min="1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Room Type
</label>
<select
value={formData.room_type_id}
onChange={(e) => setFormData({ ...formData, room_type_id: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="1">Standard</option>
<option value="2">Deluxe</option>
<option value="3">Suite</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="available">Available</option>
<option value="occupied">Occupied</option>
<option value="maintenance">Maintenance</option>
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="featured"
checked={formData.featured}
onChange={(e) => setFormData({ ...formData, featured: e.target.checked })}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="featured" className="ml-2 text-sm text-gray-700">
Featured Room
</label>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingRoom ? 'Update' : 'Add'}
</button>
</div>
</form>
{/* Image Upload Section - Only for editing */}
{editingRoom && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
Room Images
</h3>
{/* Current Images */}
{editingRoom.room_type?.images && editingRoom.room_type.images.length > 0 && (
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">Current Images:</p>
<div className="grid grid-cols-3 gap-3">
{editingRoom.room_type.images.map((img, index) => (
<div key={index} className="relative group">
<img
src={`http://localhost:8000${img}`}
alt={`Room ${index + 1}`}
className="w-full h-24 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => handleDeleteImage(img)}
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* Upload New Images */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add New Images (max 5 images):
</label>
<div className="flex gap-3">
<input
type="file"
accept="image/*"
multiple
onChange={handleFileSelect}
className="flex-1 text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<button
type="button"
onClick={handleUploadImages}
disabled={selectedFiles.length === 0 || uploadingImages}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{uploadingImages ? 'Uploading...' : 'Upload'}
</button>
</div>
{selectedFiles.length > 0 && (
<p className="text-sm text-gray-600 mt-2">
{selectedFiles.length} file(s) selected
</p>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default RoomManagementPage;

View File

@@ -0,0 +1,336 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import { serviceService, Service } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
const ServiceManagementPage: React.FC = () => {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
name: '',
description: '',
price: 0,
unit: 'time',
status: 'active' as 'active' | 'inactive',
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchServices();
}, [filters, currentPage]);
const fetchServices = async () => {
try {
setLoading(true);
const response = await serviceService.getServices({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setServices(response.data.services);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load services list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingService) {
await serviceService.updateService(editingService.id, formData);
toast.success('Service updated successfully');
} else {
await serviceService.createService(formData);
toast.success('Service added successfully');
}
setShowModal(false);
resetForm();
fetchServices();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (service: Service) => {
setEditingService(service);
setFormData({
name: service.name,
description: service.description || '',
price: service.price,
unit: service.unit || 'time',
status: service.status,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this service?')) return;
try {
await serviceService.deleteService(id);
toast.success('Service deleted successfully');
fetchServices();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete service');
}
};
const resetForm = () => {
setEditingService(null);
setFormData({
name: '',
description: '',
price: 0,
unit: 'time',
status: 'active',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Service Management</h1>
<p className="text-gray-500 mt-1">Manage hotel services</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-5 h-5" />
Add Service
</button>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<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" />
<input
type="text"
placeholder="Search services..."
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"
/>
</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"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</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">
<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">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Unit
</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>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{services.map((service) => (
<tr key={service.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{service.name}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">{service.description}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">{formatCurrency(service.price)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{service.unit}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
service.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{service.status === 'active' ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(service)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{showModal && (
<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-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingService ? 'Update Service' : 'Add New Service'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Service Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Price
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Unit
</label>
<input
type="text"
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="e.g: time, hour, day..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingService ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ServiceManagementPage;

View File

@@ -0,0 +1,412 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import { userService, User } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import useAuthStore from '../../store/useAuthStore';
const UserManagementPage: React.FC = () => {
const { userInfo } = useAuthStore();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [filters, setFilters] = useState({
search: '',
role: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const [formData, setFormData] = useState({
full_name: '',
email: '',
phone_number: '',
password: '',
role: 'customer',
status: 'active',
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchUsers();
}, [filters, currentPage]);
const fetchUsers = async () => {
try {
setLoading(true);
console.log('Fetching users with filters:', filters, 'page:', currentPage);
const response = await userService.getUsers({
...filters,
page: currentPage,
limit: itemsPerPage,
});
console.log('Users response:', response);
setUsers(response.data.users);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
console.error('Error fetching users:', error);
toast.error(error.response?.data?.message || 'Unable to load users list');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingUser) {
// When updating, only send password if changed
const updateData: any = {
full_name: formData.full_name,
email: formData.email,
phone_number: formData.phone_number,
role: formData.role,
status: formData.status,
};
// Only add password if user entered a new one
if (formData.password && formData.password.trim() !== '') {
updateData.password = formData.password;
}
console.log('Updating user:', editingUser.id, 'with data:', updateData);
const response = await userService.updateUser(editingUser.id, updateData);
console.log('Update response:', response);
toast.success('User updated successfully');
} else {
// When creating new, need complete information
if (!formData.password || formData.password.trim() === '') {
toast.error('Please enter password');
return;
}
console.log('Creating user with data:', formData);
const response = await userService.createUser(formData);
console.log('Create response:', response);
toast.success('User added successfully');
}
// Close modal and reset form first
setShowModal(false);
resetForm();
// Reload users list after a bit to ensure DB is updated
setTimeout(() => {
fetchUsers();
}, 300);
} catch (error: any) {
console.error('Error submitting user:', error);
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const handleEdit = (user: User) => {
setEditingUser(user);
setFormData({
full_name: user.full_name,
email: user.email,
phone_number: user.phone_number || '',
password: '',
role: user.role,
status: user.status || 'active',
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
// Prevent self-deletion
if (userInfo?.id === id) {
toast.error('You cannot delete your own account');
return;
}
if (!window.confirm('Are you sure you want to delete this user?')) return;
try {
console.log('Deleting user:', id);
await userService.deleteUser(id);
toast.success('User deleted successfully');
fetchUsers();
} catch (error: any) {
console.error('Error deleting user:', error);
toast.error(error.response?.data?.message || 'Unable to delete user');
}
};
const resetForm = () => {
setEditingUser(null);
setFormData({
full_name: '',
email: '',
phone_number: '',
password: '',
role: 'customer',
status: 'active',
});
};
const getRoleBadge = (role: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
admin: { bg: 'bg-red-100', text: 'text-red-800', label: 'Admin' },
staff: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Staff' },
customer: { bg: 'bg-green-100', text: 'text-green-800', label: 'Customer' },
};
const badge = badges[role] || badges.customer;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-500 mt-1">Manage accounts and permissions</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-5 h-5" />
Add User
</button>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-3 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" />
<input
type="text"
placeholder="Search by name, email..."
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"
/>
</div>
<select
value={filters.role}
onChange={(e) => setFilters({ ...filters, role: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All roles</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
<option value="customer">Customer</option>
</select>
<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"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</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">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Phone
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{user.full_name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.phone_number || 'N/A'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getRoleBadge(user.role)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{user.created_at ? new Date(user.created_at).toLocaleDateString('en-US') : 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(user)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900"
disabled={userInfo?.id === user.id}
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{showModal && (
<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-md max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingUser ? 'Update User' : 'Add New User'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone Number
</label>
<input
type="tel"
value={formData.phone_number}
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password {editingUser && '(leave blank if not changing)'}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required={!editingUser}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="customer">Customer</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingUser ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default UserManagementPage;

View File

@@ -0,0 +1,10 @@
export { default as DashboardPage } from './DashboardPage';
export { default as RoomManagementPage } from './RoomManagementPage';
export { default as UserManagementPage } from './UserManagementPage';
export { default as BookingManagementPage } from './BookingManagementPage';
export { default as PaymentManagementPage } from './PaymentManagementPage';
export { default as ServiceManagementPage } from './ServiceManagementPage';
export { default as ReviewManagementPage } from './ReviewManagementPage';
export { default as PromotionManagementPage } from './PromotionManagementPage';
export { default as CheckInPage } from './CheckInPage';
export { default as CheckOutPage } from './CheckOutPage';

View File

@@ -0,0 +1,328 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Link } from 'react-router-dom';
import {
Mail,
ArrowLeft,
Send,
Loader2,
CheckCircle,
Hotel,
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
forgotPasswordSchema,
ForgotPasswordFormData,
} from '../../utils/validationSchemas';
const ForgotPasswordPage: React.FC = () => {
const { forgotPassword, isLoading, error, clearError } =
useAuthStore();
const [isSuccess, setIsSuccess] = useState(false);
const [submittedEmail, setSubmittedEmail] = useState('');
// React Hook Form setup
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordFormData>({
resolver: yupResolver(forgotPasswordSchema),
defaultValues: {
email: '',
},
});
// Handle form submission
const onSubmit = async (data: ForgotPasswordFormData) => {
try {
clearError();
setSubmittedEmail(data.email);
await forgotPassword({ email: data.email });
// Show success state
setIsSuccess(true);
} catch (error) {
// Error has been handled in store
console.error('Forgot password error:', error);
}
};
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"
>
<div className="max-w-md w-full space-y-8">
{/* 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>
</div>
<h2 className="text-3xl font-bold text-gray-900">
Forgot Password?
</h2>
<p className="mt-2 text-sm text-gray-600">
Enter your email to receive a password reset link
</p>
</div>
{/* Form Container */}
<div className="bg-white rounded-lg shadow-xl p-8">
{isSuccess ? (
// Success State
<div className="text-center space-y-6">
<div className="flex justify-center">
<div
className="w-16 h-16 bg-green-100
rounded-full flex items-center
justify-center"
>
<CheckCircle
className="w-10 h-10 text-green-600"
/>
</div>
</div>
<div className="space-y-2">
<h3
className="text-xl font-semibold
text-gray-900"
>
Email Sent!
</h3>
<p className="text-sm text-gray-600">
We have sent a password reset link to
</p>
<p className="text-sm font-medium text-blue-600">
{submittedEmail}
</p>
</div>
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-4 text-left"
>
<p className="text-sm text-gray-700">
<strong>Note:</strong>
</p>
<ul
className="mt-2 space-y-1 text-sm
text-gray-600 list-disc list-inside"
>
<li>Link is valid for 1 hour</li>
<li>Check your Spam/Junk folder</li>
<li>
If you don't receive the email, please try again
</li>
</ul>
</div>
<div className="space-y-3">
<button
onClick={() => {
setIsSuccess(false);
clearError();
}}
className="w-full flex items-center
justify-center py-3 px-4 border
border-gray-300 rounded-lg
text-sm font-medium text-gray-700
bg-white hover:bg-gray-50
focus:outline-none focus:ring-2
focus:ring-offset-2
focus:ring-blue-500
transition-colors"
>
<Mail className="-ml-1 mr-2 h-5 w-5" />
Resend Email
</button>
<Link
to="/login"
className="w-full flex items-center
justify-center py-3 px-4 border
border-transparent rounded-lg
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
transition-colors"
>
<ArrowLeft
className="-ml-1 mr-2 h-5 w-5"
/>
Back to Login
</Link>
</div>
</div>
) : (
// Form State
<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"
>
{error}
</div>
)}
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Email
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Mail
className="h-5 w-5 text-gray-400"
/>
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
autoFocus
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'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Submit Button */}
<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"
>
{isLoading ? (
<>
<Loader2
className="animate-spin -ml-1
mr-2 h-5 w-5"
/>
Processing...
</>
) : (
<>
<Send className="-ml-1 mr-2 h-5 w-5" />
Send Reset Link
</>
)}
</button>
{/* Back to Login Link */}
<div className="text-center">
<Link
to="/login"
className="inline-flex items-center
text-sm font-medium text-blue-600
hover:text-blue-500 transition-colors"
>
<ArrowLeft
className="mr-1 h-4 w-4"
/>
Back to Login
</Link>
</div>
</form>
)}
</div>
{/* Footer Info */}
{!isSuccess && (
<div className="text-center text-sm text-gray-500">
<p>
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-blue-600
hover:underline"
>
Register now
</Link>
</p>
</div>
)}
{/* Help Section */}
<div
className="bg-white rounded-lg shadow-sm
border border-gray-200 p-4"
>
<h3
className="text-sm font-semibold text-gray-900
mb-2"
>
Need Help?
</h3>
<p className="text-xs text-gray-600">
If you're having trouble resetting your password,
please contact our support team via email{' '}
<a
href="mailto:support@hotel.com"
className="text-blue-600 hover:underline"
>
support@hotel.com
</a>{' '}
or hotline{' '}
<a
href="tel:1900-xxxx"
className="text-blue-600 hover:underline"
>
1900-xxxx
</a>
</p>
</div>
</div>
</div>
);
};
export default ForgotPasswordPage;

View File

@@ -0,0 +1,287 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import {
Eye,
EyeOff,
LogIn,
Loader2,
Mail,
Lock,
Hotel
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
loginSchema,
LoginFormData
} from '../../utils/validationSchemas';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { login, isLoading, error, clearError } =
useAuthStore();
const [showPassword, setShowPassword] = useState(false);
// React Hook Form setup
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: yupResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
// Handle form submission
const onSubmit = async (data: LoginFormData) => {
try {
clearError();
await login({
email: data.email,
password: data.password,
rememberMe: data.rememberMe,
});
// Redirect to previous page or dashboard
const from = location.state?.from?.pathname ||
'/dashboard';
navigate(from, { replace: true });
} catch (error) {
// Error has been handled in store
console.error('Login error:', error);
}
};
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"
>
<div className="max-w-md w-full space-y-8">
{/* 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>
</div>
<h2 className="text-3xl font-bold text-gray-900">
Login
</h2>
<p className="mt-2 text-sm text-gray-600">
Welcome back to Hotel Booking
</p>
</div>
{/* Login Form */}
<div className="bg-white rounded-lg shadow-xl p-8">
<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"
>
{error}
</div>
)}
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Email
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0
pl-3 flex items-center pointer-events-none"
>
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
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'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0
pl-3 flex items-center pointer-events-none"
>
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
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'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5
text-gray-400 hover:text-gray-600"
/>
) : (
<Eye className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
{...register('rememberMe')}
id="rememberMe"
type="checkbox"
className="h-4 w-4 text-blue-600
focus:ring-blue-500 border-gray-300
rounded cursor-pointer"
/>
<label
htmlFor="rememberMe"
className="ml-2 block text-sm
text-gray-700 cursor-pointer"
>
Remember me
</label>
</div>
<Link
to="/forgot-password"
className="text-sm font-medium
text-blue-600 hover:text-blue-500
transition-colors"
>
Forgot password?
</Link>
</div>
{/* Submit Button */}
<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"
>
{isLoading ? (
<>
<Loader2 className="animate-spin -ml-1
mr-2 h-5 w-5"
/>
Processing...
</>
) : (
<>
<LogIn className="-ml-1 mr-2 h-5 w-5" />
Login
</>
)}
</button>
</form>
{/* Register Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-blue-600
hover:text-blue-500 transition-colors"
>
Register now
</Link>
</p>
</div>
</div>
{/* Footer Info */}
<div className="text-center text-sm text-gray-500">
<p>
By logging in, you agree to our{' '}
<Link
to="/terms"
className="text-blue-600 hover:underline"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="/privacy"
className="text-blue-600 hover:underline"
>
Privacy Policy
</Link>
</p>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,517 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Link, useNavigate } from 'react-router-dom';
import {
Eye,
EyeOff,
UserPlus,
Loader2,
Mail,
Lock,
User,
Phone,
Hotel,
CheckCircle2,
XCircle,
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
registerSchema,
RegisterFormData,
} from '../../utils/validationSchemas';
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const { register: registerUser, isLoading, error, clearError } =
useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState(false);
// React Hook Form setup
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: yupResolver(registerSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
phone: '',
},
});
// Watch password to display password strength
const password = watch('password');
// Password strength checker
const getPasswordStrength = (pwd: string) => {
if (!pwd) return { strength: 0, label: '', color: '' };
let strength = 0;
if (pwd.length >= 8) strength++;
if (/[a-z]/.test(pwd)) strength++;
if (/[A-Z]/.test(pwd)) strength++;
if (/\d/.test(pwd)) strength++;
if (/[@$!%*?&]/.test(pwd)) strength++;
const labels = [
{ label: 'Very Weak', color: 'bg-red-500' },
{ label: 'Weak', color: 'bg-orange-500' },
{ label: 'Medium', color: 'bg-yellow-500' },
{ label: 'Strong', color: 'bg-blue-500' },
{ label: 'Very Strong', color: 'bg-green-500' },
];
return { strength, ...labels[strength] };
};
const passwordStrength = getPasswordStrength(password || '');
// Handle form submission
const onSubmit = async (data: RegisterFormData) => {
try {
clearError();
await registerUser({
name: data.name,
email: data.email,
password: data.password,
phone: data.phone,
});
// Redirect to login page
navigate('/login', { replace: true });
} catch (error) {
// Error has been handled in store
console.error('Register error:', error);
}
};
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"
>
<div className="max-w-md w-full space-y-8">
{/* 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>
</div>
<h2 className="text-3xl font-bold text-gray-900">
Create Account
</h2>
<p className="mt-2 text-sm text-gray-600">
Create a new account to book hotel rooms
</p>
</div>
{/* Register Form */}
<div className="bg-white rounded-lg shadow-xl p-8">
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-5"
>
{/* Error Message */}
{error && (
<div
className="bg-red-50 border border-red-200
text-red-700 px-4 py-3 rounded-lg
text-sm"
>
{error}
</div>
)}
{/* Name Field */}
<div>
<label
htmlFor="name"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Full Name
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<User className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('name')}
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'
}`}
placeholder="John Doe"
/>
</div>
{errors.name && (
<p className="mt-1 text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Email
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
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'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Phone Field */}
<div>
<label
htmlFor="phone"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Phone Number (Optional)
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Phone className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('phone')}
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'
}`}
placeholder="0123456789"
/>
</div>
{errors.phone && (
<p className="mt-1 text-sm text-red-600">
{errors.phone.message}
</p>
)}
</div>
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Password
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
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'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
{/* Password Strength Indicator */}
{password && password.length > 0 && (
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200
rounded-full overflow-hidden"
>
<div
className={`h-full transition-all
duration-300 ${passwordStrength.color}`}
style={{
width: `${
(passwordStrength.strength / 5) * 100
}%`,
}}
/>
</div>
<span className="text-xs font-medium
text-gray-600"
>
{passwordStrength.label}
</span>
</div>
{/* Password Requirements */}
<div className="mt-2 space-y-1">
<PasswordRequirement
met={password.length >= 8}
text="At least 8 characters"
/>
<PasswordRequirement
met={/[a-z]/.test(password)}
text="Lowercase letter (a-z)"
/>
<PasswordRequirement
met={/[A-Z]/.test(password)}
text="Uppercase letter (A-Z)"
/>
<PasswordRequirement
met={/\d/.test(password)}
text="Number (0-9)"
/>
<PasswordRequirement
met={/[@$!%*?&]/.test(password)}
text="Special character (@$!%*?&)"
/>
</div>
</div>
)}
</div>
{/* Confirm Password Field */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Confirm Password
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('confirmPassword')}
id="confirmPassword"
type={
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'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showConfirmPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* Submit Button */}
<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"
>
{isLoading ? (
<>
<Loader2 className="animate-spin -ml-1
mr-2 h-5 w-5"
/>
Processing...
</>
) : (
<>
<UserPlus className="-ml-1 mr-2 h-5 w-5" />
Register
</>
)}
</button>
</form>
{/* Login Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-purple-600
hover:text-purple-500 transition-colors"
>
Login now
</Link>
</p>
</div>
</div>
{/* Footer Info */}
<div className="text-center text-sm text-gray-500">
<p>
By registering, you agree to our{' '}
<Link
to="/terms"
className="text-purple-600 hover:underline"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="/privacy"
className="text-purple-600 hover:underline"
>
Privacy Policy
</Link>
</p>
</div>
</div>
</div>
);
};
// Helper component for password requirements
const PasswordRequirement: React.FC<{
met: boolean;
text: string;
}> = ({ met, text }) => (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-gray-300" />
)}
<span className={met ? 'text-green-600' : 'text-gray-500'}>
{text}
</span>
</div>
);
export default RegisterPage;

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { useNavigate, useParams, Link } from 'react-router-dom';
import {
Eye,
EyeOff,
Lock,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
KeyRound,
Hotel,
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import {
resetPasswordSchema,
ResetPasswordFormData,
} from '../../utils/validationSchemas';
const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { token } = useParams<{ token: string }>();
const { resetPassword, isLoading, error, clearError } =
useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// React Hook Form setup
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<ResetPasswordFormData>({
resolver: yupResolver(resetPasswordSchema),
defaultValues: {
password: '',
confirmPassword: '',
},
});
// Watch password to display password strength
const password = watch('password');
// Check if token exists
useEffect(() => {
if (!token) {
navigate('/forgot-password', { replace: true });
}
}, [token, navigate]);
// Password strength checker
const getPasswordStrength = (pwd: string) => {
if (!pwd) return { strength: 0, label: '', color: '' };
let strength = 0;
if (pwd.length >= 8) strength++;
if (/[a-z]/.test(pwd)) strength++;
if (/[A-Z]/.test(pwd)) strength++;
if (/\d/.test(pwd)) strength++;
if (/[@$!%*?&]/.test(pwd)) strength++;
const labels = [
{ label: 'Very Weak', color: 'bg-red-500' },
{ label: 'Weak', color: 'bg-orange-500' },
{ label: 'Medium', color: 'bg-yellow-500' },
{ label: 'Strong', color: 'bg-blue-500' },
{ label: 'Very Strong', color: 'bg-green-500' },
];
return { strength, ...labels[strength] };
};
const passwordStrength = getPasswordStrength(password || '');
// Handle form submission
const onSubmit = async (data: ResetPasswordFormData) => {
if (!token) {
return;
}
try {
clearError();
await resetPassword({
token,
password: data.password,
confirmPassword: data.confirmPassword,
});
// Show success state
setIsSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
navigate('/login', { replace: true });
}, 3000);
} catch (error) {
// Error has been handled in store
console.error('Reset password error:', error);
}
};
// Invalid token error check
const isTokenError =
error?.includes('token') || error?.includes('expired');
// New password reuse error check
const isReuseError =
error?.toLowerCase().includes('must be different') ||
error?.toLowerCase().includes('different from old');
return (
<div
className="min-h-screen bg-gradient-to-br
from-indigo-50 to-purple-100 flex items-center
justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div className="max-w-md w-full space-y-8">
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-indigo-600 rounded-full">
<Hotel className="w-12 h-12 text-white" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900">
{isSuccess ? 'Complete!' : 'Reset Password'}
</h2>
<p className="mt-2 text-sm text-gray-600">
{isSuccess
? 'Password has been reset successfully'
: 'Enter a new password for your account'}
</p>
</div>
{/* Form Container */}
<div className="bg-white rounded-lg shadow-xl p-8">
{isSuccess ? (
// Success State
<div className="text-center space-y-6">
<div className="flex justify-center">
<div
className="w-16 h-16 bg-green-100
rounded-full flex items-center
justify-center"
>
<CheckCircle2
className="w-10 h-10 text-green-600"
/>
</div>
</div>
<div className="space-y-2">
<h3
className="text-xl font-semibold
text-gray-900"
>
Password reset successful!
</h3>
<p className="text-sm text-gray-600">
Your password has been updated.
</p>
<p className="text-sm text-gray-600">
You can now login with your new password.
</p>
</div>
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-4"
>
<p className="text-sm text-gray-700">
Redirecting to login page...
</p>
<div className="mt-2 flex justify-center">
<Loader2
className="animate-spin h-5 w-5
text-blue-600"
/>
</div>
</div>
<Link
to="/login"
className="inline-flex items-center
justify-center w-full py-3 px-4
border border-transparent rounded-lg
text-sm font-medium text-white
bg-indigo-600 hover:bg-indigo-700
focus:outline-none focus:ring-2
focus:ring-offset-2
focus:ring-indigo-500
transition-colors"
>
<KeyRound className="-ml-1 mr-2 h-5 w-5" />
Login Now
</Link>
</div>
) : (
// Form State
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-5"
>
{/* Error Message */}
{error && (
<div
className={`border px-4 py-3 rounded-lg
text-sm flex items-start gap-2
${
isTokenError
? 'bg-yellow-50 border-yellow-200 ' +
'text-yellow-800'
: 'bg-red-50 border-red-200 ' +
'text-red-700'
}`}
>
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium">
{isReuseError
? 'New password must be different from old password'
: error}
</p>
{isTokenError && (
<Link
to="/forgot-password"
className="mt-2 inline-block text-sm
font-medium underline
hover:text-yellow-900"
>
Request new link
</Link>
)}
</div>
</div>
)}
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium
text-gray-700 mb-2"
>
New Password
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock
className="h-5 w-5 text-gray-400"
/>
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
autoFocus
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-indigo-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() =>
setShowPassword(!showPassword)
}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
{/* Password Strength Indicator */}
{password && password.length > 0 && (
<div className="mt-2">
<div className="flex items-center gap-2">
<div
className="flex-1 h-2 bg-gray-200
rounded-full overflow-hidden"
>
<div
className={`h-full transition-all
duration-300
${passwordStrength.color}`}
style={{
width: `${
(passwordStrength.strength / 5) *
100
}%`,
}}
/>
</div>
<span
className="text-xs font-medium
text-gray-600"
>
{passwordStrength.label}
</span>
</div>
{/* Password Requirements */}
<div className="mt-2 space-y-1">
<PasswordRequirement
met={password.length >= 8}
text="At least 8 characters"
/>
<PasswordRequirement
met={/[a-z]/.test(password)}
text="Lowercase letter (a-z)"
/>
<PasswordRequirement
met={/[A-Z]/.test(password)}
text="Uppercase letter (A-Z)"
/>
<PasswordRequirement
met={/\d/.test(password)}
text="Number (0-9)"
/>
<PasswordRequirement
met={/[@$!%*?&]/.test(password)}
text="Special character (@$!%*?&)"
/>
</div>
</div>
)}
</div>
{/* Confirm Password Field */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium
text-gray-700 mb-2"
>
Confirm Password
</label>
<div className="relative">
<div
className="absolute inset-y-0 left-0
pl-3 flex items-center
pointer-events-none"
>
<Lock
className="h-5 w-5 text-gray-400"
/>
</div>
<input
{...register('confirmPassword')}
id="confirmPassword"
type={
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-indigo-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(
!showConfirmPassword
)
}
className="absolute inset-y-0 right-0
pr-3 flex items-center"
>
{showConfirmPassword ? (
<EyeOff
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
) : (
<Eye
className="h-5 w-5 text-gray-400
hover:text-gray-600"
/>
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* Submit Button */}
<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-indigo-600
hover:bg-indigo-700
focus:outline-none focus:ring-2
focus:ring-offset-2
focus:ring-indigo-500
disabled:opacity-50
disabled:cursor-not-allowed
transition-colors"
>
{isLoading ? (
<>
<Loader2
className="animate-spin -ml-1 mr-2
h-5 w-5"
/>
Processing...
</>
) : (
<>
<KeyRound
className="-ml-1 mr-2 h-5 w-5"
/>
Reset Password
</>
)}
</button>
{/* Back to Login Link */}
<div className="text-center">
<Link
to="/login"
className="text-sm font-medium
text-indigo-600 hover:text-indigo-500
transition-colors"
>
Back to Login
</Link>
</div>
</form>
)}
</div>
{/* Security Info */}
{!isSuccess && (
<div
className="bg-white rounded-lg shadow-sm
border border-gray-200 p-4"
>
<h3
className="text-sm font-semibold
text-gray-900 mb-2 flex items-center
gap-2"
>
<Lock className="h-4 w-4" />
Security
</h3>
<ul
className="text-xs text-gray-600 space-y-1
list-disc list-inside"
>
<li>Reset link is valid for 1 hour only</li>
<li>Password is securely encrypted</li>
<li>
If the link expires, please request a new link
</li>
</ul>
</div>
)}
</div>
</div>
);
};
// Helper component for password requirements
const PasswordRequirement: React.FC<{
met: boolean;
text: string;
}> = ({ met, text }) => (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-gray-300" />
)}
<span className={met ? 'text-green-600' : 'text-gray-500'}>
{text}
</span>
</div>
);
export default ResetPasswordPage;

View File

@@ -0,0 +1,4 @@
export { default as LoginPage } from './LoginPage';
export { default as RegisterPage } from './RegisterPage';
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
export { default as ResetPasswordPage } from './ResetPasswordPage';

View File

@@ -0,0 +1,643 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import {
ArrowLeft,
Calendar,
MapPin,
Users,
CreditCard,
User,
Mail,
Phone,
FileText,
Building2,
CheckCircle,
AlertCircle,
Clock,
XCircle,
DoorOpen,
DoorClosed,
Loader2,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getBookingById,
cancelBooking,
type Booking,
} from '../../services/api/bookingService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
const BookingDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const [booking, setBooking] = useState<Booking | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
// Redirect if not authenticated
useEffect(() => {
if (!isAuthenticated) {
toast.error(
'Please login to view booking details'
);
navigate('/login', {
state: { from: `/bookings/${id}` }
});
}
}, [isAuthenticated, navigate, id]);
// Fetch booking details
useEffect(() => {
if (id && isAuthenticated) {
fetchBookingDetails(Number(id));
}
}, [id, isAuthenticated]);
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
setError(null);
const response = await getBookingById(bookingId);
if (
response.success &&
response.data?.booking
) {
setBooking(response.data.booking);
} else {
throw new Error(
'Unable to load booking information'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Unable to load booking information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const handleCancelBooking = async () => {
if (!booking) return;
const confirmed = window.confirm(
`Are you sure you want to cancel booking ` +
`${booking.booking_number}?\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 {
setCancelling(true);
const response = await cancelBooking(booking.id);
if (response.success) {
toast.success(
`✅ Booking ${booking.booking_number} cancelled successfully!`
);
// Update local state
setBooking((prev) =>
prev
? { ...prev, status: 'cancelled' }
: null
);
} 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 {
setCancelling(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(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) => {
return (
booking.status === 'pending' ||
booking.status === 'confirmed'
);
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !booking) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error || 'Booking not found'}
</p>
<button
onClick={() => navigate('/bookings')}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Back to list
</button>
</div>
</div>
</div>
);
}
const room = booking.room;
const roomType = room?.room_type;
const statusConfig = getStatusConfig(booking.status);
const StatusIcon = statusConfig.icon;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to="/bookings"
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to list</span>
</Link>
{/* Page Title */}
<div className="flex items-center justify-between
mb-6"
>
<h1 className="text-3xl font-bold text-gray-900">
Booking Details
</h1>
{/* Status Badge */}
<div
className={`flex items-center gap-2 px-4
py-2 rounded-full font-medium
${statusConfig.color}`}
>
<StatusIcon className="w-5 h-5" />
{statusConfig.text}
</div>
</div>
{/* Booking Number */}
<div className="bg-indigo-50 border
border-indigo-200 rounded-lg p-4 mb-6"
>
<p className="text-sm text-indigo-600
font-medium mb-1"
>
Booking Number
</p>
<p className="text-2xl font-bold text-indigo-900
font-mono"
>
{booking.booking_number}
</p>
</div>
{/* Room Information */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Room Information
</h2>
{roomType && (
<div className="flex flex-col md:flex-row
gap-6"
>
{/* Room Image */}
{roomType.images?.[0] && (
<div className="md:w-64 flex-shrink-0">
<img
src={roomType.images[0]}
alt={roomType.name}
className="w-full h-48 md:h-full
object-cover rounded-lg"
/>
</div>
)}
{/* Room Details */}
<div className="flex-1">
<h3 className="text-2xl font-bold
text-gray-900 mb-2"
>
{roomType.name}
</h3>
<p className="text-gray-600 mb-4">
<MapPin className="w-4 h-4 inline mr-1" />
Room {room?.room_number} -
Floor {room?.floor}
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">
Capacity
</p>
<p className="font-medium text-gray-900">
Max {roomType.capacity} guests
</p>
</div>
<div>
<p className="text-sm text-gray-500">
Room Price
</p>
<p className="font-medium text-indigo-600">
{formatPrice(roomType.base_price)}/night
</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Booking Details */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Booking Details
</h2>
<div className="space-y-4">
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2
gap-4"
>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Check-in Date
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_in_date)}
</p>
</div>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Check-out Date
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_out_date)}
</p>
</div>
</div>
{/* Guest Count */}
<div>
<p className="text-sm text-gray-600 mb-1">
<Users className="w-4 h-4 inline mr-1" />
Number of Guests
</p>
<p className="font-medium text-gray-900">
{booking.guest_count} guest(s)
</p>
</div>
{/* Notes */}
{booking.notes && (
<div>
<p className="text-sm text-gray-600 mb-1">
<FileText className="w-4 h-4 inline mr-1" />
Notes
</p>
<p className="font-medium text-gray-900">
{booking.notes}
</p>
</div>
)}
{/* Payment Method */}
<div className="border-t pt-4">
<p className="text-sm text-gray-600 mb-1">
<CreditCard className="w-4 h-4 inline mr-1" />
Payment Method
</p>
<p className="font-medium text-gray-900 mb-2">
{booking.payment_method === 'cash'
? '💵 Pay at hotel'
: '🏦 Bank transfer'}
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
Status:
</span>
<PaymentStatusBadge
status={booking.payment_status}
size="sm"
/>
</div>
</div>
{/* Total Price */}
<div className="border-t pt-4">
<div className="flex justify-between
items-center"
>
<span className="text-lg font-semibold
text-gray-900"
>
Total Payment
</span>
<span className="text-2xl font-bold
text-indigo-600"
>
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</div>
</div>
{/* Guest Information */}
{booking.guest_info && (
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Customer Information
</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">
<User className="w-4 h-4 inline mr-1" />
Full Name
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.full_name}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Mail className="w-4 h-4 inline mr-1" />
Email
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.email}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Phone className="w-4 h-4 inline mr-1" />
Phone Number
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.phone}
</p>
</div>
</div>
</div>
)}
{/* Bank Transfer Info */}
{booking.payment_method === 'bank_transfer' &&
booking.payment_status === 'unpaid' && (
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-6 mb-6"
>
<div className="flex items-start gap-3">
<Building2
className="w-6 h-6 text-blue-600
mt-1 flex-shrink-0"
/>
<div className="flex-1">
<h3 className="font-bold text-blue-900 mb-2">
Bank Transfer Information
</h3>
<div className="bg-white rounded p-4
space-y-2 text-sm"
>
<p>
<strong>Bank:</strong>
Vietcombank (VCB)
</p>
<p>
<strong>Account Number:</strong>
0123456789
</p>
<p>
<strong>Account Holder:</strong>
KHACH SAN ABC
</p>
<p>
<strong>Amount:</strong>{' '}
<span className="text-indigo-600
font-bold"
>
{formatPrice(booking.total_price)}
</span>
</p>
<p>
<strong>Content:</strong>{' '}
<span className="font-mono
text-indigo-600"
>
{booking.booking_number}
</span>
</p>
</div>
</div>
</div>
</div>
)}
{/* Important Notes */}
<div
className="bg-yellow-50 border border-yellow-200
rounded-lg p-4 mb-6"
>
<p className="text-sm text-yellow-800 font-medium
mb-2"
>
Important Notice
</p>
<ul className="text-sm text-yellow-700 space-y-1
ml-4 list-disc"
>
<li>
Please bring your ID card when checking in
</li>
<li>
Check-in time: 14:00 /
Check-out time: 12:00
</li>
{canCancelBooking(booking) && (
<li>
If you cancel the booking, 20% of
the total order value will be charged
</li>
)}
</ul>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Payment Button for unpaid bank transfer */}
{booking.payment_method === 'bank_transfer' &&
booking.payment_status === 'unpaid' && (
<Link
to={`/payment/${booking.id}`}
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-green-600 text-white rounded-lg
hover:bg-green-700 transition-colors
font-semibold"
>
<CreditCard className="w-5 h-5" />
Confirm Payment
</Link>
)}
{canCancelBooking(booking) && (
<button
onClick={handleCancelBooking}
disabled={cancelling}
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-red-600 text-white rounded-lg
hover:bg-red-700 transition-colors
font-semibold disabled:bg-gray-400
disabled:cursor-not-allowed"
>
{cancelling ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
/>
Cancelling...
</>
) : (
<>
<XCircle className="w-5 h-5" />
Cancel Booking
</>
)}
</button>
)}
<Link
to="/bookings"
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-gray-600 text-white rounded-lg
hover:bg-gray-700 transition-colors
font-semibold"
>
Back to list
</Link>
</div>
</div>
</div>
);
};
export default BookingDetailPage;

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { Calendar, Clock, DollarSign } from 'lucide-react';
const BookingListPage: React.FC = () => {
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">
Booking History
</h1>
<p className="text-gray-600">
Manage and track your bookings
</p>
</div>
{/* Booking List */}
<div className="space-y-4">
{[1, 2, 3].map((booking) => (
<div key={booking}
className="bg-white rounded-lg shadow-md
p-6 hover:shadow-lg transition-shadow"
>
<div className="flex flex-col md:flex-row
md:items-center md:justify-between"
>
<div className="flex-1">
<div className="flex items-center
space-x-3 mb-3"
>
<h3 className="text-xl font-semibold
text-gray-800"
>
Room {booking}01 - Deluxe
</h3>
<span className="px-3 py-1
bg-green-100 text-green-800
rounded-full text-sm font-medium"
>
Confirmed
</span>
</div>
<div className="grid grid-cols-1
md:grid-cols-3 gap-4 text-sm
text-gray-600"
>
<div className="flex items-center
space-x-2"
>
<Calendar className="w-4 h-4
text-blue-500"
/>
<span>
Check-in: 15/11/2025
</span>
</div>
<div className="flex items-center
space-x-2"
>
<Calendar className="w-4 h-4
text-blue-500"
/>
<span>
Check-out: 18/11/2025
</span>
</div>
<div className="flex items-center
space-x-2"
>
<Clock className="w-4 h-4
text-blue-500"
/>
<span>3 nights</span>
</div>
</div>
</div>
<div className="mt-4 md:mt-0
md:ml-6 flex flex-col items-end
space-y-3"
>
<div className="flex items-center
space-x-2"
>
<DollarSign className="w-5 h-5
text-green-600"
/>
<span className="text-2xl font-bold
text-gray-800"
>
${booking * 150}
</span>
</div>
<button className="px-4 py-2
bg-blue-600 text-white rounded-lg
hover:bg-blue-700 transition-colors
text-sm"
>
View Details
</button>
</div>
</div>
</div>
))}
</div>
{/* Empty State */}
{/* Uncomment when there are no bookings
<div className="text-center py-12">
<p className="text-gray-500 text-lg">
You don't have any bookings yet
</p>
<button className="mt-4 px-6 py-3
bg-blue-600 text-white rounded-lg
hover:bg-blue-700 transition-colors"
>
Book Now
</button>
</div>
*/}
</div>
);
};
export default BookingListPage;

View File

@@ -0,0 +1,839 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import DatePicker from 'react-datepicker';
import {
Calendar,
Users,
CreditCard,
Building2,
FileText,
ArrowLeft,
AlertCircle,
Loader2,
CheckCircle,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getRoomById, type Room } from
'../../services/api/roomService';
import {
createBooking,
checkRoomAvailability,
type BookingData,
} from '../../services/api/bookingService';
import useAuthStore from '../../store/useAuthStore';
import {
bookingValidationSchema,
type BookingFormData
} from '../../validators/bookingValidator';
import Loading from '../../components/common/Loading';
const BookingPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated, userInfo } = useAuthStore();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Redirect if not authenticated
useEffect(() => {
if (!isAuthenticated) {
toast.error(
'Please login to make a booking'
);
navigate('/login', {
state: { from: `/booking/${id}` }
});
}
}, [isAuthenticated, navigate, id]);
// Fetch room details
useEffect(() => {
if (id && isAuthenticated) {
fetchRoomDetails(Number(id));
}
}, [id, isAuthenticated]);
const fetchRoomDetails = async (roomId: number) => {
try {
setLoading(true);
setError(null);
const response = await getRoomById(roomId);
if (
(response.success ||
(response as any).status === 'success') &&
response.data?.room
) {
setRoom(response.data.room);
} else {
throw new Error('Unable to load room information');
}
} catch (err: any) {
console.error('Error fetching room:', err);
const message =
err.response?.data?.message ||
'Unable to load room information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
// Set up form with default values
const {
control,
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<BookingFormData>({
resolver: yupResolver(bookingValidationSchema),
defaultValues: {
checkInDate: undefined,
checkOutDate: undefined,
guestCount: 1,
notes: '',
paymentMethod: 'cash',
fullName: userInfo?.name || '',
email: userInfo?.email || '',
phone: userInfo?.phone || '',
},
});
// Watch form values for calculations
const checkInDate = watch('checkInDate');
const checkOutDate = watch('checkOutDate');
const paymentMethod = watch('paymentMethod');
// Calculate number of nights and total price
const numberOfNights =
checkInDate && checkOutDate
? Math.ceil(
(checkOutDate.getTime() -
checkInDate.getTime()) /
(1000 * 60 * 60 * 24)
)
: 0;
const roomPrice =
room?.room_type?.base_price || 0;
const totalPrice = numberOfNights * roomPrice;
// Format price
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
// Handle form submission
const onSubmit = async (data: BookingFormData) => {
if (!room) return;
try {
setSubmitting(true);
const checkInDateStr = data.checkInDate
.toISOString()
.split('T')[0];
const checkOutDateStr = data.checkOutDate
.toISOString()
.split('T')[0];
// Step 1: Check room availability
const availability = await checkRoomAvailability(
room.id,
checkInDateStr,
checkOutDateStr
);
if (!availability.available) {
toast.error(
availability.message ||
'Room is already booked during this time'
);
return;
}
// Step 2: Prepare booking data
const bookingData: BookingData = {
room_id: room.id,
check_in_date: checkInDateStr,
check_out_date: checkOutDateStr,
guest_count: data.guestCount,
notes: data.notes || '',
payment_method: data.paymentMethod,
total_price: totalPrice,
guest_info: {
full_name: data.fullName,
email: data.email,
phone: data.phone,
},
};
// Step 3: Create booking
const response = await createBooking(bookingData);
if (
response.success &&
response.data?.booking
) {
const bookingId = response.data.booking.id;
toast.success(
'🎉 Booking successful!',
{ icon: <CheckCircle className="text-green-500" /> }
);
// Navigate to success page
navigate(`/booking-success/${bookingId}`);
} else {
throw new Error(
response.message ||
'Unable to create booking'
);
}
} catch (err: any) {
console.error('Error creating booking:', err);
// Handle specific error cases
if (err.response?.status === 409) {
toast.error(
'❌ Room is already booked during this time. ' +
'Please select different dates.'
);
} else if (err.response?.status === 400) {
toast.error(
err.response?.data?.message ||
'Invalid booking information'
);
} else {
const message =
err.response?.data?.message ||
'Unable to book room. Please try again.';
toast.error(message);
}
} finally {
setSubmitting(false);
}
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
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"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 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;
if (!roomType) return null;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
{/* Back Button */}
<Link
to={`/rooms/${room.id}`}
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to room details</span>
</Link>
{/* Page Title */}
<h1
className="text-3xl font-bold text-gray-900 mb-8"
>
Book Room
</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Booking Form */}
<div className="lg:col-span-2">
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white rounded-lg shadow-md
p-6 space-y-6"
>
{/* Guest Information */}
<div>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Customer Information
</h2>
<div className="space-y-4">
{/* Full Name */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
>
Full Name
<span className="text-red-500">*</span>
</label>
<input
{...register('fullName')}
type="text"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
placeholder="John Doe"
/>
{errors.fullName && (
<p className="text-sm text-red-600 mt-1">
{errors.fullName.message}
</p>
)}
</div>
{/* Email & Phone */}
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
Email
<span className="text-red-500">*</span>
</label>
<input
{...register('email')}
type="email"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
placeholder="email@example.com"
/>
{errors.email && (
<p className="text-sm text-red-600
mt-1"
>
{errors.email.message}
</p>
)}
</div>
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
Phone Number
<span className="text-red-500">*</span>
</label>
<input
{...register('phone')}
type="tel"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
placeholder="0123456789"
/>
{errors.phone && (
<p className="text-sm text-red-600
mt-1"
>
{errors.phone.message}
</p>
)}
</div>
</div>
</div>
</div>
{/* Booking Details */}
<div className="border-t pt-6">
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Booking Details
</h2>
<div className="space-y-4">
{/* Date Range */}
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
{/* Check-in Date */}
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
<Calendar
className="w-4 h-4 inline mr-1"
/>
Check-in Date
<span className="text-red-500">*</span>
</label>
<Controller
control={control}
name="checkInDate"
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={new Date()}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-in date"
className="w-full px-4 py-2
border border-gray-300
rounded-lg focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
wrapperClassName="w-full"
/>
)}
/>
{errors.checkInDate && (
<p className="text-sm text-red-600
mt-1"
>
{errors.checkInDate.message}
</p>
)}
</div>
{/* Check-out Date */}
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
>
<Calendar
className="w-4 h-4 inline mr-1"
/>
Check-out Date
<span className="text-red-500">*</span>
</label>
<Controller
control={control}
name="checkOutDate"
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={
checkInDate || new Date()
}
selectsEnd
startDate={checkInDate}
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-out date"
className="w-full px-4 py-2
border border-gray-300
rounded-lg focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
wrapperClassName="w-full"
/>
)}
/>
{errors.checkOutDate && (
<p className="text-sm text-red-600
mt-1"
>
{errors.checkOutDate.message}
</p>
)}
</div>
</div>
{/* Guest Count */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
>
<Users
className="w-4 h-4 inline mr-1"
/>
Number of Guests
<span className="text-red-500">*</span>
</label>
<input
{...register('guestCount')}
type="number"
min="1"
max={roomType.capacity}
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
placeholder="1"
/>
<p className="text-sm text-gray-500 mt-1">
Maximum capacity: {roomType.capacity} guests
</p>
{errors.guestCount && (
<p className="text-sm text-red-600 mt-1">
{errors.guestCount.message}
</p>
)}
</div>
{/* Notes */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
>
<FileText
className="w-4 h-4 inline mr-1"
/>
Notes (optional)
</label>
<textarea
{...register('notes')}
rows={3}
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
placeholder="Special requests..."
/>
{errors.notes && (
<p className="text-sm text-red-600 mt-1">
{errors.notes.message}
</p>
)}
</div>
</div>
</div>
{/* Payment Method */}
<div className="border-t pt-6">
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Payment Method
</h2>
<div className="space-y-3">
{/* Cash */}
<label
className="flex items-start p-4
border-2 border-gray-200
rounded-lg cursor-pointer
hover:border-indigo-500
transition-colors"
>
<input
{...register('paymentMethod')}
type="radio"
value="cash"
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center
gap-2 mb-1"
>
<CreditCard
className="w-5 h-5
text-gray-600"
/>
<span className="font-medium
text-gray-900"
>
Pay on arrival
</span>
<span className="text-xs bg-orange-100
text-orange-700 px-2 py-0.5 rounded"
>
Requires 20% deposit
</span>
</div>
<p className="text-sm text-gray-600 mb-2">
Pay the remaining balance on arrival
</p>
<div className="bg-orange-50 border
border-orange-200 rounded p-2"
>
<p className="text-xs text-orange-800">
<strong>Note:</strong> You need to pay
<strong> 20% deposit</strong> via
bank transfer immediately after booking to
secure the room. Pay the remaining balance
on arrival.
</p>
</div>
</div>
</label>
{/* Bank Transfer */}
<label
className="flex items-start p-4
border-2 border-gray-200
rounded-lg cursor-pointer
hover:border-indigo-500
transition-colors"
>
<input
{...register('paymentMethod')}
type="radio"
value="bank_transfer"
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center
gap-2 mb-1"
>
<Building2
className="w-5 h-5
text-gray-600"
/>
<span className="font-medium
text-gray-900"
>
Bank Transfer
</span>
</div>
<p className="text-sm text-gray-600">
Transfer via QR code or
account number
</p>
</div>
</label>
{errors.paymentMethod && (
<p className="text-sm text-red-600">
{errors.paymentMethod.message}
</p>
)}
{/* Bank Transfer Info */}
{paymentMethod === 'bank_transfer' && (
<div
className="bg-blue-50 border
border-blue-200 rounded-lg
p-4 mt-3"
>
<p className="text-sm text-blue-800
font-medium mb-2"
>
📌 Bank Transfer Information
</p>
<p className="text-sm text-blue-700">
Scan QR code or transfer according to
the information after confirming the booking.
</p>
</div>
)}
</div>
</div>
{/* Submit Button */}
<div className="border-t pt-6">
<button
type="submit"
disabled={submitting}
className="w-full bg-indigo-600
text-white py-4 rounded-lg
hover:bg-indigo-700
transition-colors font-semibold
text-lg disabled:bg-gray-400
disabled:cursor-not-allowed
flex items-center justify-center
gap-2"
>
{submitting ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
/>
Processing...
</>
) : (
'Confirm Booking'
)}
</button>
</div>
</form>
</div>
{/* Booking Summary */}
<div className="lg:col-span-1">
<div
className="bg-white rounded-lg shadow-md
p-6 sticky top-8"
>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
>
Booking Summary
</h2>
{/* Room Info */}
<div className="mb-4">
{roomType.images?.[0] && (
<img
src={roomType.images[0]}
alt={roomType.name}
className="w-full h-48 object-cover
rounded-lg mb-3"
/>
)}
<h3 className="font-bold text-gray-900">
{roomType.name}
</h3>
<p className="text-sm text-gray-600">
Room {room.room_number} - Floor {room.floor}
</p>
</div>
{/* Pricing Breakdown */}
<div className="border-t pt-4 space-y-2">
<div className="flex justify-between
text-sm"
>
<span className="text-gray-600">
Room price/night
</span>
<span className="font-medium">
{formatPrice(roomPrice)}
</span>
</div>
{numberOfNights > 0 && (
<div className="flex justify-between
text-sm"
>
<span className="text-gray-600">
Nights
</span>
<span className="font-medium">
{numberOfNights} night(s)
</span>
</div>
)}
<div
className="border-t pt-2 flex
justify-between text-lg
font-bold"
>
<span>Total</span>
<span className="text-indigo-600">
{numberOfNights > 0
? formatPrice(totalPrice)
: '---'}
</span>
</div>
{/* Deposit amount for cash payment */}
{paymentMethod === 'cash' && numberOfNights > 0 && (
<div className="bg-orange-50 border
border-orange-200 rounded-lg p-3 mt-2"
>
<div className="flex justify-between
items-center mb-1"
>
<span className="text-sm font-medium
text-orange-900"
>
Deposit to pay (20%)
</span>
<span className="text-lg font-bold
text-orange-700"
>
{formatPrice(totalPrice * 0.2)}
</span>
</div>
<p className="text-xs text-orange-700">
Pay via bank transfer to confirm booking
</p>
</div>
)}
</div>
{/* Note */}
<div
className={`border rounded-lg p-3 mt-4 ${
paymentMethod === 'cash'
? 'bg-orange-50 border-orange-200'
: 'bg-yellow-50 border-yellow-200'
}`}
>
{paymentMethod === 'cash' ? (
<p className="text-xs text-orange-800">
🔒 <strong>Required:</strong> Pay 20% deposit
via bank transfer after booking.
Remaining balance ({formatPrice(totalPrice * 0.8)})
to be paid on arrival.
</p>
) : (
<p className="text-xs text-yellow-800">
💡 Scan QR code or transfer according to the information
after confirming the booking.
</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default BookingPage;

View File

@@ -0,0 +1,823 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import {
CheckCircle,
Home,
ListOrdered,
Calendar,
Users,
CreditCard,
MapPin,
Mail,
Phone,
User,
FileText,
Building2,
AlertCircle,
Copy,
Check,
Loader2,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getBookingById,
generateQRCode,
type Booking,
} from '../../services/api/bookingService';
import { confirmBankTransfer } from
'../../services/api/paymentService';
import Loading from '../../components/common/Loading';
const BookingSuccessPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [booking, setBooking] = useState<Booking | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedBookingNumber, setCopiedBookingNumber] =
useState(false);
const [uploadingReceipt, setUploadingReceipt] =
useState(false);
const [receiptUploaded, setReceiptUploaded] =
useState(false);
const [selectedFile, setSelectedFile] =
useState<File | null>(null);
const [previewUrl, setPreviewUrl] =
useState<string | null>(null);
useEffect(() => {
if (id) {
fetchBookingDetails(Number(id));
}
}, [id]);
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
setError(null);
const response = await getBookingById(bookingId);
if (
response.success &&
response.data?.booking
) {
const bookingData = response.data.booking;
setBooking(bookingData);
// Redirect to deposit payment page if required and not yet paid
if (
bookingData.requires_deposit &&
!bookingData.deposit_paid
) {
navigate(`/deposit-payment/${bookingId}`, { replace: true });
return;
}
} else {
throw new Error(
'Unable to load booking information'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Unable to load booking information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'checked_in':
return 'bg-blue-100 text-blue-800';
case 'checked_out':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'confirmed':
return 'Confirmed';
case 'pending':
return 'Pending confirmation';
case 'cancelled':
return 'Cancelled';
case 'checked_in':
return 'Checked in';
case 'checked_out':
return 'Checked out';
default:
return status;
}
};
const copyBookingNumber = async () => {
if (!booking?.booking_number) return;
try {
await navigator.clipboard.writeText(
booking.booking_number
);
setCopiedBookingNumber(true);
toast.success('Booking number copied');
setTimeout(() => setCopiedBookingNumber(false), 2000);
} catch (err) {
toast.error('Unable to copy');
}
};
const handleFileSelect = (
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 not exceed 5MB');
return;
}
setSelectedFile(file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleUploadReceipt = async () => {
if (!selectedFile || !booking) return;
try {
setUploadingReceipt(true);
// Generate transaction ID based on booking number
const transactionId =
`TXN-${booking.booking_number}-${Date.now()}`;
const response = await confirmBankTransfer(
booking.id,
transactionId,
selectedFile
);
if (response.success) {
toast.success(
'✅ Payment confirmation sent successfully! ' +
'We will confirm as soon as possible.'
);
setReceiptUploaded(true);
// Update booking payment status locally
setBooking((prev) =>
prev
? {
...prev,
payment_status: 'paid',
status: prev.status === 'pending'
? 'confirmed'
: prev.status
}
: null
);
} else {
throw new Error(
response.message ||
'Unable to confirm payment'
);
}
} catch (err: any) {
console.error('Error uploading receipt:', err);
const message =
err.response?.data?.message ||
'Unable to send payment confirmation. ' +
'Please try again.';
toast.error(message);
} finally {
setUploadingReceipt(false);
}
};
const qrCodeUrl = booking
? generateQRCode(
booking.booking_number,
booking.total_price
)
: null;
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !booking) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error || 'Booking 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 room = booking.room;
const roomType = room?.room_type;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Success Header */}
<div
className="bg-white rounded-lg shadow-md
p-8 mb-6 text-center"
>
<div
className="w-20 h-20 bg-green-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<CheckCircle
className="w-12 h-12 text-green-600"
/>
</div>
<h1
className="text-3xl font-bold text-gray-900
mb-2"
>
Booking Successful!
</h1>
<p className="text-gray-600 mb-4">
Thank you for booking with our hotel
</p>
{/* Booking Number */}
<div
className="inline-flex items-center gap-2
bg-indigo-50 px-6 py-3 rounded-lg"
>
<span className="text-sm text-indigo-600
font-medium"
>
Booking Number:
</span>
<span className="text-lg font-bold
text-indigo-900"
>
{booking.booking_number}
</span>
<button
onClick={copyBookingNumber}
className="ml-2 p-1 hover:bg-indigo-100
rounded transition-colors"
title="Copy booking number"
>
{copiedBookingNumber ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-indigo-600" />
)}
</button>
</div>
{/* Status Badge */}
<div className="mt-4">
<span
className={`inline-block px-4 py-2
rounded-full text-sm font-medium
${getStatusColor(booking.status)}`}
>
{getStatusText(booking.status)}
</span>
</div>
</div>
{/* Booking Details */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Booking Details
</h2>
<div className="space-y-4">
{/* Room Information */}
{roomType && (
<div className="border-b pb-4">
<div className="flex items-start gap-4">
{roomType.images?.[0] && (
<img
src={roomType.images[0]}
alt={roomType.name}
className="w-24 h-24 object-cover
rounded-lg"
/>
)}
<div className="flex-1">
<h3 className="font-bold text-lg
text-gray-900"
>
{roomType.name}
</h3>
{room && (
<p className="text-gray-600 text-sm">
<MapPin className="w-4 h-4
inline mr-1"
/>
Room {room.room_number} -
Floor {room.floor}
</p>
)}
<p className="text-indigo-600
font-semibold mt-1"
>
{formatPrice(roomType.base_price)}/night
</p>
</div>
</div>
</div>
)}
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2
gap-4"
>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Check-in Date
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_in_date)}
</p>
</div>
<div>
<p className="text-sm text-gray-600 mb-1">
<Calendar className="w-4 h-4 inline mr-1" />
Check-out Date
</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_out_date)}
</p>
</div>
</div>
{/* Guest Count */}
<div>
<p className="text-sm text-gray-600 mb-1">
<Users className="w-4 h-4 inline mr-1" />
Number of Guests
</p>
<p className="font-medium text-gray-900">
{booking.guest_count} guest(s)
</p>
</div>
{/* Notes */}
{booking.notes && (
<div>
<p className="text-sm text-gray-600 mb-1">
<FileText className="w-4 h-4 inline mr-1" />
Notes
</p>
<p className="font-medium text-gray-900">
{booking.notes}
</p>
</div>
)}
{/* Payment Method */}
<div className="border-t pt-4">
<p className="text-sm text-gray-600 mb-1">
<CreditCard className="w-4 h-4 inline mr-1" />
Payment Method
</p>
<p className="font-medium text-gray-900">
{booking.payment_method === 'cash'
? '💵 Pay at hotel'
: '🏦 Bank transfer'}
</p>
</div>
{/* Total Price */}
<div className="border-t pt-4">
<div className="flex justify-between
items-center"
>
<span className="text-lg font-semibold
text-gray-900"
>
Total Payment
</span>
<span className="text-2xl font-bold
text-indigo-600"
>
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</div>
</div>
{/* Guest Information */}
{booking.guest_info && (
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h2 className="text-xl font-bold text-gray-900
mb-4"
>
Customer Information
</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">
<User className="w-4 h-4 inline mr-1" />
Full Name
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.full_name}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Mail className="w-4 h-4 inline mr-1" />
Email
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.email}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<Phone className="w-4 h-4 inline mr-1" />
Phone Number
</p>
<p className="font-medium text-gray-900">
{booking.guest_info.phone}
</p>
</div>
</div>
</div>
)}
{/* Bank Transfer Instructions */}
{booking.payment_method === 'bank_transfer' && (
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-6 mb-6"
>
<div className="flex items-start gap-3 mb-4">
<Building2
className="w-6 h-6 text-blue-600
mt-1 flex-shrink-0"
/>
<div className="flex-1">
<h3 className="font-bold text-blue-900 mb-2">
Bank Transfer Instructions
</h3>
<div className="space-y-2 text-sm
text-blue-800"
>
<p>
Please transfer according to the following information:
</p>
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
{/* Bank Info */}
<div className="bg-white rounded-lg
p-4 space-y-2"
>
<p>
<strong>Bank:</strong>
Vietcombank (VCB)
</p>
<p>
<strong>Account Number:</strong>
0123456789
</p>
<p>
<strong>Account Holder:</strong>
KHACH SAN ABC
</p>
<p>
<strong>Amount:</strong>{' '}
<span className="text-indigo-600
font-bold"
>
{formatPrice(booking.total_price)}
</span>
</p>
<p>
<strong>Content:</strong>{' '}
<span className="font-mono
text-indigo-600"
>
{booking.booking_number}
</span>
</p>
</div>
{/* QR Code */}
{qrCodeUrl && (
<div className="bg-white rounded-lg
p-4 flex flex-col items-center
justify-center"
>
<p className="text-sm font-medium
text-gray-700 mb-2"
>
Scan QR code to transfer
</p>
<img
src={qrCodeUrl}
alt="QR Code"
className="w-48 h-48 border-2
border-gray-200 rounded-lg"
/>
<p className="text-xs text-gray-500
mt-2 text-center"
>
QR code includes all information
</p>
</div>
)}
</div>
<p className="text-xs italic mt-2">
💡 Note: Please enter the correct booking number
in the transfer content so we can confirm your payment.
</p>
</div>
</div>
</div>
{/* Upload Receipt Section */}
{!receiptUploaded ? (
<div className="border-t border-blue-200
pt-4"
>
<h4 className="font-semibold text-blue-900
mb-3"
>
📎 Payment Confirmation
</h4>
<p className="text-sm text-blue-700 mb-3">
After transferring, please upload
the receipt image so we can confirm faster.
</p>
<div className="space-y-3">
{/* File Input */}
<div>
<label
htmlFor="receipt-upload"
className="block w-full px-4 py-3
border-2 border-dashed
border-blue-300 rounded-lg
text-center cursor-pointer
hover:border-blue-400
hover:bg-blue-100/50
transition-colors"
>
<input
id="receipt-upload"
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex flex-col
items-center gap-2"
>
{previewUrl ? (
<>
<img
src={previewUrl}
alt="Preview"
className="w-32 h-32
object-cover rounded"
/>
<p className="text-sm
text-blue-600 font-medium"
>
{selectedFile?.name}
</p>
<p className="text-xs
text-gray-500"
>
Click to select another image
</p>
</>
) : (
<>
<FileText
className="w-8 h-8
text-blue-400"
/>
<p className="text-sm
text-blue-600 font-medium"
>
Select receipt image
</p>
<p className="text-xs
text-gray-500"
>
PNG, JPG, JPEG (Max 5MB)
</p>
</>
)}
</div>
</label>
</div>
{/* Upload Button */}
{selectedFile && (
<button
onClick={handleUploadReceipt}
disabled={uploadingReceipt}
className="w-full px-4 py-3
bg-blue-600 text-white
rounded-lg hover:bg-blue-700
transition-colors font-semibold
disabled:bg-gray-400
disabled:cursor-not-allowed
flex items-center
justify-center gap-2"
>
{uploadingReceipt ? (
<>
<Loader2
className="w-5 h-5
animate-spin"
/>
Sending...
</>
) : (
<>
<CheckCircle
className="w-5 h-5"
/>
Confirm payment completed
</>
)}
</button>
)}
</div>
</div>
) : (
<div className="border-t border-green-200
pt-4 bg-green-50 rounded-lg p-4"
>
<div className="flex items-center
gap-3"
>
<CheckCircle
className="w-6 h-6 text-green-600
flex-shrink-0"
/>
<div>
<p className="font-semibold
text-green-900"
>
Payment confirmation sent
</p>
<p className="text-sm text-green-700">
We will confirm your order
as soon as possible.
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Important Notice */}
<div
className="bg-yellow-50 border border-yellow-200
rounded-lg p-4 mb-6"
>
<p className="text-sm text-yellow-800">
<strong>Important Notice:</strong>
</p>
<ul className="text-sm text-yellow-700 mt-2
space-y-1 ml-4 list-disc"
>
<li>
Please bring your ID card when checking in
</li>
<li>
Check-in time: 14:00 /
Check-out time: 12:00
</li>
<li>
If you cancel the booking, 20% of
the total order value will be charged
</li>
{booking.payment_method === 'bank_transfer' && (
<li>
Please transfer within 24 hours
to secure your room
</li>
)}
</ul>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4">
<Link
to="/bookings"
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
<ListOrdered className="w-5 h-5" />
View My Bookings
</Link>
<Link
to="/"
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-gray-600 text-white rounded-lg
hover:bg-gray-700 transition-colors
font-semibold"
>
<Home className="w-5 h-5" />
Go to Home
</Link>
</div>
</div>
</div>
);
};
export default BookingSuccessPage;

View File

@@ -0,0 +1,250 @@
import React from 'react';
import {
TrendingUp,
Hotel,
DollarSign,
Calendar,
Activity
} from 'lucide-react';
const DashboardPage: React.FC = () => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
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">
Dashboard
</h1>
<p className="text-gray-600">
Overview of your activity
</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="p-3 bg-blue-100 rounded-lg">
<Calendar className="w-6 h-6
text-blue-600"
/>
</div>
<span className="text-sm text-green-600
font-medium"
>
+12%
</span>
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
Total Bookings
</h3>
<p className="text-3xl font-bold text-gray-800">
45
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<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>
<span className="text-sm text-green-600
font-medium"
>
+8%
</span>
</div>
<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)}
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<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>
</div>
<h3 className="text-gray-500 text-sm
font-medium mb-1"
>
Currently Staying
</h3>
<p className="text-3xl font-bold text-gray-800">
2
</p>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<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"
/>
</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>
<p className="text-3xl font-bold text-gray-800">
1,250
</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
</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"
>
<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}
</p>
</div>
<span className="text-sm text-gray-400">
{activity.time}
</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-lg shadow-md
p-6"
>
<h2 className="text-xl font-semibold
text-gray-800 mb-4"
>
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'
}`}
>
{booking.status}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,546 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
AlertCircle,
CreditCard,
Building2,
Copy,
Check,
Loader2,
ArrowLeft,
Download,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, type Booking } from
'../../services/api/bookingService';
import {
getPaymentsByBookingId,
getBankTransferInfo,
notifyPaymentCompletion,
type Payment,
type BankInfo,
} from '../../services/api/paymentService';
import Loading from '../../components/common/Loading';
const DepositPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate();
const [booking, setBooking] = useState<Booking | null>(null);
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
const [bankInfo, setBankInfo] = useState<BankInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notifying, setNotifying] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<
'bank_transfer' | null
>('bank_transfer');
useEffect(() => {
if (bookingId) {
fetchData(Number(bookingId));
}
}, [bookingId]);
const fetchData = async (id: number) => {
try {
setLoading(true);
setError(null);
// Fetch booking details
const bookingResponse = await getBookingById(id);
if (!bookingResponse.success || !bookingResponse.data?.booking) {
throw new Error('Booking not found');
}
const bookingData = bookingResponse.data.booking;
setBooking(bookingData);
// Check if booking requires deposit
if (!bookingData.requires_deposit) {
toast.info('This booking does not require a deposit');
navigate(`/bookings/${id}`);
return;
}
// Fetch payments
const paymentsResponse = await getPaymentsByBookingId(id);
if (paymentsResponse.success) {
const deposit = paymentsResponse.data.payments.find(
(p) => p.payment_type === 'deposit'
);
if (deposit) {
setDepositPayment(deposit);
// If payment is pending, fetch bank info
if (deposit.payment_status === 'pending') {
const bankInfoResponse = await getBankTransferInfo(deposit.id);
if (bankInfoResponse.success && bankInfoResponse.data.bank_info) {
setBankInfo(bankInfoResponse.data.bank_info);
}
}
}
}
} catch (err: any) {
console.error('Error fetching data:', err);
const message =
err.response?.data?.message || 'Unable to load payment information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedText(label);
toast.success(`Copied ${label}`);
setTimeout(() => setCopiedText(null), 2000);
} catch (err) {
toast.error('Unable to copy');
}
};
// No auto-redirect payment methods. Default to bank transfer.
const handleNotifyPayment = async () => {
if (!depositPayment) return;
try {
setNotifying(true);
const response = await notifyPaymentCompletion(
depositPayment.id,
'Customer has transferred deposit'
);
if (response.success) {
toast.success(
'✅ Payment notification sent! ' +
'We will confirm within 24 hours.'
);
navigate(`/bookings/${bookingId}`);
} else {
throw new Error(response.message || 'Unable to send notification');
}
} catch (err: any) {
console.error('Error notifying payment:', err);
const message =
err.response?.data?.message ||
'Unable to send notification. Please try again.';
toast.error(message);
} finally {
setNotifying(false);
}
};
// VNPay removed: no online redirect handler
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !booking || !depositPayment) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
<p className="text-red-700 font-medium mb-4">
{error || 'Payment information not found'}
</p>
<Link
to="/bookings"
className="inline-flex items-center gap-2 px-6 py-2
bg-red-600 text-white rounded-lg hover:bg-red-700
transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to booking list
</Link>
</div>
</div>
</div>
);
}
const depositAmount = parseFloat(depositPayment.amount.toString());
const remainingAmount = booking.total_price - depositAmount;
const isDepositPaid = depositPayment.payment_status === 'completed';
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to={`/bookings/${bookingId}`}
className="inline-flex items-center gap-2 text-gray-600
hover:text-gray-900 mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to booking details</span>
</Link>
{/* Success Header (if paid) */}
{isDepositPaid && (
<div
className="bg-green-50 border-2 border-green-200
rounded-lg p-6 mb-6"
>
<div className="flex items-center gap-4">
<div
className="w-16 h-16 bg-green-100 rounded-full
flex items-center justify-center"
>
<CheckCircle className="w-10 h-10 text-green-600" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-green-900 mb-1">
Deposit payment successful!
</h1>
<p className="text-green-700">
Your booking has been confirmed.
Remaining amount to be paid at check-in.
</p>
</div>
</div>
</div>
)}
{/* Pending Header */}
{!isDepositPaid && (
<div
className="bg-orange-50 border-2 border-orange-200
rounded-lg p-6 mb-6"
>
<div className="flex items-center gap-4">
<div
className="w-16 h-16 bg-orange-100 rounded-full
flex items-center justify-center"
>
<CreditCard className="w-10 h-10 text-orange-600" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-orange-900 mb-1">
Deposit Payment
</h1>
<p className="text-orange-700">
Please pay <strong>20% deposit</strong> to
confirm your booking
</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Payment Info */}
<div className="lg:col-span-2 space-y-6">
{/* Payment Summary */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Payment Information
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Total Room Price</span>
<span className="font-medium">
{formatPrice(booking.total_price)}
</span>
</div>
<div
className="flex justify-between border-t pt-3
text-orange-600"
>
<span className="font-medium">
Deposit Amount to Pay (20%)
</span>
<span className="text-xl font-bold">
{formatPrice(depositAmount)}
</span>
</div>
<div className="flex justify-between text-sm text-gray-500">
<span>Remaining amount to be paid at check-in</span>
<span>{formatPrice(remainingAmount)}</span>
</div>
</div>
{isDepositPaid && (
<div className="mt-4 bg-green-50 border border-green-200 rounded p-3">
<p className="text-sm text-green-800">
Deposit paid on:{' '}
{depositPayment.payment_date
? new Date(depositPayment.payment_date).toLocaleString('en-US')
: 'N/A'}
</p>
{depositPayment.transaction_id && (
<p className="text-xs text-green-700 mt-1">
Transaction ID: {depositPayment.transaction_id}
</p>
)}
</div>
)}
</div>
{/* Payment Method Selection */}
{!isDepositPaid && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6">
Select Payment Method
</h2>
{/* Payment Method Buttons */}
<div className="grid grid-cols-2 gap-4 mb-6">
{/* Bank Transfer Button */}
<button
onClick={() => setSelectedPaymentMethod('bank_transfer')}
className={`p-4 border-2 rounded-lg transition-all
${
selectedPaymentMethod === 'bank_transfer'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 bg-white hover:border-indigo-300'
}`}
>
<Building2
className={`w-8 h-8 mx-auto mb-2 ${
selectedPaymentMethod === 'bank_transfer'
? 'text-indigo-600'
: 'text-gray-600'
}`}
/>
<div
className={`font-bold text-sm ${
selectedPaymentMethod === 'bank_transfer'
? 'text-indigo-900'
: 'text-gray-700'
}`}
>
Bank Transfer
</div>
<div className="text-xs text-gray-500 mt-1">
Bank transfer
</div>
</button>
{/* VNPay removed */}
</div>
</div>
)}
{/* Bank Transfer Instructions or VNPay panel */}
{!isDepositPaid && selectedPaymentMethod === 'bank_transfer' &&
bankInfo && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<Building2 className="w-5 h-5 inline mr-2" />
Bank Transfer Information
</h2>
<div className="space-y-4">
{/* Bank Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Bank</div>
<div className="font-medium">{bankInfo.bank_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.bank_name, 'bank name')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'bank name' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Account Number</div>
<div className="font-medium font-mono">
{bankInfo.account_number}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_number, 'account number')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'account number' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Account Holder</div>
<div className="font-medium">{bankInfo.account_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_name, 'account holder')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'account holder' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-orange-50 border border-orange-200 rounded">
<div>
<div className="text-xs text-orange-700">Amount</div>
<div className="text-lg font-bold text-orange-600">
{formatPrice(bankInfo.amount)}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.amount.toString(), 'amount')
}
className="p-2 hover:bg-orange-100 rounded transition-colors"
>
{copiedText === 'amount' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-orange-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Transfer Content</div>
<div className="font-medium font-mono text-red-600">
{bankInfo.content}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.content, 'content')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'content' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<p className="text-sm text-yellow-800">
<strong> Note:</strong> Please enter the correct transfer content so
the system can automatically confirm the payment.
</p>
</div>
{/* Notify Button */}
<button
onClick={handleNotifyPayment}
disabled={notifying}
className="w-full bg-indigo-600 text-white py-3 rounded-lg
hover:bg-indigo-700 transition-colors font-semibold
disabled:bg-gray-400 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{notifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Sending...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
I have transferred
</>
)}
</button>
<p className="text-xs text-center text-gray-500 mt-2">
After transferring, click the button above to notify us
</p>
</div>
</div>
)}
{/* VNPay removed */}
</div>
{/* QR Code Sidebar */}
{!isDepositPaid &&
bankInfo &&
selectedPaymentMethod === 'bank_transfer' && (
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-md p-6 sticky top-8">
<h3 className="text-lg font-bold text-gray-900 mb-4 text-center">
Scan QR Code to Pay
</h3>
<div className="bg-gray-50 p-4 rounded-lg mb-4">
<img
src={bankInfo.qr_url}
alt="QR Code"
className="w-full h-auto rounded"
/>
</div>
<div className="text-center space-y-2">
<p className="text-sm text-gray-600">
Scan QR code with your bank app
</p>
<p className="text-xs text-gray-500">
Transfer information has been automatically filled
</p>
</div>
<a
href={bankInfo.qr_url}
download={`deposit-qr-${booking.booking_number}.jpg`}
className="mt-4 w-full inline-flex items-center justify-center
gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200
text-gray-700 rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Download QR Code
</a>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default DepositPaymentPage;

View File

@@ -0,0 +1,201 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Heart, AlertCircle, ArrowLeft } from 'lucide-react';
import { RoomCard, RoomCardSkeleton } from
'../../components/rooms';
import useFavoritesStore from
'../../store/useFavoritesStore';
import useAuthStore from '../../store/useAuthStore';
const FavoritesPage: React.FC = () => {
const { isAuthenticated } = useAuthStore();
const {
favorites,
isLoading,
error,
fetchFavorites
} = useFavoritesStore();
useEffect(() => {
if (isAuthenticated) {
fetchFavorites();
}
}, [isAuthenticated, fetchFavorites]);
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div
className="bg-yellow-50 border
border-yellow-200 rounded-lg
p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-yellow-500
mx-auto mb-3"
/>
<h3
className="text-xl font-bold
text-gray-900 mb-2"
>
Please Login
</h3>
<p className="text-gray-600 mb-4">
You need to login to view your favorites list
</p>
<Link
to="/login"
className="inline-block px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
Login
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<Link
to="/"
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to home</span>
</Link>
<div className="flex items-center gap-3">
<Heart
className="w-8 h-8 text-red-500"
fill="currentColor"
/>
<div>
<h1
className="text-3xl font-bold
text-gray-900"
>
Favorites List
</h1>
<p className="text-gray-600 mt-1">
{favorites.length > 0
? `${favorites.length} room${favorites.length !== 1 ? 's' : ''}`
: 'No favorite rooms yet'}
</p>
</div>
</div>
</div>
{/* Loading State */}
{isLoading && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{/* Error State */}
{error && !isLoading && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error}
</p>
<button
onClick={fetchFavorites}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Try again
</button>
</div>
)}
{/* Empty State */}
{!isLoading &&
!error &&
favorites.length === 0 && (
<div
className="bg-white rounded-lg shadow-sm
p-12 text-center"
>
<div
className="w-24 h-24 bg-gray-100
rounded-full flex items-center
justify-center mx-auto mb-6"
>
<Heart
className="w-12 h-12 text-gray-400"
/>
</div>
<h3
className="text-2xl font-bold
text-gray-900 mb-3"
>
No favorite rooms yet
</h3>
<p
className="text-gray-600 mb-6
max-w-md mx-auto"
>
You haven't added any rooms to your favorites list yet. Explore and save the rooms you like!
</p>
<Link
to="/rooms"
className="inline-block px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
Explore rooms
</Link>
</div>
)}
{/* Favorites Grid */}
{!isLoading &&
!error &&
favorites.length > 0 && (
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{favorites.map((favorite) =>
favorite.room ? (
<RoomCard
key={favorite.id}
room={favorite.room}
/>
) : null
)}
</div>
)}
</div>
</div>
);
};
export default FavoritesPage;

View File

@@ -0,0 +1,639 @@
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';
const MyBookingsPage: React.FC = () => {
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
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) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(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) => {
// Can only cancel pending or confirmed bookings
return (
booking.status === 'pending' ||
booking.status === 'confirmed'
);
};
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 */}
{roomType?.images?.[0] && (
<div className="lg:w-48 flex-shrink-0">
<img
src={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
onClick={() =>
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"
>
{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;

View File

@@ -0,0 +1,536 @@
import React, { useState, useEffect } from 'react';
import {
useParams,
useNavigate,
Link
} from 'react-router-dom';
import {
ArrowLeft,
Building2,
Upload,
CheckCircle,
AlertCircle,
Loader2,
Copy,
Check,
FileText,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
getBookingById,
generateQRCode,
type Booking,
} from '../../services/api/bookingService';
import { confirmBankTransfer } from
'../../services/api/paymentService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
const PaymentConfirmationPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const [booking, setBooking] = useState<Booking | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [selectedFile, setSelectedFile] =
useState<File | null>(null);
const [previewUrl, setPreviewUrl] =
useState<string | null>(null);
const [copiedBookingNumber, setCopiedBookingNumber] =
useState(false);
useEffect(() => {
if (!isAuthenticated) {
toast.error(
'Please login to confirm payment'
);
navigate('/login');
}
}, [isAuthenticated, navigate]);
useEffect(() => {
if (id && isAuthenticated) {
fetchBookingDetails(Number(id));
}
}, [id, isAuthenticated]);
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
setError(null);
const response = await getBookingById(bookingId);
if (
response.success &&
response.data?.booking
) {
const bookingData = response.data.booking;
// Check if already paid
if (bookingData.payment_status === 'paid') {
toast.info('This booking has already been paid');
navigate(`/bookings/${bookingId}`);
return;
}
// Check if payment method is cash
if (bookingData.payment_method === 'cash') {
toast.info(
'This booking uses on-site payment method'
);
navigate(`/bookings/${bookingId}`);
return;
}
setBooking(bookingData);
} else {
throw new Error(
'Unable to load booking information'
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Unable to load booking information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const copyBookingNumber = async () => {
if (!booking?.booking_number) return;
try {
await navigator.clipboard.writeText(
booking.booking_number
);
setCopiedBookingNumber(true);
toast.success('Booking number copied');
setTimeout(() => setCopiedBookingNumber(false), 2000);
} catch (err) {
toast.error('Unable to copy');
}
};
const handleFileSelect = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error(
'Image size must not exceed 5MB'
);
return;
}
setSelectedFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleConfirmPayment = async () => {
if (!selectedFile || !booking) return;
try {
setUploading(true);
const transactionId =
`TXN-${booking.booking_number}-${Date.now()}`;
const response = await confirmBankTransfer(
booking.id,
transactionId,
selectedFile
);
if (response.success) {
toast.success(
'✅ Payment confirmation sent successfully!'
);
setUploadSuccess(true);
// Redirect after 2 seconds
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
} else {
throw new Error(
response.message ||
'Unable to confirm payment'
);
}
} catch (err: any) {
console.error('Error confirming payment:', err);
const message =
err.response?.data?.message ||
'Unable to send payment confirmation';
toast.error(message);
} finally {
setUploading(false);
}
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !booking) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error || 'Booking not found'}
</p>
<button
onClick={() => navigate('/bookings')}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Back to list
</button>
</div>
</div>
</div>
);
}
const qrCodeUrl = generateQRCode(
booking.booking_number,
booking.total_price
);
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to={`/bookings/${booking.id}`}
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-6 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to booking details</span>
</Link>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900
mb-2"
>
Payment Confirmation
</h1>
<p className="text-gray-600">
Complete payment for your booking
</p>
</div>
{/* Booking Info Card */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<div className="flex items-start justify-between
gap-4 mb-4"
>
<div>
<p className="text-sm text-gray-600 mb-1">
Booking Number
</p>
<div className="flex items-center gap-2">
<span className="text-xl font-bold
text-indigo-900 font-mono"
>
{booking.booking_number}
</span>
<button
onClick={copyBookingNumber}
className="p-1 hover:bg-gray-100
rounded transition-colors"
title="Copy"
>
{copiedBookingNumber ? (
<Check className="w-4 h-4
text-green-600"
/>
) : (
<Copy className="w-4 h-4
text-gray-400"
/>
)}
</button>
</div>
</div>
<PaymentStatusBadge
status={booking.payment_status}
size="md"
/>
</div>
<div className="border-t pt-4">
<div className="flex justify-between
items-center"
>
<span className="text-gray-600">
Total Payment
</span>
<span className="text-2xl font-bold
text-indigo-600"
>
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</div>
{!uploadSuccess ? (
<>
{/* Bank Transfer Instructions */}
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-6 mb-6"
>
<div className="flex items-start gap-3 mb-4">
<Building2
className="w-6 h-6 text-blue-600
mt-1 flex-shrink-0"
/>
<div className="flex-1">
<h3 className="font-bold text-blue-900
mb-3"
>
Bank Transfer Information
</h3>
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
>
{/* Bank Info */}
<div className="bg-white rounded-lg
p-4 space-y-2 text-sm"
>
<p>
<strong>Bank:</strong>
Vietcombank (VCB)
</p>
<p>
<strong>Account Number:</strong>
0123456789
</p>
<p>
<strong>Account Holder:</strong>
KHACH SAN ABC
</p>
<p>
<strong>Amount:</strong>{' '}
<span className="text-indigo-600
font-bold"
>
{formatPrice(booking.total_price)}
</span>
</p>
<p>
<strong>Content:</strong>{' '}
<span className="font-mono
text-indigo-600"
>
{booking.booking_number}
</span>
</p>
</div>
{/* QR Code */}
<div className="bg-white rounded-lg
p-4 flex flex-col items-center"
>
<p className="text-sm font-medium
text-gray-700 mb-2"
>
Scan QR code to transfer
</p>
<img
src={qrCodeUrl}
alt="QR Code"
className="w-48 h-48 border-2
border-gray-200 rounded-lg"
/>
</div>
</div>
</div>
</div>
</div>
{/* Upload Receipt Section */}
<div className="bg-white rounded-lg shadow-md
p-6 mb-6"
>
<h3 className="text-xl font-bold
text-gray-900 mb-4"
>
<Upload className="w-5 h-5 inline mr-2" />
Upload Payment Receipt
</h3>
<p className="text-gray-600 mb-4">
After transferring, please upload a receipt image
so we can confirm faster.
</p>
<div className="space-y-4">
{/* File Input */}
<label
htmlFor="receipt-upload"
className="block w-full px-4 py-6
border-2 border-dashed
border-gray-300 rounded-lg
text-center cursor-pointer
hover:border-indigo-400
hover:bg-indigo-50
transition-all"
>
<input
id="receipt-upload"
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
{previewUrl ? (
<div className="flex flex-col
items-center gap-3"
>
<img
src={previewUrl}
alt="Preview"
className="w-48 h-48 object-cover
rounded-lg border-2
border-indigo-200"
/>
<p className="text-sm text-indigo-600
font-medium"
>
{selectedFile?.name}
</p>
<p className="text-xs text-gray-500">
Click to select another image
</p>
</div>
) : (
<div className="flex flex-col
items-center gap-2"
>
<FileText
className="w-12 h-12 text-gray-400"
/>
<p className="text-sm text-gray-600
font-medium"
>
Click to select receipt image
</p>
<p className="text-xs text-gray-500">
PNG, JPG, JPEG (Max 5MB)
</p>
</div>
)}
</label>
{/* Upload Button */}
{selectedFile && (
<button
onClick={handleConfirmPayment}
disabled={uploading}
className="w-full px-6 py-4
bg-indigo-600 text-white
rounded-lg hover:bg-indigo-700
transition-colors font-semibold
text-lg disabled:bg-gray-400
disabled:cursor-not-allowed
flex items-center justify-center
gap-2"
>
{uploading ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
/>
Processing...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
Confirm Payment
</>
)}
</button>
)}
</div>
</div>
</>
) : (
<div className="bg-green-50 border
border-green-200 rounded-lg p-8
text-center"
>
<CheckCircle
className="w-16 h-16 text-green-600
mx-auto mb-4"
/>
<h3 className="text-2xl font-bold
text-green-900 mb-2"
>
Confirmation sent successfully!
</h3>
<p className="text-green-700 mb-4">
We will confirm your payment as soon as possible.
</p>
<p className="text-sm text-green-600">
Redirecting...
</p>
</div>
)}
</div>
</div>
);
};
export default PaymentConfirmationPage;

View File

@@ -0,0 +1,251 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
XCircle,
AlertCircle,
Home,
Receipt,
Loader2,
} from 'lucide-react';
const PaymentResultPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [countdown, setCountdown] = useState(10);
const status = searchParams.get('status');
const bookingId = searchParams.get('bookingId');
const transactionId = searchParams.get('transactionId');
const message = searchParams.get('message');
useEffect(() => {
if (status === 'success' && bookingId) {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
navigate(`/bookings/${bookingId}`);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [status, bookingId, navigate]);
const getStatusContent = () => {
switch (status) {
case 'success':
return {
icon: <CheckCircle className="w-20 h-20 text-green-500" />,
title: 'Payment Successful!',
description:
'Thank you for your payment. Your booking has been confirmed.',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-800',
};
case 'failed':
return {
icon: <XCircle className="w-20 h-20 text-red-500" />,
title: 'Payment Failed',
description: message ||
'Transaction was not successful. Please try again.',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
textColor: 'text-red-800',
};
case 'invalid_signature':
return {
icon: <AlertCircle className="w-20 h-20 text-orange-500" />,
title: 'Authentication Error',
description:
'Unable to verify transaction. Please contact support.',
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
textColor: 'text-orange-800',
};
case 'payment_not_found':
return {
icon: <AlertCircle className="w-20 h-20 text-gray-500" />,
title: 'Payment Not Found',
description:
'Payment information not found. ' +
'Please check again.',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
textColor: 'text-gray-800',
};
default:
return {
icon: <AlertCircle className="w-20 h-20 text-gray-500" />,
title: 'Unknown Error',
description: message ||
'An error occurred. Please try again later.',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
textColor: 'text-gray-800',
};
}
};
const content = getStatusContent();
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-2xl mx-auto px-4">
<div
className={`${content.bgColor} border-2
${content.borderColor} rounded-lg p-8`}
>
{/* Icon */}
<div className="flex justify-center mb-6">
{content.icon}
</div>
{/* Title */}
<h1
className={`text-3xl font-bold text-center
mb-4 ${content.textColor}`}
>
{content.title}
</h1>
{/* Description */}
<p className="text-center text-gray-700 mb-6">
{content.description}
</p>
{/* Transaction Details */}
{status === 'success' && transactionId && (
<div
className="bg-white border border-gray-200
rounded-lg p-4 mb-6"
>
<div className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">
Transaction ID
</span>
<span className="font-medium font-mono">
{transactionId}
</span>
</div>
{bookingId && (
<div className="flex justify-between">
<span className="text-gray-600">
Booking ID
</span>
<span className="font-medium">
#{bookingId}
</span>
</div>
)}
</div>
</div>
)}
{/* Auto redirect notice for success */}
{status === 'success' && bookingId && countdown > 0 && (
<div className="text-center mb-6">
<div className="flex items-center justify-center gap-2
text-gray-600"
>
<Loader2 className="w-4 h-4 animate-spin" />
<span>
Auto redirecting to booking details in {countdown}s...
</span>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3">
{status === 'success' && bookingId ? (
<>
<Link
to={`/bookings/${bookingId}`}
className="flex-1 flex items-center justify-center
gap-2 px-6 py-3 bg-green-600 text-white
rounded-lg hover:bg-green-700
transition-colors font-medium"
>
<Receipt className="w-5 h-5" />
View booking details
</Link>
<Link
to="/"
className="flex-1 flex items-center justify-center
gap-2 px-6 py-3 bg-white text-gray-700
border-2 border-gray-300 rounded-lg
hover:bg-gray-50 transition-colors
font-medium"
>
<Home className="w-5 h-5" />
Go to home
</Link>
</>
) : status === 'failed' && bookingId ? (
<>
<Link
to={`/deposit-payment/${bookingId}`}
className="flex-1 px-6 py-3 bg-indigo-600
text-white rounded-lg hover:bg-indigo-700
transition-colors font-medium text-center"
>
Retry payment
</Link>
<Link
to="/bookings"
className="flex-1 px-6 py-3 bg-white
text-gray-700 border-2 border-gray-300
rounded-lg hover:bg-gray-50
transition-colors font-medium text-center"
>
Booking list
</Link>
</>
) : (
<Link
to="/"
className="w-full flex items-center
justify-center gap-2 px-6 py-3
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-medium"
>
<Home className="w-5 h-5" />
Go to home
</Link>
)}
</div>
</div>
{/* Support Notice */}
<div className="mt-6 text-center text-sm text-gray-600">
<p>
If you have any issues, please contact{' '}
<a
href="mailto:support@hotel.com"
className="text-indigo-600 hover:underline"
>
support@hotel.com
</a>{' '}
or call{' '}
<a
href="tel:1900xxxx"
className="text-indigo-600 hover:underline"
>
1900 xxxx
</a>
</p>
</div>
</div>
</div>
);
};
export default PaymentResultPage;

View File

@@ -0,0 +1,281 @@
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('de-DE', {
style: 'currency',
currency: 'EUR',
}).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;

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect } 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 } 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 [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 10,
totalPages: 1,
});
// 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-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"
>
<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">
Room List
</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<aside className="lg:col-span-1">
<RoomFilter />
</aside>
<main className="lg:col-span-3">
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-3 gap-6"
>
{Array.from({ length: 6 }).map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
)}
{error && !loading && (
<div className="bg-red-50 border border-red-200
rounded-lg p-6 text-center"
>
<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>
<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"
>
Try Again
</button>
</div>
)}
{!loading && !error && rooms.length === 0 && (
<div className="bg-white rounded-lg shadow-md
p-12 text-center"
>
<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"
>
No matching rooms found
</h3>
<p className="text-gray-600 mb-6">
Please try adjusting the filters or search differently
</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"
>
Clear Filters
</button>
</div>
)}
{!loading && !error && rooms.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-2 gap-6"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</>
)}
</main>
</div>
</div>
</div>
);
};
export default RoomListPage;

View File

@@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import {
useSearchParams,
useNavigate,
Link
} from 'react-router-dom';
import {
Search,
Calendar,
AlertCircle,
ArrowLeft,
Home,
Users,
} from 'lucide-react';
import {
RoomCard,
RoomCardSkeleton,
Pagination,
} from '../../components/rooms';
import { searchAvailableRooms } from
'../../services/api/roomService';
import type { Room } from '../../services/api/roomService';
import { toast } from 'react-toastify';
const SearchResultsPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 12,
totalPages: 1,
});
// Get search params
const from = searchParams.get('from') || '';
const to = searchParams.get('to') || '';
const type = searchParams.get('type') || '';
const capacityParam = searchParams.get('capacity') || '';
const capacity = capacityParam ? Number(capacityParam) : undefined;
const pageParam = searchParams.get('page') || '';
const page = pageParam ? Number(pageParam) : 1;
useEffect(() => {
// Validate required params
if (!from || !to) {
toast.error(
'Missing search information. ' +
'Please select check-in and check-out dates.'
);
navigate('/');
return;
}
fetchAvailableRooms();
}, [from, to, type, capacity, page]);
const fetchAvailableRooms = async () => {
try {
setLoading(true);
setError(null);
const response = await searchAvailableRooms({
from,
to,
type: type || undefined,
capacity: capacity || undefined,
page,
limit: 12,
});
if (
response.success ||
response.status === 'success'
) {
setRooms(response.data.rooms || []);
if (response.data.pagination) {
setPagination(response.data.pagination);
} else {
// Fallback compute
const total = response.data.rooms
? response.data.rooms.length
: 0;
const limit = 12;
setPagination({
total,
page,
limit,
totalPages: Math.max(1, Math.ceil(total / limit)),
});
}
} else {
throw new Error('Unable to search rooms');
}
} catch (err: any) {
console.error('Error searching rooms:', err);
const message =
err.response?.data?.message ||
'Unable to search available rooms';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
return (
<div className="min-h-screen bg-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"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to home</span>
</Link>
{/* Search Info Header */}
<div
className="bg-white rounded-lg shadow-sm
p-6 mb-8"
>
<div className="flex items-start justify-between
flex-wrap gap-4"
>
<div>
<h1
className="text-3xl font-bold
text-gray-900 mb-4"
>
Search Results
</h1>
<div
className="flex flex-wrap items-center
gap-4 text-gray-700"
>
<div
className="flex items-center gap-2"
>
<Calendar className="w-5 h-5
text-indigo-600"
/>
<span>
<strong>Check-in:</strong>{' '}
{formatDate(from)}
</span>
</div>
<div
className="flex items-center gap-2"
>
<Calendar className="w-5 h-5
text-indigo-600"
/>
<span>
<strong>Check-out:</strong>{' '}
{formatDate(to)}
</span>
</div>
{type && (
<div
className="flex items-center gap-2"
>
<Home className="w-5 h-5
text-indigo-600"
/>
<span>
<strong>Room Type:</strong>{' '}
{type}
</span>
</div>
)}
{capacity && (
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-600" />
<span>
<strong>Guests:</strong>{' '}
{capacity}
</span>
</div>
)}
</div>
</div>
<button
onClick={() => navigate('/')}
className="px-4 py-2 border border-gray-300
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 disabled:bg-gray-400
transition-colors flex items-center gap-2"
>
<Search className="w-4 h-4" />
New Search
</button>
</div>
</div>
{/* Loading State */}
{loading && (
<div>
<p
className="text-gray-600 mb-6
text-center animate-pulse"
>
Searching for available rooms...
</p>
<div
className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-6"
>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
</div>
)}
{/* Error State */}
{error && !loading && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
/>
<p className="text-red-700 font-medium mb-4">
{error}
</p>
<button
onClick={fetchAvailableRooms}
className="px-6 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
>
Try Again
</button>
</div>
)}
{/* Results */}
{!loading && !error && (
<>
{rooms.length > 0 ? (
<>
<div
className="flex items-center
justify-between mb-6"
>
</div>
<div
className="grid grid-cols-1
md:grid-cols-2 lg:grid-cols-3
gap-6"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</>
) : (
// Empty State
<div
className="bg-white rounded-lg
shadow-sm p-12 text-center"
>
<div
className="w-24 h-24 bg-gray-100
rounded-full flex items-center
justify-center mx-auto mb-6"
>
<Search
className="w-12 h-12 text-gray-400"
/>
</div>
<h3
className="text-2xl font-bold
text-gray-900 mb-3"
>
No matching rooms found
</h3>
<p
className="text-gray-600 mb-6
max-w-md mx-auto"
>
Sorry, there are no available rooms
for the selected dates.
Please try searching with different dates
or room types.
</p>
<div
className="flex flex-col sm:flex-row
gap-3 justify-center"
>
<button
onClick={() => navigate('/')}
className="px-6 py-3 bg-indigo-600
text-white rounded-lg
hover:bg-indigo-700
transition-colors font-semibold
inline-flex items-center
justify-center gap-2"
>
<Search className="w-5 h-5" />
Search Again
</button>
<Link
to="/rooms"
className="px-6 py-3 border
border-gray-300 text-gray-700
rounded-lg hover:bg-gray-50
transition-colors font-semibold
inline-flex items-center
justify-center gap-2"
>
View All Rooms
</Link>
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
export default SearchResultsPage;