This commit is contained in:
Iliyan Angelov
2025-11-21 19:44:42 +02:00
parent 2a105c1170
commit 9842cc3a4a
24 changed files with 2801 additions and 2654 deletions

View File

@@ -21,8 +21,10 @@ class RegisterRequest(BaseModel):
@validator('phone')
def validate_phone(cls, v):
if v and (not v.isdigit()) or (v and len(v) not in [10, 11]):
raise ValueError('Phone must be 10-11 digits')
if v:
cleaned = ''.join(c for c in v if c.isdigit())
if len(cleaned) < 5:
raise ValueError('Phone number must contain at least 5 digits')
return v
class LoginRequest(BaseModel):

View File

@@ -42,10 +42,8 @@ const RoomDetailPage = lazy(() => import('./pages/customer/RoomDetailPage'));
const SearchResultsPage = lazy(() => import('./pages/customer/SearchResultsPage'));
const FavoritesPage = lazy(() => import('./pages/customer/FavoritesPage'));
const MyBookingsPage = lazy(() => import('./pages/customer/MyBookingsPage'));
const BookingPage = lazy(() => import('./pages/customer/BookingPage'));
const BookingSuccessPage = lazy(() => import('./pages/customer/BookingSuccessPage') as Promise<{ default: React.ComponentType<any> }>);
const BookingDetailPage = lazy(() => import('./pages/customer/BookingDetailPage'));
const DepositPaymentPage = lazy(() => import('./pages/customer/DepositPaymentPage'));
const FullPaymentPage = lazy(() => import('./pages/customer/FullPaymentPage'));
const PaymentConfirmationPage = lazy(() => import('./pages/customer/PaymentConfirmationPage'));
const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'));
@@ -234,14 +232,6 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="booking/:id"
element={
<CustomerRoute>
<BookingPage />
</CustomerRoute>
}
/>
<Route
path="booking-success/:id"
element={
@@ -250,14 +240,6 @@ function App() {
</CustomerRoute>
}
/>
<Route
path="payment/deposit/:bookingId"
element={
<CustomerRoute>
<DepositPaymentPage />
</CustomerRoute>
}
/>
<Route
path="bookings"
element={

View File

@@ -0,0 +1,232 @@
import React, { useState } from 'react';
import { X, AlertTriangle, XCircle, Loader2, Info } from 'lucide-react';
import { cancelBooking, type Booking } from '../../services/api/bookingService';
import { toast } from 'react-toastify';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface CancelBookingModalProps {
isOpen: boolean;
booking: Booking | null;
onClose: () => void;
onSuccess: () => void;
}
const CancelBookingModal: React.FC<CancelBookingModalProps> = ({
isOpen,
booking,
onClose,
onSuccess,
}) => {
const { formatCurrency } = useFormatCurrency();
const [cancelling, setCancelling] = useState(false);
if (!isOpen || !booking) return null;
// Check if booking is fully paid
const isFullyPaid = (() => {
// Check payment_status first
if (booking.payment_status === 'paid') {
return true;
}
// Check payment_balance
if (booking.payment_balance?.is_fully_paid === true) {
return true;
}
// Check payments array - sum all completed payments
if (booking.payments && Array.isArray(booking.payments)) {
const totalPaid = booking.payments
.filter((p: any) => p.payment_status === 'completed')
.reduce((sum: number, p: any) => sum + parseFloat(p.amount?.toString() || '0'), 0);
return totalPaid >= booking.total_price - 0.01; // Allow small rounding differences
}
return false;
})();
const cancellationFee = booking.total_price * 0.2;
const refundAmount = isFullyPaid ? booking.total_price - cancellationFee : 0;
const handleCancel = async () => {
try {
setCancelling(true);
const response = await cancelBooking(booking.id);
if (response.success || (response as any).status === 'success') {
toast.error(
`Booking ${booking.booking_number} has been cancelled`
);
onSuccess();
onClose();
} else {
throw new Error(response.message || 'Unable to cancel booking');
}
} catch (err: any) {
console.error('Error cancelling booking:', err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
err.message ||
'Unable to cancel booking. Please try again.';
toast.error(message);
} finally {
setCancelling(false);
}
};
return (
<div className="fixed inset-0 z-[10001] flex items-center justify-center p-2 sm:p-4">
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md max-h-[95vh] bg-gradient-to-br from-[#0a0a0a] via-[#1a1a1a] to-[#0a0a0a] rounded-xl sm:rounded-2xl border border-red-500/30 shadow-2xl shadow-red-500/20 overflow-hidden flex flex-col">
{/* Header */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-red-500/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-red-500/20 rounded-full flex items-center justify-center border border-red-500/30 flex-shrink-0">
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 md:w-6 md:h-6 text-red-400" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-base sm:text-lg md:text-xl font-serif font-bold text-white tracking-tight truncate">
Cancel Booking
</h2>
<p className="text-xs text-gray-400 mt-0.5 truncate">
Booking #{booking.booking_number}
</p>
</div>
</div>
<button
onClick={onClose}
disabled={cancelling}
className="p-1.5 sm:p-2 hover:bg-red-500/10 rounded-lg transition-colors text-gray-400 hover:text-white disabled:opacity-50 flex-shrink-0"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6 py-4 sm:py-6 space-y-4 sm:space-y-6">
{/* Warning Message */}
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs sm:text-sm font-semibold text-red-300 mb-1">
Are you sure you want to cancel this booking?
</p>
<p className="text-xs text-red-200/80 leading-relaxed">
This action cannot be undone. The room will be made available for other guests.
</p>
</div>
</div>
</div>
{/* Booking Details */}
<div className="bg-[#0a0a0a] border border-gray-700/50 rounded-lg p-3 sm:p-4 space-y-2 sm:space-y-3">
<h3 className="text-xs sm:text-sm font-semibold text-gray-300 mb-2 sm:mb-3">Booking Details</h3>
<div className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Room</span>
<span className="text-white font-medium">
{booking.room?.room_number || 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Check-in</span>
<span className="text-white font-medium">
{new Date(booking.check_in_date).toLocaleDateString('en-US')}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Check-out</span>
<span className="text-white font-medium">
{new Date(booking.check_out_date).toLocaleDateString('en-US')}
</span>
</div>
<div className="flex justify-between pt-2 border-t border-gray-700/50">
<span className="text-gray-400">Total Amount</span>
<span className="text-white font-semibold">
{formatCurrency(booking.total_price)}
</span>
</div>
</div>
</div>
{/* Cancellation Policy */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<Info className="w-4 h-4 sm:w-5 sm:h-5 text-amber-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h4 className="text-xs sm:text-sm font-semibold text-amber-300 mb-1.5 sm:mb-2">
Cancellation Policy
</h4>
<div className="space-y-1.5 sm:space-y-2 text-xs text-amber-200/80">
<div className="flex justify-between">
<span>Cancellation Fee (20%)</span>
<span className="font-semibold text-amber-300">
-{formatCurrency(cancellationFee)}
</span>
</div>
{isFullyPaid && (
<>
<div className="flex justify-between pt-2 border-t border-amber-500/20">
<span>Refund Amount (80%)</span>
<span className="font-semibold text-green-400">
{formatCurrency(refundAmount)}
</span>
</div>
<p className="text-amber-200/70 mt-3 pt-3 border-t border-amber-500/20">
The refund will be processed to your original payment method within 5-7 business days.
</p>
</>
)}
{!isFullyPaid && (
<p className="text-amber-200/70 mt-3 pt-3 border-t border-amber-500/20">
Since this booking is not fully paid, no refund will be issued. The cancellation fee applies to the total booking amount.
</p>
)}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 sm:gap-3 pt-2">
<button
onClick={handleCancel}
disabled={cancelling}
className="w-full bg-red-500/20 hover:bg-red-500/30 border-2 border-red-500/50 text-red-300 font-semibold py-2.5 sm:py-3 px-4 sm:px-6 rounded-lg transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed text-sm sm:text-base min-h-[44px]"
>
{cancelling ? (
<>
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin" />
<span>Cancelling...</span>
</>
) : (
<>
<XCircle className="w-4 h-4 sm:w-5 sm:h-5" />
<span>Yes, Cancel Booking</span>
</>
)}
</button>
<button
onClick={onClose}
disabled={cancelling}
className="w-full bg-transparent border border-gray-600 text-gray-300 font-medium py-2.5 sm:py-3 px-4 sm:px-6 rounded-lg hover:bg-gray-800/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm sm:text-base min-h-[44px]"
>
Keep Booking
</button>
</div>
</div>
</div>
</div>
);
};
export default CancelBookingModal;

View File

@@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { X, Building2, Save } from 'lucide-react';
interface InvoiceInfoModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (invoiceInfo: any) => void;
}
interface InvoiceFormData {
company_name: string;
company_address: string;
company_tax_id: string;
customer_tax_id: string;
}
const InvoiceInfoModal: React.FC<InvoiceInfoModalProps> = ({
isOpen,
onClose,
onSave,
}) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<InvoiceFormData>({
defaultValues: {
company_name: '',
company_address: '',
company_tax_id: '',
customer_tax_id: '',
},
});
const onSubmit = (data: InvoiceFormData) => {
onSave(data);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[10001] flex items-center justify-center p-2 sm:p-4">
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-lg max-h-[95vh] bg-gradient-to-br from-[#0a0a0a] via-[#1a1a1a] to-[#0a0a0a] rounded-xl sm:rounded-2xl border border-[#d4af37]/30 shadow-2xl shadow-[#d4af37]/20 overflow-hidden flex flex-col">
{/* Header */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-[#d4af37]/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Building2 className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37] flex-shrink-0" />
<h2 className="text-base sm:text-lg md:text-xl font-serif font-bold text-white tracking-tight truncate">
Invoice Information
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 sm:p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors text-gray-400 hover:text-white flex-shrink-0"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
{/* Content */}
<form onSubmit={handleSubmit(onSubmit)} className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6 flex flex-col">
<div className="space-y-3 sm:space-y-4 flex-1">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2">
Company Name
</label>
<input
{...register('company_name')}
type="text"
className="w-full px-3 py-2 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white text-sm focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]"
placeholder="Company Name"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2">
Company Address
</label>
<textarea
{...register('company_address')}
rows={3}
className="w-full px-3 py-2 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white text-sm focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] resize-none"
placeholder="Company Address"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2">
Company Tax ID
</label>
<input
{...register('company_tax_id')}
type="text"
className="w-full px-3 py-2 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white text-sm focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]"
placeholder="Tax ID"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2">
Customer Tax ID
</label>
<input
{...register('customer_tax_id')}
type="text"
className="w-full px-3 py-2 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white text-sm focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]"
placeholder="Tax ID"
/>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 sm:mt-6 flex items-center justify-end gap-2 sm:gap-3 pt-3 sm:pt-4 border-t border-[#d4af37]/20 flex-shrink-0">
<button
type="button"
onClick={onClose}
className="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-300 hover:text-white transition-colors min-h-[44px]"
>
Cancel
</button>
<button
type="submit"
className="px-4 sm:px-6 py-2 bg-[#d4af37] text-black font-semibold rounded-lg hover:bg-[#c9a227] transition-all flex items-center gap-1.5 sm:gap-2 text-sm sm:text-base min-h-[44px]"
>
<Save className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
);
};
export default InvoiceInfoModal;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { X, CheckCircle, CreditCard } from 'lucide-react';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import DepositPaymentModal from './DepositPaymentModal';
interface CashPaymentModalProps {
isOpen: boolean;
bookingId: number;
amount: number;
depositAmount: number;
onSuccess: () => void;
onClose: () => void;
}
const CashPaymentModal: React.FC<CashPaymentModalProps> = ({
isOpen,
bookingId,
amount,
depositAmount,
onSuccess,
onClose,
}) => {
const { formatCurrency } = useFormatCurrency();
const [showDepositModal, setShowDepositModal] = useState(false);
if (!isOpen) return null;
const handlePayDeposit = () => {
setShowDepositModal(true);
};
const handleDepositSuccess = () => {
setShowDepositModal(false);
onSuccess();
onClose();
};
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-2 sm:p-4">
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md max-h-[95vh] bg-gradient-to-br from-[#0a0a0a] via-[#1a1a1a] to-[#0a0a0a] rounded-xl sm:rounded-2xl border border-[#d4af37]/30 shadow-2xl shadow-[#d4af37]/20 overflow-hidden flex flex-col">
{/* Header */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-[#d4af37]/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base sm:text-lg md:text-xl font-serif font-bold text-white tracking-tight truncate flex-1">
Booking Created
</h2>
<button
onClick={onClose}
className="p-1.5 sm:p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors text-gray-400 hover:text-white flex-shrink-0"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
<div className="space-y-4 sm:space-y-6">
<div className="flex flex-col items-center text-center">
<div className="w-12 h-12 sm:w-14 sm:h-14 md:w-16 md:h-16 bg-green-500/20 rounded-full flex items-center justify-center border border-green-500/30 mb-3 sm:mb-4">
<CheckCircle className="w-6 h-6 sm:w-8 sm:h-8 md:w-10 md:h-10 text-green-400" />
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-white mb-1.5 sm:mb-2">
Booking Confirmed!
</h3>
<p className="text-xs sm:text-sm text-gray-400 leading-relaxed px-2">
Your booking has been created successfully. Please pay the deposit to secure your reservation.
</p>
</div>
<div className="bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg p-3 sm:p-4 space-y-2 sm:space-y-3">
<div className="flex items-center justify-between text-xs sm:text-sm">
<span className="text-gray-400">Total Amount</span>
<span className="text-white font-semibold">{formatCurrency(amount)}</span>
</div>
<div className="flex items-center justify-between text-xs sm:text-sm">
<span className="text-gray-400 break-words pr-2">Deposit Required (20%)</span>
<span className="text-[#d4af37] font-bold flex-shrink-0">{formatCurrency(depositAmount)}</span>
</div>
<div className="pt-2 sm:pt-3 border-t border-[#d4af37]/20">
<p className="text-xs text-gray-400 leading-relaxed">
Pay the remaining balance on arrival at the hotel.
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:gap-3 pt-2">
<button
onClick={handlePayDeposit}
className="w-full bg-[#d4af37] text-black font-semibold py-2.5 sm:py-3 px-4 sm:px-6 rounded-lg hover:bg-[#c9a227] transition-all flex items-center justify-center gap-2 text-sm sm:text-base min-h-[44px]"
>
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5" />
<span>Pay Deposit Now</span>
</button>
<button
onClick={onClose}
className="w-full bg-transparent border border-gray-600 text-gray-300 font-medium py-2.5 sm:py-3 px-4 sm:px-6 rounded-lg hover:bg-gray-800/50 transition-all text-sm sm:text-base min-h-[44px]"
>
Close
</button>
</div>
</div>
</div>
</div>
{/* Deposit Payment Modal */}
{showDepositModal && (
<DepositPaymentModal
isOpen={showDepositModal}
bookingId={bookingId}
onSuccess={handleDepositSuccess}
onClose={() => setShowDepositModal(false)}
/>
)}
</div>
);
};
export default CashPaymentModal;

View File

@@ -0,0 +1,417 @@
import React, { useState, useEffect } from 'react';
import {
X,
CheckCircle,
AlertCircle,
CreditCard,
XCircle,
Loader2,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, type Booking } from '../../services/api/bookingService';
import CancelBookingModal from '../booking/CancelBookingModal';
import {
getPaymentsByBookingId,
type Payment,
} from '../../services/api/paymentService';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import StripePaymentModal from './StripePaymentModal';
import PayPalPaymentModal from './PayPalPaymentModal';
interface DepositPaymentModalProps {
isOpen: boolean;
bookingId: number;
onSuccess: () => void;
onClose: () => void;
}
const DepositPaymentModal: React.FC<DepositPaymentModalProps> = ({
isOpen,
bookingId,
onSuccess,
onClose,
}) => {
const { formatCurrency, currency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(null);
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paymentSuccess, setPaymentSuccess] = useState(false);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<'stripe' | 'paypal' | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
useEffect(() => {
if (isOpen && bookingId) {
fetchData(bookingId);
}
}, [isOpen, bookingId]);
useEffect(() => {
if (!isOpen) {
setBooking(null);
setDepositPayment(null);
setPaymentSuccess(false);
setSelectedPaymentMethod(null);
setShowPaymentModal(false);
setError(null);
}
}, [isOpen]);
const fetchData = async (id: number) => {
try {
setLoading(true);
setError(null);
const bookingResponse = await getBookingById(id);
if (!bookingResponse.success || !bookingResponse.data?.booking) {
throw new Error('Booking not found');
}
const bookingData = bookingResponse.data.booking;
setBooking(bookingData);
// Close modal if booking is cancelled
if (bookingData.status === 'cancelled') {
toast.info('This booking has been cancelled');
onClose();
return;
}
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
toast.success('Booking is already confirmed!');
onSuccess();
onClose();
return;
}
if (!bookingData.requires_deposit) {
toast.info('This booking does not require a deposit');
onSuccess();
onClose();
return;
}
const paymentsResponse = await getPaymentsByBookingId(id);
if (paymentsResponse.success) {
const deposit = paymentsResponse.data.payments.find(
(p) => p.payment_type === 'deposit'
);
if (deposit) {
setDepositPayment(deposit);
if (deposit.payment_status === 'completed') {
setPaymentSuccess(true);
}
}
}
} 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 handleCancelBookingClick = () => {
setShowCancelModal(true);
};
const handleCancelSuccess = () => {
setShowCancelModal(false);
onClose();
onSuccess();
};
const handlePaymentMethodSelect = (method: 'stripe' | 'paypal') => {
setSelectedPaymentMethod(method);
setShowPaymentModal(true);
};
const handlePaymentSuccess = async () => {
setShowPaymentModal(false);
setPaymentSuccess(true);
setSelectedPaymentMethod(null);
// Check booking status before showing success toast
try {
const bookingResponse = await getBookingById(bookingId);
if (bookingResponse.success && bookingResponse.data?.booking) {
const bookingData = bookingResponse.data.booking;
// Only show success toast if booking is not cancelled
if (bookingData.status !== 'cancelled') {
toast.success('✅ Deposit payment successful! Your booking has been confirmed.');
}
}
} catch (err) {
// If we can't check status, don't show toast to avoid confusion
console.error('Error checking booking status:', err);
}
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
};
const handlePaymentClose = () => {
setShowPaymentModal(false);
setSelectedPaymentMethod(null);
// When payment is canceled, close the deposit modal entirely
// User can reopen it from booking details if needed
onClose();
};
if (!isOpen) return null;
const depositAmount = depositPayment ? parseFloat(depositPayment.amount.toString()) : 0;
const remainingAmount = booking ? booking.total_price - depositAmount : 0;
const isDepositPaid = depositPayment?.payment_status === 'completed';
return (
<>
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-2 sm:p-4">
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-3xl max-h-[95vh] bg-gradient-to-br from-[#0a0a0a] via-[#1a1a1a] to-[#0a0a0a] rounded-xl sm:rounded-2xl border border-[#d4af37]/30 shadow-2xl shadow-[#d4af37]/20 overflow-hidden flex flex-col">
{/* Header */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-[#d4af37]/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<h2 className="text-base sm:text-lg md:text-xl font-serif font-bold text-white tracking-tight truncate">
Deposit Payment
</h2>
{booking && (
<p className="text-xs text-gray-400 mt-0.5 truncate">
Booking #{booking.booking_number}
</p>
)}
</div>
<div className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
{!isDepositPaid && booking && (
<button
onClick={handleCancelBookingClick}
className="px-2 sm:px-3 py-1 sm:py-1.5 text-xs bg-red-500/10 border border-red-500/30 text-red-300 rounded-lg hover:bg-red-500/20 transition-colors flex items-center gap-1 sm:gap-1.5 min-h-[32px] sm:min-h-[36px]"
>
<XCircle className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="hidden xs:inline">Cancel Booking</span>
<span className="xs:hidden">Cancel</span>
</button>
)}
<button
onClick={onClose}
className="p-1.5 sm:p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors text-gray-400 hover:text-white flex-shrink-0"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6 py-4 sm:py-6">
{loading ? (
<div className="flex flex-col items-center justify-center py-8 sm:py-12">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 animate-spin text-[#d4af37] mb-3 sm:mb-4" />
<p className="text-xs sm:text-sm text-gray-400">Loading payment information...</p>
</div>
) : error || !booking || !depositPayment ? (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 sm:p-6 text-center">
<AlertCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400 mx-auto mb-3 sm:mb-4" />
<p className="text-xs sm:text-sm text-red-300 font-medium mb-3 sm:mb-4">
{error || 'Payment information not found'}
</p>
<button
onClick={onClose}
className="px-4 py-2 sm:py-2.5 bg-[#d4af37] text-black font-semibold rounded-lg hover:bg-[#c9a227] transition-all text-sm sm:text-base min-h-[44px]"
>
Close
</button>
</div>
) : (
<div className="space-y-4 sm:space-y-6">
{/* Success Message */}
{isDepositPaid && (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-green-500/20 rounded-full flex items-center justify-center border border-green-500/30 flex-shrink-0">
<CheckCircle className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-green-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-1">
Deposit Payment Successful!
</h3>
<p className="text-xs sm:text-sm text-green-200/80 leading-relaxed">
Your booking has been confirmed. Remaining amount to be paid on arrival at the hotel.
</p>
{depositPayment.payment_date && (
<p className="text-xs text-green-300/70 mt-2">
Paid on: {new Date(depositPayment.payment_date).toLocaleString('en-US')}
</p>
)}
</div>
</div>
</div>
)}
{/* Payment Required Message */}
{!isDepositPaid && (
<div className="bg-[#d4af37]/10 border border-[#d4af37]/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-[#d4af37]/20 rounded-full flex items-center justify-center border border-[#d4af37]/30 flex-shrink-0">
<CreditCard className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[#d4af37]" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-serif font-semibold text-[#d4af37] mb-1">
Deposit Payment Required
</h3>
<p className="text-xs sm:text-sm text-gray-300 leading-relaxed">
Please pay <strong className="text-[#d4af37]">20% deposit</strong> to confirm your booking. Pay the remaining balance on arrival at the hotel.
</p>
</div>
</div>
</div>
)}
{/* Payment Information */}
<div className="bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg p-3 sm:p-4">
<h3 className="text-xs sm:text-sm font-semibold text-[#d4af37] mb-2 sm:mb-3">Payment Information</h3>
<div className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
<div className="flex justify-between text-gray-300">
<span>Total Room Price</span>
<span>{formatCurrency(booking.total_price)}</span>
</div>
<div className="flex justify-between text-base sm:text-lg font-bold text-[#d4af37] pt-2 border-t border-[#d4af37]/20">
<span className="break-words pr-2">Deposit Amount (20%)</span>
<span className="flex-shrink-0">{formatCurrency(depositAmount)}</span>
</div>
<div className="flex justify-between text-xs text-gray-400 pt-1">
<span>Remaining at check-in</span>
<span>{formatCurrency(remainingAmount)}</span>
</div>
</div>
</div>
{/* Payment Method Selection */}
{!isDepositPaid && !selectedPaymentMethod && (
<div className="bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg p-3 sm:p-4">
<h3 className="text-xs sm:text-sm font-semibold text-[#d4af37] mb-2 sm:mb-3">Choose Payment Method</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
<button
onClick={() => handlePaymentMethodSelect('stripe')}
className="p-3 sm:p-4 bg-[#1a1a1a] border-2 border-gray-600/30 rounded-lg hover:border-[#d4af37]/50 transition-all text-left group min-h-[80px] sm:min-h-[100px]"
>
<div className="flex items-center justify-between mb-1.5 sm:mb-2">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-indigo-500/20 rounded-lg flex items-center justify-center border border-indigo-500/30 flex-shrink-0">
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-400 group-hover:text-[#d4af37] transition-colors" />
</div>
<span className="text-xs font-semibold text-indigo-400 group-hover:text-[#d4af37] transition-colors">
Card Payment
</span>
</div>
<p className="text-xs text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed">
Pay with credit or debit card via Stripe
</p>
</button>
<button
onClick={() => handlePaymentMethodSelect('paypal')}
className="p-3 sm:p-4 bg-[#1a1a1a] border-2 border-gray-600/30 rounded-lg hover:border-[#d4af37]/50 transition-all text-left group min-h-[80px] sm:min-h-[100px]"
>
<div className="flex items-center justify-between mb-1.5 sm:mb-2">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-500/20 rounded-lg flex items-center justify-center border border-blue-500/30 flex-shrink-0">
<svg
className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400 group-hover:text-[#d4af37] transition-colors"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
</svg>
</div>
<span className="text-xs font-semibold text-blue-400 group-hover:text-[#d4af37] transition-colors">
PayPal
</span>
</div>
<p className="text-xs text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed">
Pay securely with your PayPal account
</p>
</button>
</div>
</div>
)}
{/* Change Payment Method */}
{!isDepositPaid && selectedPaymentMethod && (
<div className="bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg p-3 sm:p-4">
<div className="flex items-center justify-between gap-2 mb-2 sm:mb-3">
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-semibold text-[#d4af37]">
{selectedPaymentMethod === 'stripe' ? 'Card Payment' : 'PayPal Payment'}
</h3>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1">
Pay deposit of {formatCurrency(depositAmount)}
</p>
</div>
<button
onClick={() => {
setSelectedPaymentMethod(null);
setShowPaymentModal(false);
}}
className="text-xs text-gray-400 hover:text-[#d4af37] underline transition-colors flex-shrink-0 whitespace-nowrap"
>
Change method
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Payment Modals */}
{showPaymentModal && booking && depositPayment && selectedPaymentMethod && (
<>
{selectedPaymentMethod === 'stripe' && (
<StripePaymentModal
isOpen={showPaymentModal}
bookingId={booking.id}
amount={depositAmount}
currency={currency || 'USD'}
onSuccess={handlePaymentSuccess}
onClose={handlePaymentClose}
/>
)}
{selectedPaymentMethod === 'paypal' && (
<PayPalPaymentModal
isOpen={showPaymentModal}
bookingId={booking.id}
amount={depositAmount}
currency={currency || 'USD'}
onSuccess={handlePaymentSuccess}
onClose={handlePaymentClose}
/>
)}
</>
)}
{/* Cancel Booking Modal */}
{showCancelModal && booking && (
<CancelBookingModal
isOpen={showCancelModal}
booking={booking}
onClose={() => setShowCancelModal(false)}
onSuccess={handleCancelSuccess}
/>
)}
</>
);
};
export default DepositPaymentModal;

View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import { createPayPalOrder } from '../../services/api/paymentService';
import { X, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface PayPalPaymentModalProps {
isOpen: boolean;
bookingId: number;
amount: number;
currency?: string;
onSuccess: () => void;
onClose: () => void;
}
const PayPalPaymentModal: React.FC<PayPalPaymentModalProps> = ({
isOpen,
bookingId,
amount,
currency: propCurrency,
onSuccess,
onClose,
}) => {
const { currency: contextCurrency } = useFormatCurrency();
const currency = propCurrency || contextCurrency || 'USD';
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
const initializePayPal = async () => {
try {
setLoading(true);
setError(null);
const currentUrl = window.location.origin;
const returnUrl = `${currentUrl}/payment/paypal/return?bookingId=${bookingId}`;
const cancelUrl = `${currentUrl}/payment/paypal/cancel?bookingId=${bookingId}`;
const response = await createPayPalOrder(
bookingId,
amount,
currency,
returnUrl,
cancelUrl
);
if (response.success && response.data) {
const { approval_url } = response.data;
if (!approval_url) {
throw new Error('Approval URL not received from server');
}
setApprovalUrl(approval_url);
} else {
throw new Error(response.message || 'Failed to initialize PayPal payment');
}
} catch (err: any) {
console.error('Error initializing PayPal:', err);
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize PayPal payment';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
initializePayPal();
}, [isOpen, bookingId, amount, currency]);
const handlePayPalClick = () => {
if (approvalUrl) {
window.location.href = approvalUrl;
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-2 sm:p-4">
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md max-h-[95vh] bg-gradient-to-br from-[#0a0a0a] via-[#1a1a1a] to-[#0a0a0a] rounded-xl sm:rounded-2xl border border-[#d4af37]/30 shadow-2xl shadow-[#d4af37]/20 overflow-hidden flex flex-col">
{/* Header */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-[#d4af37]/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base sm:text-lg md:text-xl font-serif font-bold text-white tracking-tight truncate flex-1">
PayPal Payment
</h2>
<button
onClick={onClose}
className="p-1.5 sm:p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors text-gray-400 hover:text-white flex-shrink-0"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
{loading ? (
<div className="flex flex-col items-center justify-center py-8 sm:py-12">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 animate-spin text-[#d4af37] mb-3 sm:mb-4" />
<p className="text-xs sm:text-sm text-gray-400">Initializing PayPal payment...</p>
</div>
) : error ? (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-semibold text-red-300 mb-1">
Payment Initialization Failed
</h3>
<p className="text-xs text-red-200/80 leading-relaxed">
{error || 'Unable to initialize PayPal payment. Please try again.'}
</p>
</div>
</div>
</div>
) : approvalUrl ? (
<div className="space-y-4 sm:space-y-6">
<div className="text-center">
<div className="mb-3 sm:mb-4 flex justify-center">
<svg
className="h-10 sm:h-12 w-auto"
viewBox="0 0 283 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.7-9.2 12.2-9.2z"
fill="#003087"
/>
</svg>
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-white mb-1.5 sm:mb-2">
Complete Payment with PayPal
</h3>
<p className="text-xs sm:text-sm text-gray-400 mb-3 sm:mb-4 leading-relaxed px-2">
You will be redirected to PayPal to securely complete your payment of{' '}
<span className="font-semibold text-[#d4af37]">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount)}
</span>
</p>
</div>
<button
onClick={handlePayPalClick}
className="w-full bg-gradient-to-r from-[#0070ba] to-[#005ea6]
hover:from-[#0080cc] hover:to-[#0070ba] text-white
font-semibold py-2.5 sm:py-3 px-4 sm:px-6 rounded-lg transition-all duration-300
flex items-center justify-center gap-2 sm:gap-3 shadow-lg shadow-blue-500/30
hover:shadow-xl hover:shadow-blue-500/40 text-sm sm:text-base min-h-[44px]"
>
<svg
className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
</svg>
<span>Pay with PayPal</span>
</button>
<p className="text-xs text-gray-500 text-center">
Secure payment powered by PayPal
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 sm:py-12">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 animate-spin text-[#d4af37] mb-3 sm:mb-4" />
<p className="text-xs sm:text-sm text-gray-400">Loading PayPal...</p>
</div>
)}
</div>
</div>
</div>
);
};
export default PayPalPaymentModal;

View File

@@ -75,97 +75,88 @@ const StripePaymentForm: React.FC<StripePaymentFormProps> = ({
if (!stripe || !elements) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
<span className="ml-2 text-gray-600">Loading payment form...</span>
<Loader2 className="w-8 h-8 animate-spin text-[#d4af37]" />
<span className="ml-2 text-gray-300">Loading payment form...</span>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<CreditCard className="w-5 h-5 text-indigo-600" />
<h3 className="text-lg font-semibold text-gray-900">
Payment Details
</h3>
</div>
<div className="mb-4 p-4 bg-indigo-50 border border-indigo-200 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-indigo-900">
Amount to Pay
</span>
<span className="text-xl font-bold text-indigo-600">
${amount.toFixed(2)}
</span>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="mb-4 p-4 bg-[#d4af37]/10 border border-[#d4af37]/20 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-300">
Amount to Pay
</span>
<span className="text-xl font-bold text-[#d4af37]">
${amount.toFixed(2)}
</span>
</div>
</div>
<div className="mb-4">
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
<div className="mb-4">
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
{message && (
<div
className={`mb-4 p-4 rounded-lg flex items-start gap-3 ${
{message && (
<div
className={`mb-4 p-3 rounded-lg flex items-start gap-3 ${
message.includes('succeeded')
? 'bg-green-500/10 border border-green-500/30'
: message.includes('error') || message.includes('failed')
? 'bg-red-500/10 border border-red-500/30'
: 'bg-yellow-500/10 border border-yellow-500/30'
}`}
>
{message.includes('succeeded') ? (
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
) : message.includes('error') || message.includes('failed') ? (
<AlertCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
) : (
<Loader2 className="w-5 h-5 text-yellow-400 mt-0.5 animate-spin flex-shrink-0" />
)}
<p
className={`text-sm ${
message.includes('succeeded')
? 'bg-green-50 border border-green-200'
? 'text-green-300'
: message.includes('error') || message.includes('failed')
? 'bg-red-50 border border-red-200'
: 'bg-yellow-50 border border-yellow-200'
? 'text-red-300'
: 'text-yellow-300'
}`}
>
{message.includes('succeeded') ? (
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
) : message.includes('error') || message.includes('failed') ? (
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
) : (
<Loader2 className="w-5 h-5 text-yellow-600 mt-0.5 animate-spin" />
)}
<p
className={`text-sm ${
message.includes('succeeded')
? 'text-green-800'
: message.includes('error') || message.includes('failed')
? 'text-red-800'
: 'text-yellow-800'
}`}
>
{message}
</p>
</div>
{message}
</p>
</div>
)}
<button
type="submit"
disabled={isProcessing || !stripe || !elements}
className="w-full bg-[#d4af37] text-black py-3 px-4 rounded-lg
hover:bg-[#c9a227] transition-colors font-semibold
disabled:bg-gray-600 disabled:cursor-not-allowed disabled:text-gray-400
flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<CreditCard className="w-5 h-5" />
Pay ${amount.toFixed(2)}
</>
)}
</button>
<button
type="submit"
disabled={isProcessing || !stripe || !elements}
className="w-full bg-indigo-600 text-white py-3 px-4 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"
>
{isProcessing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<CreditCard className="w-5 h-5" />
Pay ${amount.toFixed(2)}
</>
)}
</button>
<p className="text-xs text-gray-500 mt-3 text-center">
Your payment is secure and encrypted
</p>
</div>
<p className="text-xs text-gray-500 text-center">
Your payment is secure and encrypted
</p>
</form>
);
};

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import StripePaymentForm from './StripePaymentForm';
import { createStripePaymentIntent, confirmStripePayment } from '../../services/api/paymentService';
import { X, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { toast } from 'react-toastify';
interface StripePaymentModalProps {
isOpen: boolean;
bookingId: number;
amount: number;
currency?: string;
onSuccess: () => void;
onClose: () => void;
}
const StripePaymentModal: React.FC<StripePaymentModalProps> = ({
isOpen,
bookingId,
amount,
currency = 'usd',
onSuccess,
onClose,
}) => {
const [stripePromise, setStripePromise] = useState<Promise<any> | null>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paymentCompleted, setPaymentCompleted] = useState(false);
useEffect(() => {
if (!isOpen || paymentCompleted) return;
const initializeStripe = async () => {
try {
setLoading(true);
setError(null);
const response = await createStripePaymentIntent(
bookingId,
amount,
currency
);
if (response.success && response.data) {
const { publishable_key, client_secret } = response.data;
if (!client_secret) {
throw new Error('Client secret not received from server');
}
if (!publishable_key) {
throw new Error('Publishable key not configured. Please configure Stripe settings in Admin Panel.');
}
setClientSecret(client_secret);
const stripePromise = loadStripe(publishable_key);
setStripePromise(stripePromise);
} else {
throw new Error(response.message || 'Failed to initialize payment');
}
} catch (err: any) {
console.error('Error initializing Stripe:', err);
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize payment';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
initializeStripe();
}, [isOpen, bookingId, amount, currency, paymentCompleted]);
const handlePaymentSuccess = async (paymentIntentId: string) => {
try {
setPaymentCompleted(true);
const response = await confirmStripePayment(paymentIntentId, bookingId);
if (response.success) {
toast.success('✅ Payment successful!');
onSuccess();
} else {
setPaymentCompleted(false);
throw new Error(response.message || 'Payment confirmation failed');
}
} catch (err: any) {
console.error('Error confirming payment:', err);
setPaymentCompleted(false);
const errorMessage = err.response?.data?.message || err.message || 'Payment confirmation failed';
setError(errorMessage);
toast.error(errorMessage);
}
};
const handlePaymentError = (errorMessage: string) => {
setError(errorMessage);
toast.error(errorMessage);
};
if (!isOpen) return null;
const options: StripeElementsOptions = {
clientSecret: clientSecret || undefined,
appearance: {
theme: 'night' as const,
variables: {
colorPrimary: '#d4af37',
colorBackground: '#0a0a0a',
colorText: '#ffffff',
colorDanger: '#ef4444',
fontFamily: 'system-ui, sans-serif',
spacingUnit: '4px',
borderRadius: '8px',
},
},
};
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-2 sm:p-4">
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md max-h-[95vh] bg-gradient-to-br from-[#0a0a0a] via-[#1a1a1a] to-[#0a0a0a] rounded-xl sm:rounded-2xl border border-[#d4af37]/30 shadow-2xl shadow-[#d4af37]/20 overflow-hidden flex flex-col">
{/* Header */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-[#d4af37]/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base sm:text-lg md:text-xl font-serif font-bold text-white tracking-tight truncate flex-1">
Complete Payment
</h2>
<button
onClick={onClose}
className="p-1.5 sm:p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors text-gray-400 hover:text-white flex-shrink-0"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
{loading ? (
<div className="flex flex-col items-center justify-center py-8 sm:py-12">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 animate-spin text-[#d4af37] mb-3 sm:mb-4" />
<p className="text-xs sm:text-sm text-gray-400">Initializing payment...</p>
</div>
) : error ? (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-semibold text-red-300 mb-1">
Payment Initialization Failed
</h3>
<p className="text-xs text-red-200/80 leading-relaxed">
{error || 'Unable to initialize payment. Please try again.'}
</p>
</div>
</div>
</div>
) : clientSecret && stripePromise ? (
<Elements stripe={stripePromise} options={options}>
<StripePaymentForm
clientSecret={clientSecret}
amount={amount}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
/>
</Elements>
) : (
<div className="flex flex-col items-center justify-center py-8 sm:py-12">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 animate-spin text-[#d4af37] mb-3 sm:mb-4" />
<p className="text-xs sm:text-sm text-gray-400">Loading payment form...</p>
</div>
)}
</div>
</div>
</div>
);
};
export default StripePaymentModal;

View File

@@ -26,7 +26,6 @@ import {
import { toast } from 'react-toastify';
import {
getBookingById,
cancelBooking,
type Booking,
} from '../../services/api/bookingService';
import useAuthStore from '../../store/useAuthStore';
@@ -36,6 +35,7 @@ import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import CancelBookingModal from '../../components/booking/CancelBookingModal';
const BookingDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -49,7 +49,7 @@ const BookingDetailPage: React.FC = () => {
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
useEffect(() => {
@@ -102,69 +102,19 @@ const BookingDetailPage: React.FC = () => {
}
};
const handleCancelBooking = async () => {
console.log('=== handleCancelBooking called ===');
const handleCancelBookingClick = () => {
if (!booking) {
console.error('No booking found');
toast.error('Booking not found');
return;
}
setShowCancelModal(true);
};
console.log('Cancel booking clicked', { bookingId: booking.id, status: booking.status });
const confirmed = window.confirm(
`⚠️ CANCEL BOOKING CONFIRMATION ⚠️\n\n` +
`Booking Number: ${booking.booking_number}\n\n` +
`Are you sure you want to cancel this booking?\n\n` +
`IMPORTANT NOTES:\n` +
`• You will be charged 20% cancellation fee\n` +
`• The remaining 80% will be refunded\n` +
`• This action cannot be undone\n\n` +
`Click OK to confirm cancellation\n` +
`Click Cancel to keep your booking`
);
if (!confirmed) {
console.log('User chose to keep the booking');
toast.info('Booking cancellation cancelled. Your booking remains active.');
return;
}
console.log('User confirmed cancellation, proceeding...');
try {
setCancelling(true);
console.log('Calling cancelBooking API...', booking.id);
const response = await cancelBooking(booking.id);
console.log('Cancel booking response:', response);
if (response.success || (response as any).status === 'success') {
toast.success(
`✅ Booking ${booking.booking_number} cancelled successfully!`
);
await fetchBookingDetails(booking.id);
} else {
throw new Error(
response.message ||
'Unable to cancel booking'
);
}
} catch (err: any) {
console.error('Error cancelling booking:', err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
err.message ||
'Unable to cancel booking. Please try again.';
toast.error(message);
} finally {
setCancelling(false);
const handleCancelSuccess = async () => {
if (booking) {
await fetchBookingDetails(booking.id);
}
setShowCancelModal(false);
};
const formatDate = (dateString: string) => {
@@ -828,38 +778,20 @@ const BookingDetailPage: React.FC = () => {
<button
type="button"
onClick={(e) => {
console.log('=== BUTTON CLICKED ===', {
bookingId: booking.id,
status: booking.status,
cancelling
});
e.stopPropagation();
handleCancelBooking();
handleCancelBookingClick();
}}
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 active:bg-red-800
transition-colors
font-semibold disabled:bg-gray-400
disabled:cursor-not-allowed cursor-pointer
font-semibold cursor-pointer
relative z-10"
aria-label="Cancel booking"
>
{cancelling ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
/>
Cancelling...
</>
) : (
<>
<XCircle className="w-5 h-5" />
Cancel Booking
</>
)}
<XCircle className="w-5 h-5" />
Cancel Booking
</button>
) : (
<div className="flex-1 text-center text-sm text-gray-500 py-3">
@@ -868,13 +800,6 @@ const BookingDetailPage: React.FC = () => {
<small className="text-xs">(Only pending bookings can be cancelled)</small>
</div>
)}
{}
{import.meta.env.DEV && (
<div className="text-xs text-gray-400 mt-2 p-2 bg-gray-100 rounded">
Debug: Status={booking.status}, CanCancel={canCancelBooking(booking) ? 'true' : 'false'}, Cancelling={cancelling ? 'true' : 'false'}
</div>
)}
<Link
to="/bookings"
@@ -888,6 +813,16 @@ const BookingDetailPage: React.FC = () => {
</Link>
</div>
</div>
{/* Cancel Booking Modal */}
{showCancelModal && booking && (
<CancelBookingModal
isOpen={showCancelModal}
booking={booking}
onClose={() => setShowCancelModal(false)}
onSuccess={handleCancelSuccess}
/>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import { confirmBankTransfer } from
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import DepositPaymentModal from '../../components/payments/DepositPaymentModal';
const BookingSuccessPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -54,6 +55,7 @@ const BookingSuccessPage: React.FC = () => {
useState<File | null>(null);
const [previewUrl, setPreviewUrl] =
useState<string | null>(null);
const [showDepositModal, setShowDepositModal] = useState(false);
useEffect(() => {
if (id) {
@@ -91,12 +93,13 @@ const BookingSuccessPage: React.FC = () => {
}
// Only auto-open deposit modal if booking is not cancelled
if (
bookingData.requires_deposit &&
!bookingData.deposit_paid
!bookingData.deposit_paid &&
bookingData.status !== 'cancelled'
) {
navigate(`/deposit-payment/${bookingId}`, { replace: true });
return;
setShowDepositModal(true);
}
} else {
throw new Error(
@@ -301,6 +304,51 @@ const BookingSuccessPage: React.FC = () => {
const room = booking.room;
const roomType = room?.room_type;
// Check if payment is completed
const isPaymentCompleted = (() => {
// Check if booking is cancelled
if (booking.status === 'cancelled') {
return false;
}
// Check payment_status
if (booking.payment_status === 'paid') {
return true;
}
// Check payment_balance
if (booking.payment_balance?.is_fully_paid === true) {
return true;
}
// For deposit bookings, check if deposit is paid
if (booking.requires_deposit && booking.deposit_paid === true) {
return true;
}
// Check payments array
if (booking.payments && Array.isArray(booking.payments)) {
const totalPaid = booking.payments
.filter((p: any) => p.payment_status === 'completed')
.reduce((sum: number, p: any) => sum + parseFloat(p.amount?.toString() || '0'), 0);
// For deposit bookings, check if deposit is paid
if (booking.requires_deposit) {
const depositPayment = booking.payments.find((p: any) => p.payment_type === 'deposit' && p.payment_status === 'completed');
if (depositPayment) {
return true;
}
} else {
// For full payment bookings, check if fully paid
return totalPaid >= booking.total_price - 0.01;
}
}
return false;
})();
const isCancelled = booking.status === 'cancelled';
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
@@ -309,24 +357,70 @@ const BookingSuccessPage: React.FC = () => {
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>
{isCancelled ? (
<>
<div
className="w-20 h-20 bg-red-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<AlertCircle
className="w-12 h-12 text-red-600"
/>
</div>
<h1
className="text-3xl font-bold text-gray-900
mb-2"
>
Booking Cancelled
</h1>
<p className="text-gray-600 mb-4">
This booking has been cancelled
</p>
</>
) : isPaymentCompleted ? (
<>
<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>
</>
) : (
<>
<div
className="w-20 h-20 bg-yellow-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<AlertCircle
className="w-12 h-12 text-yellow-600"
/>
</div>
<h1
className="text-3xl font-bold text-gray-900
mb-2"
>
Booking Pending Payment
</h1>
<p className="text-gray-600 mb-4">
Please complete your payment to confirm your booking
</p>
</>
)}
{}
<div
@@ -681,19 +775,25 @@ const BookingSuccessPage: React.FC = () => {
<input
id="receipt-upload"
type="file"
accept="image}
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
}}
className="hidden"
/>
<span className="text-sm text-gray-600">
Click to upload receipt
</span>
</label>
</div>
{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"
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 ? (
<>
@@ -800,6 +900,21 @@ const BookingSuccessPage: React.FC = () => {
</Link>
</div>
</div>
{/* Deposit Payment Modal */}
{showDepositModal && id && (
<DepositPaymentModal
isOpen={showDepositModal}
bookingId={Number(id)}
onClose={() => setShowDepositModal(false)}
onSuccess={() => {
setShowDepositModal(false);
if (id) {
fetchBookingDetails(Number(id));
}
}}
/>
)}
</div>
);
};

View File

@@ -1,505 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
AlertCircle,
CreditCard,
ArrowLeft,
XCircle,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, cancelBooking, type Booking } from
'../../services/api/bookingService';
import {
getPaymentsByBookingId,
type Payment,
} from '../../services/api/paymentService';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import StripePaymentWrapper from '../../components/payments/StripePaymentWrapper';
import PayPalPaymentWrapper from '../../components/payments/PayPalPaymentWrapper';
const DepositPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate();
const { formatCurrency, currency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(null);
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paymentSuccess, setPaymentSuccess] = useState(false);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<'stripe' | 'paypal' | null>(null);
const [cancelling, setCancelling] = useState(false);
useEffect(() => {
if (bookingId) {
fetchData(Number(bookingId));
}
}, [bookingId]);
const fetchData = async (id: number) => {
try {
setLoading(true);
setError(null);
const bookingResponse = await getBookingById(id);
if (!bookingResponse.success || !bookingResponse.data?.booking) {
throw new Error('Booking not found');
}
const bookingData = bookingResponse.data.booking;
setBooking(bookingData);
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
toast.success('Booking is already confirmed!');
navigate(`/bookings/${id}`);
return;
}
if (!bookingData.requires_deposit) {
toast.info('This booking does not require a deposit');
navigate(`/bookings/${id}`);
return;
}
const paymentsResponse = await getPaymentsByBookingId(id);
if (paymentsResponse.success) {
const deposit = paymentsResponse.data.payments.find(
(p) => p.payment_type === 'deposit'
);
if (deposit) {
setDepositPayment(deposit);
}
}
} 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) => formatCurrency(price);
const handleCancelBooking = async () => {
if (!booking) return;
const confirmed = window.confirm(
`Are you sure you want to cancel this booking?\n\n` +
`Booking Number: ${booking.booking_number}\n\n` +
`⚠️ Note: This will cancel your booking and free up the room.`
);
if (!confirmed) return;
try {
setCancelling(true);
const response = await cancelBooking(booking.id);
if (response.success) {
toast.success(
`✅ Booking ${booking.booking_number} has been cancelled successfully!`
);
setTimeout(() => {
navigate('/bookings');
}, 1500);
} 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);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
<Loading fullScreen text="Loading payment information..." />
</div>
);
}
if (error || !booking || !depositPayment) {
return (
<div
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8 sm:py-12 w-screen relative -mt-6 -mb-6"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)',
width: '100vw',
zIndex: 1
}}
>
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8">
<div
className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-6 sm:p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10"
>
<AlertCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400 mx-auto mb-4" />
<p className="text-red-300 font-light text-base sm:text-lg mb-6 tracking-wide px-2">
{error || 'Payment information not found'}
</p>
<Link
to="/bookings"
className="inline-flex items-center gap-2 bg-gradient-to-r
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
px-4 py-2 sm:px-6 sm:py-3 rounded-sm hover:from-[#f5d76e]
hover:to-[#d4af37] transition-all duration-300
font-medium tracking-wide shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
>
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
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-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8 sm:py-12 w-screen relative -mt-6 -mb-6"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)',
width: '100vw',
zIndex: 1
}}
>
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
{}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 mb-3 sm:mb-4">
<Link
to={`/bookings/${bookingId}`}
className="inline-flex items-center gap-1
text-[#d4af37]/80 hover:text-[#d4af37]
transition-colors font-light tracking-wide text-xs sm:text-sm"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back to booking details</span>
</Link>
{}
{!isDepositPaid && booking && (
<button
onClick={handleCancelBooking}
disabled={cancelling}
className="inline-flex items-center gap-1
bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 text-red-300
px-2.5 py-1 rounded-sm
hover:border-red-400/50 hover:bg-gradient-to-br
hover:from-red-800/30 hover:to-red-700/20
transition-all duration-300 font-light tracking-wide text-xs sm:text-sm
backdrop-blur-sm shadow-sm shadow-red-500/10
hover:shadow-md hover:shadow-red-500/20
disabled:opacity-50 disabled:cursor-not-allowed
w-full sm:w-auto"
>
<XCircle className="w-3.5 h-3.5" />
{cancelling ? 'Cancelling...' : 'Cancel Booking'}
</button>
)}
</div>
{}
{isDepositPaid && (
<div
className="bg-gradient-to-br from-green-900/20 to-green-800/10
border border-green-500/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
backdrop-blur-xl shadow-lg shadow-green-500/10"
>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
<div
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-green-500/20 to-green-600/20
rounded-full flex items-center justify-center flex-shrink-0
border border-green-500/30 shadow-sm shadow-green-500/20"
>
<CheckCircle className="w-6 h-6 sm:w-7 sm:h-7 text-green-400" />
</div>
<div className="flex-1 text-center sm:text-left">
<h1 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-1 tracking-wide">
Deposit Payment Successful!
</h1>
<p className="text-green-200/80 font-light text-xs sm:text-sm tracking-wide">
Your booking has been confirmed.
Remaining amount to be paid on arrival at the hotel.
</p>
</div>
</div>
</div>
)}
{}
{!isDepositPaid && (
<div
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
border border-[#d4af37]/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
backdrop-blur-xl shadow-lg shadow-[#d4af37]/10"
>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
<div
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-full flex items-center justify-center flex-shrink-0
border border-[#d4af37]/30 shadow-sm shadow-[#d4af37]/20"
>
<CreditCard className="w-6 h-6 sm:w-7 sm:h-7 text-[#d4af37]" />
</div>
<div className="flex-1 text-center sm:text-left">
<h1 className="text-base sm:text-lg font-elegant font-semibold text-[#d4af37] mb-1 tracking-wide">
Deposit Payment Required
</h1>
<p className="text-gray-300/80 font-light text-xs sm:text-sm tracking-wide">
Please pay <strong className="text-[#d4af37] font-medium">20% deposit</strong> to confirm your booking. Pay the remaining balance on arrival at the hotel.
</p>
</div>
</div>
</div>
)}
<div className="w-full">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
{}
<div className="lg:col-span-2 space-y-3">
{}
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-gray-700/50 rounded-lg p-3 sm:p-4
backdrop-blur-xl shadow-lg shadow-black/20">
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-2.5 sm:mb-3 tracking-wide">
Payment Information
</h2>
<div className="space-y-2">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-0 py-1.5 border-b border-gray-700/30">
<span className="text-gray-300 font-light tracking-wide text-xs sm:text-sm">Total Room Price</span>
<span className="font-medium text-gray-100 text-xs sm:text-sm">
{formatPrice(booking.total_price)}
</span>
</div>
<div
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-0 py-2
border-t-2 border-[#d4af37]/30 pt-2"
>
<span className="font-medium text-[#d4af37] text-xs sm:text-sm tracking-wide">
Deposit Amount to Pay (20%)
</span>
<span className="text-base sm:text-lg font-bold text-[#d4af37]">
{formatPrice(depositAmount)}
</span>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between gap-0.5 text-[10px] sm:text-xs text-gray-400/80 font-light pt-1">
<span>Remaining amount to be paid at check-in</span>
<span className="text-gray-300">{formatPrice(remainingAmount)}</span>
</div>
</div>
{isDepositPaid && (
<div className="mt-4 sm:mt-6 bg-gradient-to-br from-green-900/20 to-green-800/10
border border-green-500/30 rounded-lg p-3 sm:p-4 backdrop-blur-sm">
<p className="text-xs sm:text-sm text-green-300 font-light break-words">
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-400/70 mt-2 font-mono break-all">
Transaction ID: {depositPayment.transaction_id}
</p>
)}
</div>
)}
</div>
{}
{!isDepositPaid && !selectedPaymentMethod && (
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-gray-700/50 rounded-lg p-3 sm:p-4
backdrop-blur-xl shadow-lg shadow-black/20">
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-1.5 tracking-wide">
Choose Payment Method
</h2>
<p className="text-gray-300/80 font-light mb-3 sm:mb-4 tracking-wide text-xs sm:text-sm">
Please select how you would like to pay the deposit:
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 sm:gap-3">
{}
<button
onClick={() => setSelectedPaymentMethod('stripe')}
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
border-2 border-gray-600/30 rounded-lg p-3
hover:border-[#d4af37]/50 hover:bg-gradient-to-br
hover:from-[#d4af37]/10 hover:to-[#c9a227]/5
transition-all duration-300 text-left group
backdrop-blur-sm shadow-sm shadow-black/10
hover:shadow-md hover:shadow-[#d4af37]/20"
>
<div className="flex items-center justify-between mb-2">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-indigo-500/20 to-indigo-600/20
rounded-lg flex items-center justify-center
border border-indigo-500/30 group-hover:border-[#d4af37]/50
transition-colors">
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-400 group-hover:text-[#d4af37] transition-colors" />
</div>
<span className="text-[10px] sm:text-xs font-semibold text-indigo-400 group-hover:text-[#d4af37]
transition-colors tracking-wide">Card Payment</span>
</div>
<p className="text-[10px] sm:text-xs text-gray-300/70 font-light group-hover:text-gray-200 transition-colors">
Pay with credit or debit card via Stripe
</p>
</button>
{}
<button
onClick={() => setSelectedPaymentMethod('paypal')}
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
border-2 border-gray-600/30 rounded-lg p-3
hover:border-[#d4af37]/50 hover:bg-gradient-to-br
hover:from-[#d4af37]/10 hover:to-[#c9a227]/5
transition-all duration-300 text-left group
backdrop-blur-sm shadow-sm shadow-black/10
hover:shadow-md hover:shadow-[#d4af37]/20"
>
<div className="flex items-center justify-between mb-2">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500/20 to-blue-600/20
rounded-lg flex items-center justify-center
border border-blue-500/30 group-hover:border-[#d4af37]/50
transition-colors">
<svg
className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400 group-hover:text-[#d4af37] transition-colors"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
</svg>
</div>
<span className="text-[10px] sm:text-xs font-semibold text-blue-400 group-hover:text-[#d4af37]
transition-colors tracking-wide">PayPal</span>
</div>
<p className="text-[10px] sm:text-xs text-gray-300/70 font-light group-hover:text-gray-200 transition-colors">
Pay securely with your PayPal account
</p>
</button>
</div>
</div>
)}
{}
{!isDepositPaid && selectedPaymentMethod && (
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-gray-700/50 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
backdrop-blur-xl shadow-lg shadow-black/10">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
<div>
<h3 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-0.5 tracking-wide">
{selectedPaymentMethod === 'stripe' ? 'Card Payment' : 'PayPal Payment'}
</h3>
<p className="text-xs sm:text-sm text-gray-300/80 font-light tracking-wide">
Pay deposit of <span className="text-[#d4af37] font-medium">{formatPrice(depositAmount)}</span>
</p>
</div>
<button
onClick={() => setSelectedPaymentMethod(null)}
className="text-[10px] sm:text-xs text-gray-400 hover:text-[#d4af37]
underline transition-colors font-light tracking-wide self-start sm:self-auto"
>
Change method
</button>
</div>
</div>
)}
{}
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'stripe' && (
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-gray-700/50 rounded-lg p-3 sm:p-4
backdrop-blur-xl shadow-lg shadow-black/20">
{paymentSuccess ? (
<div className="bg-gradient-to-br from-green-900/20 to-green-800/10
border border-green-500/30 rounded-lg p-4 sm:p-5 text-center
backdrop-blur-sm">
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400 mx-auto mb-3" />
<h3 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-2 tracking-wide">
Payment Successful!
</h3>
<p className="text-green-200/80 mb-4 font-light tracking-wide text-xs sm:text-sm">
Your deposit payment has been confirmed.
</p>
<button
onClick={() => navigate(`/bookings/${booking.id}`)}
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-4 py-1.5 sm:px-5 sm:py-2 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium tracking-wide
shadow-sm shadow-[#d4af37]/30 text-xs sm:text-sm"
>
View Booking
</button>
</div>
) : (
<StripePaymentWrapper
bookingId={booking.id}
amount={depositAmount}
onSuccess={() => {
setPaymentSuccess(true);
toast.success('✅ Payment successful! Your booking has been confirmed.');
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
}}
onError={(error) => {
toast.error(error || 'Payment failed');
}}
/>
)}
</div>
)}
{}
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'paypal' && (
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-gray-700/50 rounded-lg p-3 sm:p-4
backdrop-blur-xl shadow-lg shadow-black/20">
<PayPalPaymentWrapper
bookingId={booking.id}
amount={depositAmount}
currency={currency || 'USD'}
onError={(error) => {
toast.error(error || 'Payment failed');
}}
/>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default DepositPaymentPage;

View File

@@ -19,9 +19,9 @@ import {
import { toast } from 'react-toastify';
import {
getMyBookings,
cancelBooking,
type Booking,
} from '../../services/api/bookingService';
import CancelBookingModal from '../../components/booking/CancelBookingModal';
import useAuthStore from '../../store/useAuthStore';
import { useAuthModal } from '../../contexts/AuthModalContext';
import Loading from '../../components/common/Loading';
@@ -40,8 +40,8 @@ const MyBookingsPage: React.FC = () => {
useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancellingId, setCancellingId] =
useState<number | null>(null);
const [showCancelModal, setShowCancelModal] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] =
useState<string>('all');
@@ -119,53 +119,23 @@ const MyBookingsPage: React.FC = () => {
}
};
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"`
);
const handleCancelBookingClick = (booking: Booking) => {
setSelectedBooking(booking);
setShowCancelModal(true);
};
if (!confirmed) return;
try {
setCancellingId(bookingId);
const response = await cancelBooking(bookingId);
if (response.success) {
toast.success(
`✅ Successfully cancelled booking ${bookingNumber}!`
);
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 handleCancelSuccess = () => {
if (selectedBooking) {
setBookings((prev) =>
prev.map((b) =>
b.id === selectedBooking.id
? { ...b, status: 'cancelled' }
: b
)
);
}
setShowCancelModal(false);
setSelectedBooking(null);
};
const formatDate = (dateString: string) => {
@@ -619,38 +589,19 @@ const MyBookingsPage: React.FC = () => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCancelBooking(
booking.id,
booking.booking_number
);
handleCancelBookingClick(booking);
}}
disabled={
cancellingId === booking.id
}
className="inline-flex
items-center gap-2 px-4 py-2
bg-red-600 text-white
rounded-lg hover:bg-red-700
transition-colors font-medium
text-sm disabled:bg-gray-400
disabled:cursor-not-allowed cursor-pointer"
text-sm cursor-pointer"
>
{cancellingId === booking.id ? (
<>
<Loader2
className="w-4 h-4
animate-spin"
/>
Cancelling...
</>
) : (
<>
<XCircle
className="w-4 h-4"
/>
Cancel booking
</>
)}
<XCircle
className="w-4 h-4"
/>
Cancel booking
</button>
)}
</div>
@@ -663,6 +614,19 @@ const MyBookingsPage: React.FC = () => {
</div>
)}
</div>
{/* Cancel Booking Modal */}
{showCancelModal && selectedBooking && (
<CancelBookingModal
isOpen={showCancelModal}
booking={selectedBooking}
onClose={() => {
setShowCancelModal(false);
setSelectedBooking(null);
}}
onSuccess={handleCancelSuccess}
/>
)}
</div>
);
};

View File

@@ -70,7 +70,7 @@ const PayPalCancelPage: React.FC = () => {
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
{bookingId && (
<button
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
onClick={() => navigate(`/bookings/${bookingId}`)}
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
@@ -80,7 +80,7 @@ const PayPalCancelPage: React.FC = () => {
disabled={cancelling}
>
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
Try Again
View Booking Details
</button>
)}
<button

View File

@@ -125,16 +125,18 @@ const PayPalReturnPage: React.FC = () => {
{error || 'Unable to process your payment. Please try again.'}
</p>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<button
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium tracking-wide
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
>
Retry Payment
</button>
{bookingId && (
<button
onClick={() => navigate(`/bookings/${bookingId}`)}
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium tracking-wide
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
>
View Booking
</button>
)}
<button
onClick={() => navigate('/bookings')}
className="flex-1 bg-gradient-to-br from-gray-800/40 to-gray-700/20

View File

@@ -197,12 +197,12 @@ const PaymentResultPage: React.FC = () => {
) : status === 'failed' && bookingId ? (
<>
<Link
to={`/deposit-payment/${bookingId}`}
to={`/bookings/${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
View Booking
</Link>
<Link
to="/bookings"

View File

@@ -42,8 +42,8 @@ const profileValidationSchema = yup.object().shape({
.string()
.required('Phone number is required')
.matches(
/^[0-9]{10,11}$/,
'Phone number must have 10-11 digits'
/^[\d\s\-\+\(\)]{5,}$/,
'Please enter a valid phone number'
),
});

View File

@@ -10,7 +10,7 @@ import {
Award,
Sparkles,
} from 'lucide-react';
import { getRoomByNumber, type Room } from
import { getRoomByNumber, getRoomBookedDates, type Room } from
'../../services/api/roomService';
import RoomGallery from '../../components/rooms/RoomGallery';
import RoomAmenities from '../../components/rooms/RoomAmenities';
@@ -18,15 +18,22 @@ import ReviewSection from '../../components/rooms/ReviewSection';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import useAuthStore from '../../store/useAuthStore';
import LuxuryBookingModal from '../../components/booking/LuxuryBookingModal';
import { useAuthModal } from '../../contexts/AuthModalContext';
import { toast } from 'react-toastify';
const RoomDetailPage: React.FC = () => {
const { room_number } = useParams<{ room_number: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const { userInfo } = useAuthStore();
const { userInfo, isAuthenticated } = useAuthStore();
const { openModal } = useAuthModal();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showBookingModal, setShowBookingModal] = useState(false);
const [nextAvailableDate, setNextAvailableDate] = useState<Date | null>(null);
const [bookedUntilDate, setBookedUntilDate] = useState<Date | null>(null);
useEffect(() => {
if (room_number) {
@@ -52,6 +59,11 @@ const RoomDetailPage: React.FC = () => {
}
setRoom(fetchedRoom);
// Fetch booked dates to calculate availability
if (fetchedRoom.id) {
await fetchBookedDates(fetchedRoom.id);
}
} else {
throw new Error('Failed to fetch room details');
}
@@ -70,6 +82,65 @@ const RoomDetailPage: React.FC = () => {
}
};
const fetchBookedDates = async (roomId: number) => {
try {
const response = await getRoomBookedDates(roomId);
if (response.success && response.data?.booked_dates) {
const bookedDates = response.data.booked_dates.map((date: string) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
});
const today = new Date();
today.setHours(0, 0, 0, 0);
// Sort booked dates
const sortedBookedDates = bookedDates.sort((a: Date, b: Date) => a.getTime() - b.getTime());
// Find if today is booked
const todayBooked = sortedBookedDates.some((date: Date) => {
return date.getTime() === today.getTime();
});
if (todayBooked) {
// Find consecutive booked dates starting from today
let lastConsecutiveDate = new Date(today);
for (let i = 0; i < sortedBookedDates.length; i++) {
const bookedDate = sortedBookedDates[i];
// Check if this date is consecutive from the last found date
const daysDiff = Math.floor((bookedDate.getTime() - lastConsecutiveDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 0 || daysDiff === 1) {
// This date is part of the consecutive booking
lastConsecutiveDate = new Date(bookedDate);
} else if (daysDiff > 1) {
// Gap found, stop looking
break;
}
}
// Set booked until date (check-out date is the last booked date)
setBookedUntilDate(lastConsecutiveDate);
// Next available date is the day after the last booked date
const nextAvailable = new Date(lastConsecutiveDate);
nextAvailable.setDate(nextAvailable.getDate() + 1);
setNextAvailableDate(nextAvailable);
} else {
// Today is not booked, so it's available now
setNextAvailableDate(today);
setBookedUntilDate(null);
}
}
} catch (err: any) {
console.error('Error fetching booked dates:', err);
// Don't show error to user, just don't show availability info
}
};
if (loading) {
return (
<div
@@ -191,23 +262,35 @@ const RoomDetailPage: React.FC = () => {
Featured
</div>
)}
<div
className={`px-2 py-0.5 rounded-sm
text-[10px] sm:text-xs font-medium tracking-wide
backdrop-blur-sm shadow-sm
${
room.status === 'available'
? 'bg-green-500/90 text-white border border-green-400/50'
: room.status === 'occupied'
? 'bg-red-500/90 text-white border border-red-400/50'
: 'bg-gray-500/90 text-white border border-gray-400/50'
}`}
>
{room.status === 'available'
? 'Available Now'
: room.status === 'occupied'
? 'Booked'
: 'Maintenance'}
<div className="flex flex-col gap-1">
<div
className={`px-2 py-0.5 rounded-sm
text-[10px] sm:text-xs font-medium tracking-wide
backdrop-blur-sm shadow-sm
${
room.status === 'available'
? 'bg-green-500/90 text-white border border-green-400/50'
: room.status === 'occupied'
? 'bg-red-500/90 text-white border border-red-400/50'
: 'bg-gray-500/90 text-white border border-gray-400/50'
}`}
>
{room.status === 'available'
? 'Available Now'
: room.status === 'occupied'
? 'Booked'
: 'Maintenance'}
</div>
{room.status === 'occupied' && bookedUntilDate && (
<p className="text-[9px] sm:text-[10px] text-gray-300 font-light leading-tight">
Booked until {bookedUntilDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</p>
)}
{room.status === 'occupied' && nextAvailableDate && (
<p className="text-[9px] sm:text-[10px] text-[#d4af37] font-light leading-tight">
Available from {nextAvailableDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</p>
)}
</div>
</div>
@@ -371,8 +454,20 @@ const RoomDetailPage: React.FC = () => {
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && (
<>
<div className="mb-3">
<Link
to={`/booking/${room.id}`}
<button
onClick={() => {
if (room.status !== 'available') {
toast.error('This room is not available for booking');
return;
}
if (!isAuthenticated) {
toast.error('Please login to make a booking');
openModal('login');
return;
}
setShowBookingModal(true);
}}
disabled={room.status !== 'available'}
className={`block w-full py-2 text-center
font-medium rounded-sm transition-all duration-300
tracking-wide relative overflow-hidden group text-xs sm:text-sm
@@ -381,9 +476,6 @@ const RoomDetailPage: React.FC = () => {
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-sm shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50'
: 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
}`}
onClick={(e) => {
if (room.status !== 'available') e.preventDefault();
}}
>
<span className="relative z-10 flex items-center justify-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
@@ -392,7 +484,7 @@ const RoomDetailPage: React.FC = () => {
{room.status === 'available' && (
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
)}
</Link>
</button>
</div>
{room.status === 'available' && (
@@ -449,6 +541,19 @@ const RoomDetailPage: React.FC = () => {
<ReviewSection roomId={room.id} />
</div>
</div>
{/* Booking Modal */}
{room && (
<LuxuryBookingModal
roomId={room.id}
isOpen={showBookingModal}
onClose={() => setShowBookingModal(false)}
onSuccess={(bookingId) => {
setShowBookingModal(false);
navigate(`/booking-success/${bookingId}`);
}}
/>
)}
</div>
);
};

View File

@@ -44,8 +44,8 @@ export const registerSchema = yup.object().shape({
.string()
.optional()
.matches(
/^[0-9]{10,11}$/,
'Invalid phone number'
/^[\d\s\-\+\(\)]{5,}$/,
'Please enter a valid phone number'
),
});

View File

@@ -55,8 +55,8 @@ export const bookingValidationSchema = yup.object().shape({
.string()
.required('Please enter phone number')
.matches(
/^[0-9]{10,11}$/,
'Phone number must have 10-11 digits'
/^[\d\s\-\+\(\)]{5,}$/,
'Please enter a valid phone number'
),
});