updates
This commit is contained in:
@@ -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={
|
||||
|
||||
232
Frontend/src/components/booking/CancelBookingModal.tsx
Normal file
232
Frontend/src/components/booking/CancelBookingModal.tsx
Normal 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;
|
||||
|
||||
145
Frontend/src/components/booking/InvoiceInfoModal.tsx
Normal file
145
Frontend/src/components/booking/InvoiceInfoModal.tsx
Normal 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;
|
||||
|
||||
1081
Frontend/src/components/booking/LuxuryBookingModal.tsx
Normal file
1081
Frontend/src/components/booking/LuxuryBookingModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
124
Frontend/src/components/payments/CashPaymentModal.tsx
Normal file
124
Frontend/src/components/payments/CashPaymentModal.tsx
Normal 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;
|
||||
|
||||
417
Frontend/src/components/payments/DepositPaymentModal.tsx
Normal file
417
Frontend/src/components/payments/DepositPaymentModal.tsx
Normal 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;
|
||||
|
||||
188
Frontend/src/components/payments/PayPalPaymentModal.tsx
Normal file
188
Frontend/src/components/payments/PayPalPaymentModal.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
185
Frontend/src/components/payments/StripePaymentModal.tsx
Normal file
185
Frontend/src/components/payments/StripePaymentModal.tsx
Normal 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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user