+
{reviews.map((review) => (
{review.user?.full_name || 'Guest'}
-
{formatDate(review.created_at)}
-
+
{review.comment}
diff --git a/Frontend/src/pages/ContactPage.tsx b/Frontend/src/pages/ContactPage.tsx
index bb80a4cc..32668000 100644
--- a/Frontend/src/pages/ContactPage.tsx
+++ b/Frontend/src/pages/ContactPage.tsx
@@ -5,6 +5,8 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import { toast } from 'react-toastify';
+import Recaptcha from '../components/common/Recaptcha';
+import { recaptchaService } from '../services/api/systemSettingsService';
const ContactPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -18,6 +20,7 @@ const ContactPage: React.FC = () => {
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState
>({});
+ const [recaptchaToken, setRecaptchaToken] = useState(null);
const validateForm = (): boolean => {
const newErrors: Record = {};
@@ -53,6 +56,22 @@ const ContactPage: React.FC = () => {
return;
}
+ // Verify reCAPTCHA if enabled
+ if (recaptchaToken) {
+ try {
+ const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
+ if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ } catch (error) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ }
+
setLoading(true);
try {
await submitContactForm(formData);
@@ -67,9 +86,11 @@ const ContactPage: React.FC = () => {
message: '',
});
setErrors({});
+ setRecaptchaToken(null);
} catch (error: any) {
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
toast.error(errorMessage);
+ setRecaptchaToken(null);
} finally {
setLoading(false);
}
@@ -400,6 +421,20 @@ const ContactPage: React.FC = () => {
)}
+ {/* reCAPTCHA */}
+
+ setRecaptchaToken(token)}
+ onError={(error) => {
+ console.error('reCAPTCHA error:', error);
+ setRecaptchaToken(null);
+ }}
+ theme="dark"
+ size="normal"
+ className="flex justify-center"
+ />
+
+
{/* Submit Button */}
{
-
- {formatCurrency(booking.total_price)}
-
+ {(() => {
+ const completedPayments = booking.payments?.filter(
+ (p) => p.payment_status === 'completed'
+ ) || [];
+ const amountPaid = completedPayments.reduce(
+ (sum, p) => sum + (p.amount || 0),
+ 0
+ );
+ const remainingDue = booking.total_price - amountPaid;
+ const hasPayments = completedPayments.length > 0;
+
+ return (
+
+
+ {formatCurrency(booking.total_price)}
+
+ {hasPayments && (
+
+
+ Paid: {formatCurrency(amountPaid)}
+
+ {remainingDue > 0 && (
+
+ Due: {formatCurrency(remainingDue)}
+
+ )}
+
+ )}
+
+ );
+ })()}
{getStatusBadge(booking.status)}
@@ -369,12 +397,219 @@ const BookingManagementPage: React.FC = () => {
{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}
- {/* Total Price - Highlighted */}
-
-
Total Price
-
- {formatCurrency(selectedBooking.total_price)}
-
+ {/* Payment Method & Status */}
+
+
+
+ Payment Information
+
+
+
+
Payment Method
+
+ {selectedBooking.payment_method === 'cash'
+ ? '💵 Pay at Hotel'
+ : selectedBooking.payment_method === 'stripe'
+ ? '💳 Stripe (Card)'
+ : selectedBooking.payment_method === 'paypal'
+ ? '💳 PayPal'
+ : selectedBooking.payment_method || 'N/A'}
+
+
+
+
Payment Status
+
+ {selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
+ ? '✅ Paid'
+ : selectedBooking.payment_status === 'pending'
+ ? '⏳ Pending'
+ : selectedBooking.payment_status === 'failed'
+ ? '❌ Failed'
+ : selectedBooking.payment_status || 'Unpaid'}
+
+
+
+
+
+ {/* Service Usages */}
+ {selectedBooking.service_usages && selectedBooking.service_usages.length > 0 && (
+
+
+
+ Additional Services
+
+
+ {selectedBooking.service_usages.map((service: any, idx: number) => (
+
+
+
{service.service_name || service.name || 'Service'}
+
+ {formatCurrency(service.unit_price || service.price || 0)} × {service.quantity || 1}
+
+
+
+ {formatCurrency(service.total_price || (service.unit_price || service.price || 0) * (service.quantity || 1))}
+
+
+ ))}
+
+
+ )}
+
+ {/* Payment Breakdown */}
+ {(() => {
+ const completedPayments = selectedBooking.payments?.filter(
+ (p) => p.payment_status === 'completed'
+ ) || [];
+ const allPayments = selectedBooking.payments || [];
+ const amountPaid = completedPayments.reduce(
+ (sum, p) => sum + (p.amount || 0),
+ 0
+ );
+ const remainingDue = selectedBooking.total_price - amountPaid;
+ const hasPayments = allPayments.length > 0;
+
+ return (
+ <>
+ {hasPayments && (
+
+
+
+ Payment History
+
+
+ {allPayments.map((payment: any, idx: number) => (
+
+
+
+
+ {formatCurrency(payment.amount || 0)}
+
+
+ {payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
+ {' • '}
+ {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
+
+
+
+ {payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
+
+
+ {payment.transaction_id && (
+
ID: {payment.transaction_id}
+ )}
+ {payment.payment_date && (
+
+ {new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
+
+ )}
+
+ ))}
+
+
+ )}
+ {/* Payment Summary - Always show, even if no payments */}
+
+
Amount Paid
+
+ {formatCurrency(amountPaid)}
+
+ {hasPayments && completedPayments.length > 0 && (
+
+ {completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
+ {amountPaid > 0 && selectedBooking.total_price > 0 && (
+
+ ({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
+
+ )}
+
+ )}
+ {amountPaid === 0 && !hasPayments && (
+
No payments made yet
+ )}
+
+
+ {/* Remaining Due - Show prominently if there's remaining balance */}
+ {remainingDue > 0 && (
+
+
Remaining Due (To be paid)
+
+ {formatCurrency(remainingDue)}
+
+ {selectedBooking.total_price > 0 && (
+
+ ({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
+
+ )}
+
+ )}
+
+ {/* Total Booking Price - Show as reference */}
+
+
Total Booking Price
+
+ {formatCurrency(selectedBooking.total_price)}
+
+
+ This is the total amount for the booking
+
+
+ >
+ );
+ })()}
+
+ {/* Booking Metadata */}
+
+
+
+ Booking Metadata
+
+
+ {selectedBooking.createdAt && (
+
+
Created At
+
+ {new Date(selectedBooking.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
+
+
+ )}
+ {selectedBooking.updatedAt && (
+
+
Last Updated
+
+ {new Date(selectedBooking.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
+
+
+ )}
+ {selectedBooking.requires_deposit !== undefined && (
+
+
Deposit Required
+
+ {selectedBooking.requires_deposit ? 'Yes (20%)' : 'No'}
+
+
+ )}
+ {selectedBooking.deposit_paid !== undefined && (
+
+
Deposit Paid
+
+ {selectedBooking.deposit_paid ? '✅ Yes' : '❌ No'}
+
+
+ )}
+
{/* Notes */}
diff --git a/Frontend/src/pages/admin/BusinessDashboardPage.tsx b/Frontend/src/pages/admin/BusinessDashboardPage.tsx
index b5ca3cad..9ad06cd5 100644
--- a/Frontend/src/pages/admin/BusinessDashboardPage.tsx
+++ b/Frontend/src/pages/admin/BusinessDashboardPage.tsx
@@ -742,6 +742,7 @@ const BusinessDashboardPage: React.FC = () => {
Booking Number
Customer
Method
+
Type
Amount
Payment Date
@@ -756,11 +757,28 @@ const BusinessDashboardPage: React.FC = () => {
{payment.booking?.booking_number}
- {payment.booking?.user?.name}
+
+ {payment.booking?.user?.name || payment.booking?.user?.full_name || 'N/A'}
+
{getPaymentMethodBadge(payment.payment_method)}
+
+ {payment.payment_type === 'deposit' ? (
+
+ Deposit (20%)
+
+ ) : payment.payment_type === 'remaining' ? (
+
+ Remaining
+
+ ) : (
+
+ Full Payment
+
+ )}
+
{formatCurrency(payment.amount)}
diff --git a/Frontend/src/pages/admin/CheckInPage.tsx b/Frontend/src/pages/admin/CheckInPage.tsx
index 6daecb58..025aac33 100644
--- a/Frontend/src/pages/admin/CheckInPage.tsx
+++ b/Frontend/src/pages/admin/CheckInPage.tsx
@@ -35,7 +35,17 @@ const CheckInPage: React.FC = () => {
const response = await bookingService.checkBookingByNumber(bookingNumber);
setBooking(response.data.booking);
setActualRoomNumber(response.data.booking.room?.room_number || '');
- toast.success('Booking found');
+
+ // Show warning if there's remaining balance
+ if ((response as any).warning) {
+ const warning = (response as any).warning;
+ toast.warning(
+ `⚠️ Payment Reminder: Guest has remaining balance of ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
+ { autoClose: 8000 }
+ );
+ } else {
+ toast.success('Booking found');
+ }
} catch (error: any) {
toast.error(error.response?.data?.message || 'Booking not found');
setBooking(null);
@@ -89,12 +99,21 @@ const CheckInPage: React.FC = () => {
// Calculate additional fee
calculateAdditionalFee();
- await bookingService.updateBooking(booking.id, {
+ const response = await bookingService.updateBooking(booking.id, {
status: 'checked_in',
// Can send additional data about guests, room_number, additional_fee
} as any);
- toast.success('Check-in successful');
+ // Show warning if there's remaining balance
+ if ((response as any).warning) {
+ const warning = (response as any).warning;
+ toast.warning(
+ `⚠️ Check-in successful, but guest has remaining balance: ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
+ { autoClose: 10000 }
+ );
+ } else {
+ toast.success('Check-in successful');
+ }
// Reset form
setBooking(null);
@@ -201,6 +220,150 @@ const CheckInPage: React.FC = () => {
+ {/* Payment Warning Alert */}
+ {booking.payment_balance && booking.payment_balance.remaining_balance > 0.01 && (
+
+
+
+
+
+ ⚠️ Payment Reminder
+
+
+ This guest has not fully paid for their booking. Please collect the remaining balance during check-in.
+
+
+
+
Total Price:
+
{formatCurrency(booking.payment_balance.total_price)}
+
+
+
Amount Paid:
+
{formatCurrency(booking.payment_balance.total_paid)}
+
+
+
Payment Progress:
+
{booking.payment_balance.payment_percentage.toFixed(1)}%
+
+
+
Remaining Balance:
+
{formatCurrency(booking.payment_balance.remaining_balance)}
+
+
+
+
+
+ )}
+
+ {/* Payment Information */}
+
+
+
+ Payment Information
+
+
+
+
+
+ Payment Method:
+
+ {booking.payment_method === 'cash'
+ ? '💵 Pay at Hotel'
+ : booking.payment_method === 'stripe'
+ ? '💳 Stripe (Card)'
+ : booking.payment_method === 'paypal'
+ ? '💳 PayPal'
+ : booking.payment_method || 'N/A'}
+
+
+
+ Payment Status:
+
+ {booking.payment_status === 'paid' || booking.payment_status === 'completed'
+ ? '✅ Paid'
+ : booking.payment_status === 'pending'
+ ? '⏳ Pending'
+ : booking.payment_status === 'failed'
+ ? '❌ Failed'
+ : booking.payment_status || 'Unpaid'}
+
+
+
+
+
+
+ {(() => {
+ // Use payment_balance from API if available, otherwise calculate from payments
+ const paymentBalance = booking.payment_balance || (() => {
+ const completedPayments = booking.payments?.filter(
+ (p) => p.payment_status === 'completed'
+ ) || [];
+ const amountPaid = completedPayments.reduce(
+ (sum, p) => sum + (p.amount || 0),
+ 0
+ );
+ const remainingDue = booking.total_price - amountPaid;
+ return {
+ total_paid: amountPaid,
+ total_price: booking.total_price,
+ remaining_balance: remainingDue,
+ is_fully_paid: remainingDue <= 0.01,
+ payment_percentage: booking.total_price > 0 ? (amountPaid / booking.total_price * 100) : 0
+ };
+ })();
+
+ const completedPayments = booking.payments?.filter(
+ (p) => p.payment_status === 'completed'
+ ) || [];
+ const hasPayments = completedPayments.length > 0;
+
+ return (
+ <>
+
+ Total Price:
+ {formatCurrency(paymentBalance.total_price)}
+
+ {hasPayments && (
+ <>
+
+ Amount Paid:
+ {formatCurrency(paymentBalance.total_paid)}
+
+ {paymentBalance.remaining_balance > 0.01 && (
+
+ Remaining Due:
+ {formatCurrency(paymentBalance.remaining_balance)}
+
+ )}
+ {completedPayments.length > 0 && (
+
+
Payment Details:
+ {completedPayments.map((payment, idx) => (
+
+ • {formatCurrency(payment.amount)} via {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
+ {payment.payment_type === 'deposit' && ' (Deposit 20%)'}
+ {payment.transaction_id && ` - ${payment.transaction_id}`}
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ );
+ })()}
+
+
+
+
+
+
{booking.status !== 'confirmed' && (
diff --git a/Frontend/src/pages/admin/PaymentManagementPage.tsx b/Frontend/src/pages/admin/PaymentManagementPage.tsx
index fcba12e7..94c5ebd1 100644
--- a/Frontend/src/pages/admin/PaymentManagementPage.tsx
+++ b/Frontend/src/pages/admin/PaymentManagementPage.tsx
@@ -158,6 +158,7 @@ const PaymentManagementPage: React.FC = () => {
Booking Number
Customer
Method
+
Type
Amount
Payment Date
@@ -181,6 +182,21 @@ const PaymentManagementPage: React.FC = () => {
{getMethodBadge(payment.payment_method)}
+
+ {payment.payment_type === 'deposit' ? (
+
+ Deposit (20%)
+
+ ) : payment.payment_type === 'remaining' ? (
+
+ Remaining
+
+ ) : (
+
+ Full Payment
+
+ )}
+
{formatCurrency(payment.amount)}
diff --git a/Frontend/src/pages/admin/ReceptionDashboardPage.tsx b/Frontend/src/pages/admin/ReceptionDashboardPage.tsx
index 49fa227f..08e274f2 100644
--- a/Frontend/src/pages/admin/ReceptionDashboardPage.tsx
+++ b/Frontend/src/pages/admin/ReceptionDashboardPage.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
import {
LogIn,
LogOut,
@@ -189,6 +189,14 @@ const ReceptionDashboardPage: React.FC = () => {
return total;
};
+ // Calculate additional fee when extraPersons or children change
+ useEffect(() => {
+ const extraPersonFee = extraPersons * 200000;
+ const childrenFee = children * 100000;
+ const total = extraPersonFee + childrenFee;
+ setAdditionalFee(total);
+ }, [extraPersons, children]);
+
const handleCheckIn = async () => {
if (!checkInBooking) return;
@@ -272,8 +280,11 @@ const ReceptionDashboardPage: React.FC = () => {
return 0;
};
- const calculateDeposit = () => {
- return checkOutBooking?.total_price ? checkOutBooking.total_price * 0.3 : 0;
+ const calculateTotalPaid = () => {
+ if (!checkOutBooking?.payments) return 0;
+ return checkOutBooking.payments
+ .filter(payment => payment.payment_status === 'completed')
+ .reduce((sum, payment) => sum + (payment.amount || 0), 0);
};
const calculateSubtotal = () => {
@@ -285,7 +296,9 @@ const ReceptionDashboardPage: React.FC = () => {
};
const calculateRemaining = () => {
- return calculateTotal() - calculateDeposit();
+ const total = calculateTotal();
+ const totalPaid = calculateTotalPaid();
+ return total - totalPaid;
};
const handleCheckOut = async () => {
@@ -326,17 +339,7 @@ const ReceptionDashboardPage: React.FC = () => {
};
// Bookings Management Functions
- useEffect(() => {
- setBookingCurrentPage(1);
- }, [bookingFilters]);
-
- useEffect(() => {
- if (activeTab === 'bookings') {
- fetchBookings();
- }
- }, [bookingFilters, bookingCurrentPage, activeTab]);
-
- const fetchBookings = async () => {
+ const fetchBookings = useCallback(async () => {
try {
setBookingsLoading(true);
const response = await bookingService.getAllBookings({
@@ -354,7 +357,17 @@ const ReceptionDashboardPage: React.FC = () => {
} finally {
setBookingsLoading(false);
}
- };
+ }, [bookingFilters.search, bookingFilters.status, bookingCurrentPage]);
+
+ useEffect(() => {
+ setBookingCurrentPage(1);
+ }, [bookingFilters.search, bookingFilters.status]);
+
+ useEffect(() => {
+ if (activeTab === 'bookings') {
+ fetchBookings();
+ }
+ }, [activeTab, fetchBookings]);
const handleUpdateBookingStatus = async (id: number, status: string) => {
try {
@@ -426,19 +439,63 @@ const ReceptionDashboardPage: React.FC = () => {
};
// Rooms Management Functions
+ const fetchAvailableAmenities = useCallback(async () => {
+ try {
+ const response = await roomService.getAmenities();
+ if (response.data?.amenities) {
+ setAvailableAmenities(response.data.amenities);
+ }
+ } catch (error) {
+ console.error('Failed to fetch amenities:', error);
+ }
+ }, []);
+
+ const fetchRooms = useCallback(async () => {
+ try {
+ setRoomsLoading(true);
+ const response = await roomService.getRooms({
+ ...roomFilters,
+ page: roomCurrentPage,
+ limit: roomItemsPerPage,
+ });
+ setRooms(response.data.rooms);
+ if (response.data.pagination) {
+ setRoomTotalPages(response.data.pagination.totalPages);
+ setRoomTotalItems(response.data.pagination.total);
+ }
+
+ const uniqueRoomTypes = new Map();
+ response.data.rooms.forEach((room: Room) => {
+ if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
+ uniqueRoomTypes.set(room.room_type.id, {
+ id: room.room_type.id,
+ name: room.room_type.name,
+ });
+ }
+ });
+ setRoomTypes(Array.from(uniqueRoomTypes.values()));
+ } catch (error: any) {
+ toast.error(error.response?.data?.message || 'Unable to load rooms list');
+ } finally {
+ setRoomsLoading(false);
+ }
+ }, [roomFilters.search, roomFilters.status, roomFilters.type, roomCurrentPage]);
+
useEffect(() => {
setRoomCurrentPage(1);
setSelectedRooms([]);
- }, [roomFilters]);
+ }, [roomFilters.search, roomFilters.status, roomFilters.type]);
useEffect(() => {
if (activeTab === 'rooms') {
fetchRooms();
fetchAvailableAmenities();
}
- }, [roomFilters, roomCurrentPage, activeTab]);
+ }, [activeTab, fetchRooms, fetchAvailableAmenities]);
useEffect(() => {
+ if (activeTab !== 'rooms') return;
+
const fetchAllRoomTypes = async () => {
try {
const response = await roomService.getRooms({ limit: 100, page: 1 });
@@ -474,60 +531,21 @@ const ReceptionDashboardPage: React.FC = () => {
if (allUniqueRoomTypes.size > 0) {
const roomTypesList = Array.from(allUniqueRoomTypes.values());
setRoomTypes(roomTypesList);
- if (!editingRoom && roomFormData.room_type_id === 1 && roomTypesList.length > 0) {
- setRoomFormData(prev => ({ ...prev, room_type_id: roomTypesList[0].id }));
- }
+ setRoomFormData(prev => {
+ if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
+ return { ...prev, room_type_id: roomTypesList[0].id };
+ }
+ return prev;
+ });
}
} catch (err) {
console.error('Failed to fetch room types:', err);
}
};
- if (activeTab === 'rooms') {
- fetchAllRoomTypes();
- }
- }, [activeTab]);
+
+ fetchAllRoomTypes();
+ }, [activeTab, editingRoom]);
- const fetchAvailableAmenities = async () => {
- try {
- const response = await roomService.getAmenities();
- if (response.data?.amenities) {
- setAvailableAmenities(response.data.amenities);
- }
- } catch (error) {
- console.error('Failed to fetch amenities:', error);
- }
- };
-
- const fetchRooms = async () => {
- try {
- setRoomsLoading(true);
- const response = await roomService.getRooms({
- ...roomFilters,
- page: roomCurrentPage,
- limit: roomItemsPerPage,
- });
- setRooms(response.data.rooms);
- if (response.data.pagination) {
- setRoomTotalPages(response.data.pagination.totalPages);
- setRoomTotalItems(response.data.pagination.total);
- }
-
- const uniqueRoomTypes = new Map();
- response.data.rooms.forEach((room: Room) => {
- if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
- uniqueRoomTypes.set(room.room_type.id, {
- id: room.room_type.id,
- name: room.room_type.name,
- });
- }
- });
- setRoomTypes(Array.from(uniqueRoomTypes.values()));
- } catch (error: any) {
- toast.error(error.response?.data?.message || 'Unable to load rooms list');
- } finally {
- setRoomsLoading(false);
- }
- };
const handleRoomSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -866,17 +884,7 @@ const ReceptionDashboardPage: React.FC = () => {
};
// Services Management Functions
- useEffect(() => {
- setServiceCurrentPage(1);
- }, [serviceFilters]);
-
- useEffect(() => {
- if (activeTab === 'services') {
- fetchServices();
- }
- }, [serviceFilters, serviceCurrentPage, activeTab]);
-
- const fetchServices = async () => {
+ const fetchServices = useCallback(async () => {
try {
setServicesLoading(true);
const response = await serviceService.getServices({
@@ -894,7 +902,17 @@ const ReceptionDashboardPage: React.FC = () => {
} finally {
setServicesLoading(false);
}
- };
+ }, [serviceFilters.search, serviceFilters.status, serviceCurrentPage]);
+
+ useEffect(() => {
+ setServiceCurrentPage(1);
+ }, [serviceFilters.search, serviceFilters.status]);
+
+ useEffect(() => {
+ if (activeTab === 'services') {
+ fetchServices();
+ }
+ }, [activeTab, fetchServices]);
const handleServiceSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -1311,6 +1329,100 @@ const ReceptionDashboardPage: React.FC = () => {
+ {/* Payment Information */}
+
+
+
+ Payment Information
+
+
+
+
+
+ Payment Method:
+
+ {checkInBooking.payment_method === 'cash'
+ ? '💵 Pay at Hotel'
+ : checkInBooking.payment_method === 'stripe'
+ ? '💳 Stripe (Card)'
+ : checkInBooking.payment_method === 'paypal'
+ ? '💳 PayPal'
+ : checkInBooking.payment_method || 'N/A'}
+
+
+
+ Payment Status:
+
+ {checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
+ ? '✅ Paid'
+ : checkInBooking.payment_status === 'pending'
+ ? '⏳ Pending'
+ : checkInBooking.payment_status === 'failed'
+ ? '❌ Failed'
+ : checkInBooking.payment_status || 'Unpaid'}
+
+
+
+
+
+
+ {(() => {
+ const completedPayments = checkInBooking.payments?.filter(
+ (p) => p.payment_status === 'completed'
+ ) || [];
+ const amountPaid = completedPayments.reduce(
+ (sum, p) => sum + (p.amount || 0),
+ 0
+ );
+ const remainingDue = checkInBooking.total_price - amountPaid;
+ const hasPayments = completedPayments.length > 0;
+
+ return (
+ <>
+
+ Total Price:
+ {formatCurrency(checkInBooking.total_price)}
+
+ {hasPayments && (
+ <>
+
+ Amount Paid:
+ {formatCurrency(amountPaid)}
+
+ {remainingDue > 0 && (
+
+ Remaining Due:
+ {formatCurrency(remainingDue)}
+
+ )}
+ {completedPayments.length > 0 && (
+
+
Payment Details:
+ {completedPayments.map((payment, idx) => (
+
+ • {formatCurrency(payment.amount)} via {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
+ {payment.payment_type === 'deposit' && ' (Deposit 20%)'}
+ {payment.transaction_id && ` - ${payment.transaction_id}`}
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ );
+ })()}
+
+
+
+
+
{checkInBooking.status !== 'confirmed' && (
@@ -1458,7 +1570,7 @@ const ReceptionDashboardPage: React.FC = () => {
Total Additional Fee
- {formatCurrency(calculateCheckInAdditionalFee())}
+ {formatCurrency(additionalFee)}
@@ -1693,10 +1805,12 @@ const ReceptionDashboardPage: React.FC = () => {
Total:
{formatCurrency(calculateTotal())}
-
- Deposit paid:
- -{formatCurrency(calculateDeposit())}
-
+ {calculateTotalPaid() > 0 && (
+
+ Total paid:
+ -{formatCurrency(calculateTotalPaid())}
+
+ )}
Remaining payment:
{formatCurrency(calculateRemaining())}
@@ -2075,13 +2189,178 @@ const ReceptionDashboardPage: React.FC = () => {
{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}
-
-
Total Price
-
- {formatCurrency(selectedBooking.total_price)}
-
+ {/* Payment Method & Status */}
+
+
+
+ Payment Information
+
+
+
+
Payment Method
+
+ {selectedBooking.payment_method === 'cash'
+ ? '💵 Pay at Hotel'
+ : selectedBooking.payment_method === 'stripe'
+ ? '💳 Stripe (Card)'
+ : selectedBooking.payment_method === 'paypal'
+ ? '💳 PayPal'
+ : selectedBooking.payment_method || 'N/A'}
+
+
+
+
Payment Status
+
+ {selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
+ ? '✅ Paid'
+ : selectedBooking.payment_status === 'pending'
+ ? '⏳ Pending'
+ : selectedBooking.payment_status === 'failed'
+ ? '❌ Failed'
+ : selectedBooking.payment_status || 'Unpaid'}
+
+
+
+ {/* Payment History */}
+ {selectedBooking.payments && selectedBooking.payments.length > 0 && (
+
+
+
+ Payment History
+
+
+ {selectedBooking.payments.map((payment: any, idx: number) => (
+
+
+
+
+ {formatCurrency(payment.amount || 0)}
+
+
+ {payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
+ {' • '}
+ {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
+
+
+
+ {payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
+
+
+ {payment.transaction_id && (
+
ID: {payment.transaction_id}
+ )}
+ {payment.payment_date && (
+
+ {new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Payment Breakdown */}
+ {(() => {
+ const completedPayments = selectedBooking.payments?.filter(
+ (p) => p.payment_status === 'completed'
+ ) || [];
+ const amountPaid = completedPayments.reduce(
+ (sum, p) => sum + (p.amount || 0),
+ 0
+ );
+ const remainingDue = selectedBooking.total_price - amountPaid;
+ const hasPayments = selectedBooking.payments && selectedBooking.payments.length > 0;
+
+ return (
+ <>
+ {/* Payment Summary */}
+
+
Amount Paid
+
+ {formatCurrency(amountPaid)}
+
+ {hasPayments && completedPayments.length > 0 && (
+
+ {completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
+ {amountPaid > 0 && selectedBooking.total_price > 0 && (
+
+ ({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
+
+ )}
+
+ )}
+ {amountPaid === 0 && !hasPayments && (
+
No payments made yet
+ )}
+
+
+ {/* Remaining Due */}
+ {remainingDue > 0 && (
+
+
Remaining Due (To be paid)
+
+ {formatCurrency(remainingDue)}
+
+ {selectedBooking.total_price > 0 && (
+
+ ({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
+
+ )}
+
+ )}
+
+ {/* Total Booking Price */}
+
+
Total Booking Price
+ {selectedBooking.original_price && selectedBooking.discount_amount && selectedBooking.discount_amount > 0 ? (
+ <>
+
+
+ Subtotal:
+ {formatCurrency(selectedBooking.original_price)}
+
+
+
+ Discount{selectedBooking.promotion_code ? ` (${selectedBooking.promotion_code})` : ''}:
+
+ -{formatCurrency(selectedBooking.discount_amount)}
+
+
+
+ Total:
+ {formatCurrency(selectedBooking.total_price)}
+
+
+
+ >
+ ) : (
+
+ {formatCurrency(selectedBooking.total_price)}
+
+ )}
+
+ This is the total amount for the booking
+
+
+ >
+ );
+ })()}
+
{selectedBooking.notes && (
Special Notes
diff --git a/Frontend/src/pages/admin/SettingsPage.tsx b/Frontend/src/pages/admin/SettingsPage.tsx
index 81d7ac15..6f970f47 100644
--- a/Frontend/src/pages/admin/SettingsPage.tsx
+++ b/Frontend/src/pages/admin/SettingsPage.tsx
@@ -36,11 +36,12 @@ import systemSettingsService, {
CompanySettingsResponse,
UpdateCompanySettingsRequest,
} from '../../services/api/systemSettingsService';
+import { recaptchaService, RecaptchaSettingsAdminResponse, UpdateRecaptchaSettingsRequest } from '../../services/api/systemSettingsService';
import { useCurrency } from '../../contexts/CurrencyContext';
import { Loading } from '../../components/common';
import { getCurrencySymbol } from '../../utils/format';
-type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company';
+type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company' | 'recaptcha';
const SettingsPage: React.FC = () => {
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
@@ -105,12 +106,22 @@ const SettingsPage: React.FC = () => {
company_phone: '',
company_email: '',
company_address: '',
+ tax_rate: 0,
});
const [logoPreview, setLogoPreview] = useState(null);
const [faviconPreview, setFaviconPreview] = useState(null);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingFavicon, setUploadingFavicon] = useState(false);
+ // reCAPTCHA Settings State
+ const [recaptchaSettings, setRecaptchaSettings] = useState(null);
+ const [recaptchaFormData, setRecaptchaFormData] = useState({
+ recaptcha_site_key: '',
+ recaptcha_secret_key: '',
+ recaptcha_enabled: false,
+ });
+ const [showRecaptchaSecret, setShowRecaptchaSecret] = useState(false);
+
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -146,6 +157,9 @@ const SettingsPage: React.FC = () => {
if (activeTab === 'company') {
loadCompanySettings();
}
+ if (activeTab === 'recaptcha') {
+ loadRecaptchaSettings();
+ }
}, [activeTab]);
useEffect(() => {
@@ -219,6 +233,7 @@ const SettingsPage: React.FC = () => {
company_phone: companyRes.data.company_phone || '',
company_email: companyRes.data.company_email || '',
company_address: companyRes.data.company_address || '',
+ tax_rate: companyRes.data.tax_rate || 0,
});
// Set previews if URLs exist
@@ -579,6 +594,41 @@ const SettingsPage: React.FC = () => {
}
};
+ const loadRecaptchaSettings = async () => {
+ try {
+ const recaptchaRes = await recaptchaService.getRecaptchaSettingsAdmin();
+ setRecaptchaSettings(recaptchaRes.data);
+ setRecaptchaFormData({
+ recaptcha_site_key: recaptchaRes.data.recaptcha_site_key || '',
+ recaptcha_secret_key: '',
+ recaptcha_enabled: recaptchaRes.data.recaptcha_enabled || false,
+ });
+ } catch (error: any) {
+ toast.error(
+ error.response?.data?.detail ||
+ error.response?.data?.message ||
+ 'Failed to load reCAPTCHA settings'
+ );
+ }
+ };
+
+ const handleSaveRecaptcha = async () => {
+ try {
+ setSaving(true);
+ await recaptchaService.updateRecaptchaSettings(recaptchaFormData);
+ toast.success('reCAPTCHA settings saved successfully');
+ await loadRecaptchaSettings();
+ } catch (error: any) {
+ toast.error(
+ error.response?.data?.detail ||
+ error.response?.data?.message ||
+ 'Failed to save reCAPTCHA settings'
+ );
+ } finally {
+ setSaving(false);
+ }
+ };
+
if (loading) {
return ;
}
@@ -590,6 +640,7 @@ const SettingsPage: React.FC = () => {
{ id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard },
{ id: 'smtp' as SettingsTab, label: 'Email Server', icon: Mail },
{ id: 'company' as SettingsTab, label: 'Company Info', icon: Building2 },
+ { id: 'recaptcha' as SettingsTab, label: 'reCAPTCHA', icon: Shield },
];
return (
@@ -2154,6 +2205,29 @@ const SettingsPage: React.FC = () => {
Physical address of your company or hotel
+
+ {/* Tax Rate */}
+
+
+
+ Tax Rate (%)
+
+
+ setCompanyFormData({ ...companyFormData, tax_rate: parseFloat(e.target.value) || 0 })
+ }
+ placeholder="0.00"
+ className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
+ />
+
+ Default tax rate percentage to be applied to all invoices (e.g., 10 for 10%). This will be used for all bookings unless overridden.
+
+
@@ -2178,6 +2252,152 @@ const SettingsPage: React.FC = () => {
)}
+
+ {activeTab === 'recaptcha' && (
+
+ {/* Section Header */}
+
+
+
+
+ Google reCAPTCHA Settings
+
+
+ Configure Google reCAPTCHA to protect your forms from spam and abuse
+
+
+
+
+ {/* reCAPTCHA Settings Form */}
+
+
+ {/* Enable/Disable Toggle */}
+
+
+
+ Enable reCAPTCHA
+
+
+ Enable or disable reCAPTCHA verification across all forms
+
+
+
+
+ setRecaptchaFormData({
+ ...recaptchaFormData,
+ recaptcha_enabled: e.target.checked,
+ })
+ }
+ className="sr-only peer"
+ />
+
+
+
+
+ {/* Site Key */}
+
+
+
+ reCAPTCHA Site Key
+
+
+ setRecaptchaFormData({
+ ...recaptchaFormData,
+ recaptcha_site_key: e.target.value,
+ })
+ }
+ placeholder="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
+ className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
+ />
+
+ Your reCAPTCHA site key from Google. Get it from{' '}
+
+ Google reCAPTCHA Admin
+
+
+
+
+ {/* Secret Key */}
+
+
+
+ reCAPTCHA Secret Key
+
+
+
+ setRecaptchaFormData({
+ ...recaptchaFormData,
+ recaptcha_secret_key: e.target.value,
+ })
+ }
+ placeholder={recaptchaSettings?.recaptcha_secret_key_masked || 'Enter secret key'}
+ className="w-full px-4 py-3.5 pr-12 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
+ />
+ setShowRecaptchaSecret(!showRecaptchaSecret)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
+ >
+ {showRecaptchaSecret ? (
+
+ ) : (
+
+ )}
+
+
+
+ Your reCAPTCHA secret key (keep this secure). Leave empty to keep existing value.
+
+ {recaptchaSettings?.recaptcha_secret_key_masked && (
+
+ Current: {recaptchaSettings.recaptcha_secret_key_masked}
+
+ )}
+
+
+ {/* Info Box */}
+
+
+
+
+
About reCAPTCHA
+
+ reCAPTCHA protects your forms from spam and abuse. You can use reCAPTCHA v2 (checkbox) or v3 (invisible).
+ Make sure to use the same version for both site key and secret key.
+
+
+
+
+
+ {/* Save Button */}
+
+
+
+ {saving ? 'Saving...' : 'Save reCAPTCHA Settings'}
+
+
+
+
+
+ )}
);
diff --git a/Frontend/src/pages/auth/LoginPage.tsx b/Frontend/src/pages/auth/LoginPage.tsx
index 9640205f..9f40e6ba 100644
--- a/Frontend/src/pages/auth/LoginPage.tsx
+++ b/Frontend/src/pages/auth/LoginPage.tsx
@@ -21,6 +21,9 @@ import {
} from '../../utils/validationSchemas';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
import * as yup from 'yup';
+import { toast } from 'react-toastify';
+import Recaptcha from '../../components/common/Recaptcha';
+import { recaptchaService } from '../../services/api/systemSettingsService';
const mfaTokenSchema = yup.object().shape({
mfaToken: yup
@@ -41,6 +44,7 @@ const LoginPage: React.FC = () => {
const { settings } = useCompanySettings();
const [showPassword, setShowPassword] = useState(false);
+ const [recaptchaToken, setRecaptchaToken] = useState(null);
// MFA form setup
const {
@@ -78,6 +82,23 @@ const LoginPage: React.FC = () => {
const onSubmit = async (data: LoginFormData) => {
try {
clearError();
+
+ // Verify reCAPTCHA if enabled
+ if (recaptchaToken) {
+ try {
+ const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
+ if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ } catch (error) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ }
+
await login({
email: data.email,
password: data.password,
@@ -91,9 +112,11 @@ const LoginPage: React.FC = () => {
'/dashboard';
navigate(from, { replace: true });
}
+ setRecaptchaToken(null);
} catch (error) {
// Error has been handled in store
console.error('Login error:', error);
+ setRecaptchaToken(null);
}
};
@@ -391,6 +414,19 @@ const LoginPage: React.FC = () => {
+ {/* reCAPTCHA */}
+
+ setRecaptchaToken(token)}
+ onError={(error) => {
+ console.error('reCAPTCHA error:', error);
+ setRecaptchaToken(null);
+ }}
+ theme="light"
+ size="normal"
+ />
+
+
{/* Submit Button */}
{
const navigate = useNavigate();
@@ -32,6 +35,7 @@ const RegisterPage: React.FC = () => {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState(false);
+ const [recaptchaToken, setRecaptchaToken] = useState(null);
// Update page title
useEffect(() => {
@@ -87,6 +91,23 @@ const RegisterPage: React.FC = () => {
const onSubmit = async (data: RegisterFormData) => {
try {
clearError();
+
+ // Verify reCAPTCHA if enabled
+ if (recaptchaToken) {
+ try {
+ const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
+ if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ } catch (error) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ }
+
await registerUser({
name: data.name,
email: data.email,
@@ -96,9 +117,11 @@ const RegisterPage: React.FC = () => {
// Redirect to login page
navigate('/login', { replace: true });
+ setRecaptchaToken(null);
} catch (error) {
// Error has been handled in store
console.error('Register error:', error);
+ setRecaptchaToken(null);
}
};
@@ -443,6 +466,19 @@ const RegisterPage: React.FC = () => {
)}
+ {/* reCAPTCHA */}
+
+ setRecaptchaToken(token)}
+ onError={(error) => {
+ console.error('reCAPTCHA error:', error);
+ setRecaptchaToken(null);
+ }}
+ theme="light"
+ size="normal"
+ />
+
+
{/* Submit Button */}
{
)}
- {/* Payment Method */}
+ {/* Payment Method & Status */}
@@ -529,6 +529,70 @@ const BookingDetailPage: React.FC = () => {
+ {/* Payment History */}
+ {booking.payments && booking.payments.length > 0 && (
+
+
+ Payment History
+
+
+ {booking.payments.map((payment: any, index: number) => (
+
+
+
+
+
+ {formatPrice(payment.amount || 0)}
+
+
+ {payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
+
+
+
+
+ Payment Method: {' '}
+ {payment.payment_method === 'stripe' ? '💳 Stripe (Card)' :
+ payment.payment_method === 'paypal' ? '💳 PayPal' :
+ payment.payment_method === 'cash' ? '💵 Cash' :
+ payment.payment_method || 'N/A'}
+
+
+ Payment Type: {' '}
+ {payment.payment_type === 'deposit' ? 'Deposit (20%)' :
+ payment.payment_type === 'remaining' ? 'Remaining Payment' :
+ 'Full Payment'}
+
+ {payment.transaction_id && (
+
+ Transaction ID: {payment.transaction_id}
+
+ )}
+ {payment.payment_date && (
+
+ Paid on: {new Date(payment.payment_date).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ })}
+
+ )}
+
+
+
+
+ ))}
+
+
+ )}
+
{/* Price Breakdown */}
@@ -579,17 +643,59 @@ const BookingDetailPage: React.FC = () => {
return null;
})()}
- {/* Total */}
-
-
-
- Total Payment
-
-
- {formatPrice(booking.total_price)}
-
-
-
+ {/* Payment Breakdown */}
+ {(() => {
+ // Calculate amount paid from completed payments
+ const completedPayments = booking.payments?.filter(
+ (p) => p.payment_status === 'completed'
+ ) || [];
+ const amountPaid = completedPayments.reduce(
+ (sum, p) => sum + (p.amount || 0),
+ 0
+ );
+ const remainingDue = booking.total_price - amountPaid;
+ const hasPayments = completedPayments.length > 0;
+
+ return (
+ <>
+ {hasPayments && (
+ <>
+
+
+
+ Amount Paid:
+
+
+ {formatPrice(amountPaid)}
+
+
+ {remainingDue > 0 && (
+
+
+ Remaining Due:
+
+
+ {formatPrice(remainingDue)}
+
+
+ )}
+
+ >
+ )}
+ {/* Total */}
+
+
+
+ Total Booking Price
+
+
+ {formatPrice(booking.total_price)}
+
+
+
+ >
+ );
+ })()}
diff --git a/Frontend/src/pages/customer/BookingPage.tsx b/Frontend/src/pages/customer/BookingPage.tsx
index bba97562..cd98a031 100644
--- a/Frontend/src/pages/customer/BookingPage.tsx
+++ b/Frontend/src/pages/customer/BookingPage.tsx
@@ -24,6 +24,7 @@ import {
MapPin,
Plus,
Minus,
+ X,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getRoomById, getRoomBookedDates, type Room } from
@@ -33,7 +34,7 @@ import {
checkRoomAvailability,
type BookingData,
} from '../../services/api/bookingService';
-import { serviceService, Service } from '../../services/api';
+import { serviceService, Service, promotionService, Promotion } from '../../services/api';
import { createPayPalOrder } from '../../services/api/paymentService';
import useAuthStore from '../../store/useAuthStore';
import {
@@ -43,20 +44,28 @@ import {
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDateLocal } from '../../utils/format';
+import Recaptcha from '../../components/common/Recaptcha';
+import { recaptchaService } from '../../services/api/systemSettingsService';
const BookingPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated, userInfo } = useAuthStore();
- const { formatCurrency } = useFormatCurrency();
+ const { formatCurrency, currency } = useFormatCurrency();
const [room, setRoom] = useState(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
+ const [recaptchaToken, setRecaptchaToken] = useState(null);
const [services, setServices] = useState([]);
const [selectedServices, setSelectedServices] = useState>([]);
const [bookedDates, setBookedDates] = useState([]);
+ const [promotionCode, setPromotionCode] = useState('');
+ const [selectedPromotion, setSelectedPromotion] = useState(null);
+ const [promotionDiscount, setPromotionDiscount] = useState(0);
+ const [validatingPromotion, setValidatingPromotion] = useState(false);
+ const [promotionError, setPromotionError] = useState(null);
// Redirect if not authenticated
useEffect(() => {
@@ -180,7 +189,7 @@ const BookingPage: React.FC = () => {
handleSubmit,
watch,
formState: { errors },
- } = useForm({
+ } = useForm({
resolver: yupResolver(bookingValidationSchema),
defaultValues: {
checkInDate: undefined,
@@ -191,6 +200,12 @@ const BookingPage: React.FC = () => {
fullName: userInfo?.name || '',
email: userInfo?.email || '',
phone: userInfo?.phone || '',
+ invoiceInfo: {
+ company_name: '',
+ company_address: '',
+ company_tax_id: '',
+ customer_tax_id: '',
+ },
},
});
@@ -221,15 +236,79 @@ const BookingPage: React.FC = () => {
return sum + (item.service.price * item.quantity);
}, 0);
- const totalPrice = roomTotal + servicesTotal;
+ const subtotal = roomTotal + servicesTotal;
+ const totalPrice = Math.max(0, subtotal - promotionDiscount);
// Format price using currency context
const formatPrice = (price: number) => formatCurrency(price);
+ // Handle promotion code validation
+ const handleValidatePromotion = async () => {
+ if (!promotionCode.trim()) {
+ setPromotionError('Please enter a promotion code');
+ return;
+ }
+
+ if (subtotal === 0) {
+ setPromotionError('Please select dates first');
+ return;
+ }
+
+ try {
+ setValidatingPromotion(true);
+ setPromotionError(null);
+
+ const response = await promotionService.validatePromotion(
+ promotionCode.toUpperCase().trim(),
+ subtotal
+ );
+
+ if (response.success && response.data) {
+ setSelectedPromotion(response.data.promotion);
+ setPromotionDiscount(response.data.discount);
+ toast.success(`Promotion "${response.data.promotion.name}" applied!`);
+ } else {
+ throw new Error(response.message || 'Invalid promotion code');
+ }
+ } catch (error: any) {
+ setPromotionError(error.response?.data?.message || error.message || 'Invalid promotion code');
+ setSelectedPromotion(null);
+ setPromotionDiscount(0);
+ toast.error(error.response?.data?.message || error.message || 'Invalid promotion code');
+ } finally {
+ setValidatingPromotion(false);
+ }
+ };
+
+ // Handle removing promotion
+ const handleRemovePromotion = () => {
+ setPromotionCode('');
+ setSelectedPromotion(null);
+ setPromotionDiscount(0);
+ setPromotionError(null);
+ toast.info('Promotion removed');
+ };
+
// Handle form submission
const onSubmit = async (data: BookingFormData) => {
if (!room) return;
+ // Verify reCAPTCHA if enabled
+ if (recaptchaToken) {
+ try {
+ const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
+ if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ } catch (error) {
+ toast.error('reCAPTCHA verification failed. Please try again.');
+ setRecaptchaToken(null);
+ return;
+ }
+ }
+
try {
setSubmitting(true);
@@ -279,7 +358,7 @@ const BookingPage: React.FC = () => {
}
// Step 2: Prepare booking data
- const bookingData: BookingData = {
+ const bookingData: BookingData & { invoice_info?: any } = {
room_id: room.id,
check_in_date: checkInDateStr,
check_out_date: checkOutDateStr,
@@ -296,6 +375,13 @@ const BookingPage: React.FC = () => {
service_id: item.service.id,
quantity: item.quantity,
})),
+ promotion_code: selectedPromotion?.code || undefined,
+ invoice_info: (data as any).invoiceInfo ? {
+ company_name: (data as any).invoiceInfo.company_name || undefined,
+ company_address: (data as any).invoiceInfo.company_address || undefined,
+ company_tax_id: (data as any).invoiceInfo.company_tax_id || undefined,
+ customer_tax_id: (data as any).invoiceInfo.customer_tax_id || undefined,
+ } : undefined,
};
// Step 3: Create booking
@@ -318,7 +404,7 @@ const BookingPage: React.FC = () => {
const paypalResponse = await createPayPalOrder(
bookingId,
totalPrice,
- 'USD',
+ currency || 'USD', // Use the platform currency from context
returnUrl,
cancelUrl
);
@@ -343,6 +429,17 @@ const BookingPage: React.FC = () => {
}
}
+ // If cash payment (pay on arrival), redirect to deposit payment page
+ if (paymentMethod === 'cash') {
+ toast.success(
+ '📋 Booking created! Please pay the 20% deposit to confirm your booking.',
+ { icon: }
+ );
+ // Navigate to deposit payment page
+ navigate(`/payment/deposit/${bookingId}`);
+ return;
+ }
+
// For other payment methods, navigate to success page
toast.success(
'🎉 Booking successful!',
@@ -377,6 +474,7 @@ const BookingPage: React.FC = () => {
'Unable to book room. Please try again.';
toast.error(message);
}
+ setRecaptchaToken(null);
} finally {
setSubmitting(false);
}
@@ -384,8 +482,16 @@ const BookingPage: React.FC = () => {
if (loading) {
return (
-
-
+
@@ -394,8 +500,16 @@ const BookingPage: React.FC = () => {
if (error || !room) {
return (
-
-
+
+
-
+
+
{/* Back Button */}
-
+
Back to room details
{/* Page Title */}
-
+
Book Your Room
-
+
Complete your reservation in just a few steps
-