This commit is contained in:
Iliyan Angelov
2025-11-20 02:18:52 +02:00
parent 34b4c969d4
commit 44e11520c5
55 changed files with 4741 additions and 876 deletions

View File

@@ -0,0 +1,34 @@
"""add_promotion_fields_to_bookings
Revision ID: bd309b0742c1
Revises: f1a2b3c4d5e6
Create Date: 2025-11-20 02:16:09.496685
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bd309b0742c1'
down_revision = 'f1a2b3c4d5e6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add promotion-related columns to bookings table
op.add_column('bookings', sa.Column('original_price', sa.Numeric(10, 2), nullable=True))
op.add_column('bookings', sa.Column('discount_amount', sa.Numeric(10, 2), nullable=True, server_default='0'))
op.add_column('bookings', sa.Column('promotion_code', sa.String(50), nullable=True))
# Add index on promotion_code for faster lookups
op.create_index(op.f('ix_bookings_promotion_code'), 'bookings', ['promotion_code'], unique=False)
def downgrade() -> None:
# Remove promotion-related columns
op.drop_index(op.f('ix_bookings_promotion_code'), table_name='bookings')
op.drop_column('bookings', 'promotion_code')
op.drop_column('bookings', 'discount_amount')
op.drop_column('bookings', 'original_price')

View File

@@ -0,0 +1,27 @@
"""add_is_proforma_to_invoices
Revision ID: f1a2b3c4d5e6
Revises: d9aff6c5f0d4
Create Date: 2025-11-20 00:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f1a2b3c4d5e6'
down_revision = 'd9aff6c5f0d4'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add is_proforma column to invoices table
op.add_column('invoices', sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default='0'))
def downgrade() -> None:
# Remove is_proforma column
op.drop_column('invoices', 'is_proforma')

View File

@@ -20,6 +20,7 @@ stripe>=13.2.0
paypal-checkout-serversdk>=1.0.3 paypal-checkout-serversdk>=1.0.3
pyotp==2.9.0 pyotp==2.9.0
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
httpx==0.25.2
# Enterprise features (optional but recommended) # Enterprise features (optional but recommended)
# redis==5.0.1 # Uncomment if using Redis caching # redis==5.0.1 # Uncomment if using Redis caching

View File

@@ -24,6 +24,9 @@ class Booking(Base):
check_out_date = Column(DateTime, nullable=False) check_out_date = Column(DateTime, nullable=False)
num_guests = Column(Integer, nullable=False, default=1) num_guests = Column(Integer, nullable=False, default=1)
total_price = Column(Numeric(10, 2), nullable=False) total_price = Column(Numeric(10, 2), nullable=False)
original_price = Column(Numeric(10, 2), nullable=True) # Price before discount
discount_amount = Column(Numeric(10, 2), nullable=True, default=0) # Discount amount applied
promotion_code = Column(String(50), nullable=True) # Promotion code used
status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending) status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending)
deposit_paid = Column(Boolean, nullable=False, default=False) deposit_paid = Column(Boolean, nullable=False, default=False)
requires_deposit = Column(Boolean, nullable=False, default=False) requires_deposit = Column(Boolean, nullable=False, default=False)

View File

@@ -37,6 +37,7 @@ class Invoice(Base):
# Status # Status
status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft) status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft)
is_proforma = Column(Boolean, nullable=False, default=False) # True for proforma invoices
# Company/Organization information (for admin to manage) # Company/Organization information (for admin to manage)
company_name = Column(String(200), nullable=True) company_name = Column(String(200), nullable=True)

View File

@@ -26,6 +26,134 @@ from ..utils.email_templates import (
router = APIRouter(prefix="/bookings", tags=["bookings"]) router = APIRouter(prefix="/bookings", tags=["bookings"])
def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> str:
"""Generate HTML email content for invoice"""
invoice_type = "Proforma Invoice" if is_proforma else "Invoice"
items_html = ''.join([f'''
<tr>
<td>{item.get('description', 'N/A')}</td>
<td>{item.get('quantity', 0)}</td>
<td>{item.get('unit_price', 0):.2f}</td>
<td>{item.get('line_total', 0):.2f}</td>
</tr>
''' for item in invoice.get('items', [])])
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
color: #0f0f0f;
padding: 20px;
text-align: center;
border-radius: 8px 8px 0 0;
}}
.content {{
background-color: #ffffff;
padding: 30px;
border: 1px solid #e0e0e0;
}}
.invoice-info {{
margin-bottom: 20px;
}}
.invoice-info h2 {{
color: #d4af37;
margin-bottom: 10px;
}}
.details {{
margin: 20px 0;
}}
.details table {{
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}}
.details th, .details td {{
padding: 10px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}}
.details th {{
background-color: #f5f5f5;
font-weight: bold;
}}
.total {{
text-align: right;
font-size: 18px;
font-weight: bold;
margin-top: 20px;
}}
.footer {{
text-align: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
color: #666;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{invoice_type}</h1>
</div>
<div class="content">
<div class="invoice-info">
<h2>{invoice_type} #{invoice.get('invoice_number', 'N/A')}</h2>
<p><strong>Issue Date:</strong> {invoice.get('issue_date', 'N/A')}</p>
<p><strong>Due Date:</strong> {invoice.get('due_date', 'N/A')}</p>
<p><strong>Status:</strong> {invoice.get('status', 'N/A')}</p>
</div>
<div class="details">
<h3>Items</h3>
<table>
<thead>
<tr>
<th>Description</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{items_html}
</tbody>
</table>
</div>
<div class="total">
<p>Subtotal: {invoice.get('subtotal', 0):.2f}</p>
{f'<p style="color: #059669;">Discount: -{invoice.get("discount_amount", 0):.2f}</p>' if invoice.get('discount_amount', 0) > 0 else ''}
<p>Tax: {invoice.get('tax_amount', 0):.2f}</p>
<p><strong>Total Amount: {invoice.get('total_amount', 0):.2f}</strong></p>
<p>Amount Paid: {invoice.get('amount_paid', 0):.2f}</p>
<p>Balance Due: {invoice.get('balance_due', 0):.2f}</p>
</div>
</div>
<div class="footer">
<p>Thank you for your booking!</p>
</div>
</div>
</body>
</html>
"""
def generate_booking_number() -> str: def generate_booking_number() -> str:
"""Generate unique booking number""" """Generate unique booking number"""
prefix = "BK" prefix = "BK"
@@ -34,6 +162,29 @@ def generate_booking_number() -> str:
return f"{prefix}-{ts}-{rand}" return f"{prefix}-{ts}-{rand}"
def calculate_booking_payment_balance(booking: Booking) -> dict:
"""Calculate total paid amount and remaining balance for a booking"""
total_paid = 0.0
if booking.payments:
# Sum all completed payments
total_paid = sum(
float(payment.amount) if payment.amount else 0.0
for payment in booking.payments
if payment.payment_status == PaymentStatus.completed
)
total_price = float(booking.total_price) if booking.total_price else 0.0
remaining_balance = total_price - total_paid
return {
"total_paid": total_paid,
"total_price": total_price,
"remaining_balance": remaining_balance,
"is_fully_paid": remaining_balance <= 0.01, # Allow small floating point differences
"payment_percentage": (total_paid / total_price * 100) if total_price > 0 else 0
}
@router.get("/") @router.get("/")
async def get_all_bookings( async def get_all_bookings(
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
@@ -47,7 +198,11 @@ async def get_all_bookings(
): ):
"""Get all bookings (Admin/Staff only)""" """Get all bookings (Admin/Staff only)"""
try: try:
query = db.query(Booking) query = db.query(Booking).options(
selectinload(Booking.payments),
joinedload(Booking.user),
joinedload(Booking.room).joinedload(Room.room_type)
)
# Filter by search (booking_number) # Filter by search (booking_number)
if search: if search:
@@ -79,6 +234,23 @@ async def get_all_bookings(
# Include related data # Include related data
result = [] result = []
for booking in bookings: for booking in bookings:
# Determine payment_method and payment_status from payments
payment_method_from_payments = None
payment_status_from_payments = "unpaid"
if booking.payments:
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = "paid"
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = "refunded"
booking_dict = { booking_dict = {
"id": booking.id, "id": booking.id,
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
@@ -87,21 +259,33 @@ async def get_all_bookings(
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"num_guests": booking.num_guests, "num_guests": booking.num_guests,
"guest_count": booking.num_guests, # Frontend expects guest_count
"total_price": float(booking.total_price) if booking.total_price else 0.0, "total_price": float(booking.total_price) if booking.total_price else 0.0,
"original_price": float(booking.original_price) if booking.original_price else None,
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
"promotion_code": booking.promotion_code,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
"payment_status": payment_status_from_payments,
"deposit_paid": booking.deposit_paid, "deposit_paid": booking.deposit_paid,
"requires_deposit": booking.requires_deposit, "requires_deposit": booking.requires_deposit,
"special_requests": booking.special_requests, "special_requests": booking.special_requests,
"notes": booking.special_requests, # Frontend expects notes
"created_at": booking.created_at.isoformat() if booking.created_at else None, "created_at": booking.created_at.isoformat() if booking.created_at else None,
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
} }
# Add user info # Add user info
if booking.user: if booking.user:
booking_dict["user"] = { booking_dict["user"] = {
"id": booking.user.id, "id": booking.user.id,
"name": booking.user.full_name,
"full_name": booking.user.full_name, "full_name": booking.user.full_name,
"email": booking.user.email, "email": booking.user.email,
"phone": booking.user.phone, "phone": booking.user.phone,
"phone_number": booking.user.phone,
} }
# Add room info # Add room info
@@ -111,6 +295,37 @@ async def get_all_bookings(
"room_number": booking.room.room_number, "room_number": booking.room.room_number,
"floor": booking.room.floor, "floor": booking.room.floor,
} }
# Safely access room_type - it should be loaded via joinedload
try:
if hasattr(booking.room, 'room_type') and booking.room.room_type:
booking_dict["room"]["room_type"] = {
"id": booking.room.room_type.id,
"name": booking.room.room_type.name,
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
"capacity": booking.room.room_type.capacity,
}
except Exception as room_type_error:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Could not load room_type for booking {booking.id}: {room_type_error}")
# Add payments
if booking.payments:
booking_dict["payments"] = [
{
"id": p.id,
"amount": float(p.amount) if p.amount else 0.0,
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
"transaction_id": p.transaction_id,
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
}
for p in booking.payments
]
else:
booking_dict["payments"] = []
result.append(booking_dict) result.append(booking_dict)
@@ -127,6 +342,11 @@ async def get_all_bookings(
}, },
} }
except Exception as e: except Exception as e:
import logging
import traceback
logger = logging.getLogger(__name__)
logger.error(f"Error in get_all_bookings: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -138,13 +358,33 @@ async def get_my_bookings(
): ):
"""Get current user's bookings""" """Get current user's bookings"""
try: try:
bookings = db.query(Booking).filter( bookings = db.query(Booking).options(
selectinload(Booking.payments),
joinedload(Booking.room).joinedload(Room.room_type)
).filter(
Booking.user_id == current_user.id Booking.user_id == current_user.id
).order_by(Booking.created_at.desc()).all() ).order_by(Booking.created_at.desc()).all()
base_url = get_base_url(request) base_url = get_base_url(request)
result = [] result = []
for booking in bookings: for booking in bookings:
# Determine payment_method and payment_status from payments
payment_method_from_payments = None
payment_status_from_payments = "unpaid"
if booking.payments:
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = "paid"
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = "refunded"
booking_dict = { booking_dict = {
"id": booking.id, "id": booking.id,
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
@@ -152,12 +392,22 @@ async def get_my_bookings(
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"num_guests": booking.num_guests, "num_guests": booking.num_guests,
"total_price": float(booking.total_price) if booking.total_price else 0.0, "guest_count": booking.num_guests,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, "total_price": float(booking.total_price) if booking.total_price else 0.0,
"deposit_paid": booking.deposit_paid, "original_price": float(booking.original_price) if booking.original_price else None,
"requires_deposit": booking.requires_deposit, "discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
"special_requests": booking.special_requests, "promotion_code": booking.promotion_code,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
"payment_status": payment_status_from_payments,
"deposit_paid": booking.deposit_paid,
"requires_deposit": booking.requires_deposit,
"special_requests": booking.special_requests,
"notes": booking.special_requests,
"created_at": booking.created_at.isoformat() if booking.created_at else None, "created_at": booking.created_at.isoformat() if booking.created_at else None,
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
} }
# Add room info # Add room info
@@ -184,6 +434,24 @@ async def get_my_bookings(
} }
} }
# Add payments
if booking.payments:
booking_dict["payments"] = [
{
"id": p.id,
"amount": float(p.amount) if p.amount else 0.0,
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
"transaction_id": p.transaction_id,
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
}
for p in booking.payments
]
else:
booking_dict["payments"] = []
result.append(booking_dict) result.append(booking_dict)
return { return {
@@ -219,6 +487,10 @@ async def create_booking(
guest_count = booking_data.get("guest_count", 1) guest_count = booking_data.get("guest_count", 1)
notes = booking_data.get("notes") notes = booking_data.get("notes")
payment_method = booking_data.get("payment_method", "cash") payment_method = booking_data.get("payment_method", "cash")
promotion_code = booking_data.get("promotion_code")
# Invoice information (optional)
invoice_info = booking_data.get("invoice_info", {})
# Detailed validation with specific error messages # Detailed validation with specific error messages
missing_fields = [] missing_fields = []
@@ -284,6 +556,34 @@ async def create_booking(
# Will be confirmed after successful payment # Will be confirmed after successful payment
initial_status = BookingStatus.pending initial_status = BookingStatus.pending
# Calculate original price (before discount) and discount amount
# Calculate room price
room_price = float(room.price) if room.price and room.price > 0 else float(room.room_type.base_price) if room.room_type else 0.0
number_of_nights = (check_out - check_in).days
room_total = room_price * number_of_nights
# Calculate services total (will be recalculated when adding services, but estimate here)
services = booking_data.get("services", [])
services_total = 0.0
if services:
from ..models.service import Service
for service_item in services:
service_id = service_item.get("service_id")
quantity = service_item.get("quantity", 1)
if service_id:
service = db.query(Service).filter(Service.id == service_id).first()
if service and service.is_active:
services_total += float(service.price) * quantity
original_price = room_total + services_total
discount_amount = max(0.0, original_price - float(total_price)) if promotion_code else 0.0
# Add promotion code to notes if provided
final_notes = notes or ""
if promotion_code:
promotion_note = f"Promotion Code: {promotion_code}"
final_notes = f"{promotion_note}\n{final_notes}".strip() if final_notes else promotion_note
# Create booking # Create booking
booking = Booking( booking = Booking(
booking_number=booking_number, booking_number=booking_number,
@@ -293,7 +593,10 @@ async def create_booking(
check_out_date=check_out, check_out_date=check_out,
num_guests=guest_count, num_guests=guest_count,
total_price=total_price, total_price=total_price,
special_requests=notes, original_price=original_price if promotion_code else None,
discount_amount=discount_amount if promotion_code and discount_amount > 0 else None,
promotion_code=promotion_code,
special_requests=final_notes,
status=initial_status, status=initial_status,
requires_deposit=requires_deposit, requires_deposit=requires_deposit,
deposit_paid=False, deposit_paid=False,
@@ -330,8 +633,21 @@ async def create_booking(
logger.info(f"Payment created: ID={payment.id}, method={payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method}") logger.info(f"Payment created: ID={payment.id}, method={payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method}")
# Create deposit payment if required (for cash method) # Create deposit payment if required (for cash method)
# Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record # For cash payments, create a pending deposit payment record that can be paid via PayPal or Stripe
# The payment will be created when the customer pays at check-in if requires_deposit and deposit_amount > 0:
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
deposit_payment = Payment(
booking_id=booking.id,
amount=deposit_amount,
payment_method=PaymentMethod.stripe, # Default, will be updated when user chooses payment method
payment_type=PaymentType.deposit,
deposit_percentage=deposit_percentage,
payment_status=PaymentStatus.pending,
payment_date=None,
)
db.add(deposit_payment)
db.flush()
logger.info(f"Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%")
# Add services to booking if provided # Add services to booking if provided
services = booking_data.get("services", []) services = booking_data.get("services", [])
@@ -368,9 +684,10 @@ async def create_booking(
db.commit() db.commit()
db.refresh(booking) db.refresh(booking)
# Automatically create invoice for the booking # Automatically create invoice(s) for the booking
try: try:
from ..services.invoice_service import InvoiceService from ..services.invoice_service import InvoiceService
from ..utils.mailer import send_email
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
# Reload booking with service_usages for invoice creation # Reload booking with service_usages for invoice creation
@@ -378,15 +695,113 @@ async def create_booking(
selectinload(Booking.service_usages).selectinload(ServiceUsage.service) selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
).filter(Booking.id == booking.id).first() ).filter(Booking.id == booking.id).first()
# Create invoice automatically # Get company settings for invoice
invoice = InvoiceService.create_invoice_from_booking( from ..models.system_settings import SystemSettings
booking_id=booking.id, company_settings = {}
db=db, for key in ["company_name", "company_address", "company_phone", "company_email", "company_tax_id", "company_logo_url"]:
created_by_id=current_user.id, setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
tax_rate=0.0, # Default no tax, can be configured if setting and setting.value:
discount_amount=0.0, company_settings[key] = setting.value
due_days=30,
) # Get tax rate from settings (default to 0 if not set)
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == "tax_rate").first()
tax_rate = float(tax_rate_setting.value) if tax_rate_setting and tax_rate_setting.value else 0.0
# Merge invoice info from form with company settings (form takes precedence)
# Only include non-empty values from invoice_info
invoice_kwargs = {**company_settings}
if invoice_info:
if invoice_info.get("company_name"):
invoice_kwargs["company_name"] = invoice_info.get("company_name")
if invoice_info.get("company_address"):
invoice_kwargs["company_address"] = invoice_info.get("company_address")
if invoice_info.get("company_tax_id"):
invoice_kwargs["company_tax_id"] = invoice_info.get("company_tax_id")
if invoice_info.get("customer_tax_id"):
invoice_kwargs["customer_tax_id"] = invoice_info.get("customer_tax_id")
if invoice_info.get("notes"):
invoice_kwargs["notes"] = invoice_info.get("notes")
if invoice_info.get("terms_and_conditions"):
invoice_kwargs["terms_and_conditions"] = invoice_info.get("terms_and_conditions")
if invoice_info.get("payment_instructions"):
invoice_kwargs["payment_instructions"] = invoice_info.get("payment_instructions")
# Get discount from booking
booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0
# Create invoices based on payment method
if payment_method == "cash":
# For cash bookings: create invoice for 20% deposit + proforma for 80% remaining
deposit_amount = float(total_price) * 0.2
remaining_amount = float(total_price) * 0.8
# Create invoice for deposit (20%)
deposit_invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking.id,
db=db,
created_by_id=current_user.id,
tax_rate=tax_rate,
discount_amount=booking_discount,
due_days=30,
is_proforma=False,
invoice_amount=deposit_amount,
**invoice_kwargs
)
# Create proforma invoice for remaining amount (80%)
proforma_invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking.id,
db=db,
created_by_id=current_user.id,
tax_rate=tax_rate,
discount_amount=booking_discount,
due_days=30,
is_proforma=True,
invoice_amount=remaining_amount,
**invoice_kwargs
)
# Send deposit invoice via email
try:
invoice_html = _generate_invoice_email_html(deposit_invoice, is_proforma=False)
await send_email(
to=current_user.email,
subject=f"Invoice {deposit_invoice['invoice_number']} - Deposit Payment",
html=invoice_html
)
logger.info(f"Deposit invoice sent to {current_user.email}")
except Exception as email_error:
logger.error(f"Failed to send deposit invoice email: {str(email_error)}")
# Send proforma invoice via email
try:
proforma_html = _generate_invoice_email_html(proforma_invoice, is_proforma=True)
await send_email(
to=current_user.email,
subject=f"Proforma Invoice {proforma_invoice['invoice_number']} - Remaining Balance",
html=proforma_html
)
logger.info(f"Proforma invoice sent to {current_user.email}")
except Exception as email_error:
logger.error(f"Failed to send proforma invoice email: {str(email_error)}")
else:
# For full payment (Stripe/PayPal): create full invoice
# Invoice will be created and sent after payment is confirmed
# We create it now as draft, and it will be updated when payment is confirmed
full_invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking.id,
db=db,
created_by_id=current_user.id,
tax_rate=tax_rate,
discount_amount=booking_discount,
due_days=30,
is_proforma=False,
**invoice_kwargs
)
# Don't send invoice email yet - will be sent after payment is confirmed
# The invoice will be updated and sent when payment is completed
logger.info(f"Invoice {full_invoice['invoice_number']} created for booking {booking.id} (will be sent after payment confirmation)")
except Exception as e: except Exception as e:
# Log error but don't fail booking creation if invoice creation fails # Log error but don't fail booking creation if invoice creation fails
import logging import logging
@@ -511,32 +926,7 @@ async def create_booking(
"capacity": booking.room.room_type.capacity, "capacity": booking.room.room_type.capacity,
} }
# Send booking confirmation email (non-blocking) # Don't send email here - emails will be sent when booking is confirmed or cancelled
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
room = db.query(Room).filter(Room.id == room_id).first()
room_type_name = room.room_type.name if room and room.room_type else "Room"
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=current_user.full_name,
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=check_in.strftime("%B %d, %Y"),
check_out=check_out.strftime("%B %d, %Y"),
num_guests=guest_count,
total_price=float(total_price),
requires_deposit=requires_deposit,
deposit_amount=deposit_amount if requires_deposit else None,
client_url=client_url
)
await send_email(
to=current_user.email,
subject=f"Booking Confirmation - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send booking confirmation email: {e}")
return { return {
"success": True, "success": True,
@@ -678,7 +1068,11 @@ async def get_booking_by_id(
"id": p.id, "id": p.id,
"amount": float(p.amount) if p.amount else 0.0, "amount": float(p.amount) if p.amount else 0.0,
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
"transaction_id": p.transaction_id,
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
} }
for p in booking.payments for p in booking.payments
] ]
@@ -757,7 +1151,12 @@ async def cancel_booking(
# Send cancellation email (non-blocking) # Send cancellation email (non-blocking)
try: try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") from ..models.system_settings import SystemSettings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
email_html = booking_status_changed_email_template( email_html = booking_status_changed_email_template(
booking_number=booking.booking_number, booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest", guest_name=booking.user.full_name if booking.user else "Guest",
@@ -770,7 +1169,9 @@ async def cancel_booking(
html=email_html html=email_html
) )
except Exception as e: except Exception as e:
print(f"Failed to send cancellation email: {e}") import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send cancellation email: {e}")
return { return {
"success": True, "success": True,
@@ -792,7 +1193,10 @@ async def update_booking(
): ):
"""Update booking status (Admin only)""" """Update booking status (Admin only)"""
try: try:
booking = db.query(Booking).filter(Booking.id == id).first() # Load booking with payments to check balance
booking = db.query(Booking).options(
selectinload(Booking.payments)
).filter(Booking.id == id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail="Booking not found") raise HTTPException(status_code=404, detail="Booking not found")
@@ -807,29 +1211,105 @@ async def update_booking(
db.commit() db.commit()
db.refresh(booking) db.refresh(booking)
# Send status change email if status changed (non-blocking) # Check payment balance if status changed to checked_in
if status_value and old_status != booking.status: payment_warning = None
try: if status_value and old_status != booking.status and booking.status == BookingStatus.checked_in:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") payment_balance = calculate_booking_payment_balance(booking)
email_html = booking_status_changed_email_template( if payment_balance["remaining_balance"] > 0.01: # More than 1 cent remaining
booking_number=booking.booking_number, payment_warning = {
guest_name=booking.user.full_name if booking.user else "Guest", "message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}",
status=booking.status.value, "total_paid": payment_balance["total_paid"],
client_url=client_url "total_price": payment_balance["total_price"],
) "remaining_balance": payment_balance["remaining_balance"],
await send_email( "payment_percentage": payment_balance["payment_percentage"]
to=booking.user.email if booking.user else None, }
subject=f"Booking Status Updated - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send status change email: {e}")
return { # Send status change email only if status changed to confirmed or cancelled (non-blocking)
if status_value and old_status != booking.status:
if booking.status in [BookingStatus.confirmed, BookingStatus.cancelled]:
try:
from ..models.system_settings import SystemSettings
from ..services.room_service import get_base_url
from fastapi import Request
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
if booking.status == BookingStatus.confirmed:
# Send booking confirmation email with full details
from sqlalchemy.orm import selectinload
booking_with_room = db.query(Booking).options(
selectinload(Booking.room).selectinload(Room.room_type)
).filter(Booking.id == booking.id).first()
room = booking_with_room.room if booking_with_room else None
room_type_name = room.room_type.name if room and room.room_type else "Room"
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
num_guests=booking.num_guests,
total_price=float(booking.total_price),
requires_deposit=booking.requires_deposit,
deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None,
original_price=float(booking.original_price) if booking.original_price else None,
discount_amount=float(booking.discount_amount) if booking.discount_amount else None,
promotion_code=booking.promotion_code,
client_url=client_url,
currency_symbol=currency_symbol
)
await send_email(
to=booking.user.email if booking.user else None,
subject=f"Booking Confirmed - {booking.booking_number}",
html=email_html
)
elif booking.status == BookingStatus.cancelled:
# Send cancellation email
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status="cancelled",
client_url=client_url
)
await send_email(
to=booking.user.email if booking.user else None,
subject=f"Booking Cancelled - {booking.booking_number}",
html=email_html
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send status change email: {e}")
response_data = {
"status": "success", "status": "success",
"message": "Booking updated successfully", "message": "Booking updated successfully",
"data": {"booking": booking} "data": {"booking": booking}
} }
# Add payment warning if there's remaining balance during check-in
if payment_warning:
response_data["warning"] = payment_warning
response_data["message"] = "Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance."
return response_data
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -844,30 +1324,126 @@ async def check_booking_by_number(
): ):
"""Check booking by booking number""" """Check booking by booking number"""
try: try:
booking = db.query(Booking).filter(Booking.booking_number == booking_number).first() booking = db.query(Booking).options(
selectinload(Booking.payments),
joinedload(Booking.user),
joinedload(Booking.room).joinedload(Room.room_type)
).filter(Booking.booking_number == booking_number).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail="Booking not found") raise HTTPException(status_code=404, detail="Booking not found")
# Determine payment_method and payment_status from payments
payment_method_from_payments = None
payment_status_from_payments = "unpaid"
if booking.payments:
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = "paid"
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = "refunded"
booking_dict = { booking_dict = {
"id": booking.id, "id": booking.id,
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
"user_id": booking.user_id,
"room_id": booking.room_id, "room_id": booking.room_id,
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"num_guests": booking.num_guests,
"guest_count": booking.num_guests,
"total_price": float(booking.total_price) if booking.total_price else 0.0,
"original_price": float(booking.original_price) if booking.original_price else None,
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
"promotion_code": booking.promotion_code,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
"payment_status": payment_status_from_payments,
"deposit_paid": booking.deposit_paid,
"requires_deposit": booking.requires_deposit,
"special_requests": booking.special_requests,
"notes": booking.special_requests,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
} }
# Add user info
if booking.user:
booking_dict["user"] = {
"id": booking.user.id,
"name": booking.user.full_name,
"full_name": booking.user.full_name,
"email": booking.user.email,
"phone": booking.user.phone,
"phone_number": booking.user.phone,
}
# Add room info
if booking.room: if booking.room:
booking_dict["room"] = { booking_dict["room"] = {
"id": booking.room.id, "id": booking.room.id,
"room_number": booking.room.room_number, "room_number": booking.room.room_number,
"floor": booking.room.floor,
} }
if booking.room.room_type:
booking_dict["room"]["room_type"] = {
"id": booking.room.room_type.id,
"name": booking.room.room_type.name,
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
"capacity": booking.room.room_type.capacity,
}
return { # Add payments
if booking.payments:
booking_dict["payments"] = [
{
"id": p.id,
"amount": float(p.amount) if p.amount else 0.0,
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
"transaction_id": p.transaction_id,
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
}
for p in booking.payments
]
else:
booking_dict["payments"] = []
# Calculate and add payment balance information
payment_balance = calculate_booking_payment_balance(booking)
booking_dict["payment_balance"] = {
"total_paid": payment_balance["total_paid"],
"total_price": payment_balance["total_price"],
"remaining_balance": payment_balance["remaining_balance"],
"is_fully_paid": payment_balance["is_fully_paid"],
"payment_percentage": payment_balance["payment_percentage"]
}
# Add warning if there's remaining balance (useful for check-in)
response_data = {
"status": "success", "status": "success",
"data": {"booking": booking_dict} "data": {"booking": booking_dict}
} }
if payment_balance["remaining_balance"] > 0.01:
response_data["warning"] = {
"message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}",
"remaining_balance": payment_balance["remaining_balance"],
"payment_percentage": payment_balance["payment_percentage"]
}
return response_data
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@@ -25,7 +25,15 @@ class ContactForm(BaseModel):
def get_admin_email(db: Session) -> str: def get_admin_email(db: Session) -> str:
"""Get admin email from system settings or find admin user""" """Get admin email from system settings or find admin user"""
# First, try to get from system settings # First, try to get from company_email (company settings)
company_email_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_email"
).first()
if company_email_setting and company_email_setting.value:
return company_email_setting.value
# Second, try to get from admin_email (legacy setting)
admin_email_setting = db.query(SystemSettings).filter( admin_email_setting = db.query(SystemSettings).filter(
SystemSettings.key == "admin_email" SystemSettings.key == "admin_email"
).first() ).first()
@@ -52,7 +60,7 @@ def get_admin_email(db: Session) -> str:
# Last resort: raise error # Last resort: raise error
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Admin email not configured. Please set admin_email in system settings or ensure an admin user exists." detail="Admin email not configured. Please set company_email in system settings or ensure an admin user exists."
) )

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload, selectinload
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
import os import os
@@ -11,13 +11,51 @@ from ..models.user import User
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking, BookingStatus from ..models.booking import Booking, BookingStatus
from ..utils.mailer import send_email from ..utils.mailer import send_email
from ..utils.email_templates import payment_confirmation_email_template from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
from ..services.stripe_service import StripeService from ..services.stripe_service import StripeService
from ..services.paypal_service import PayPalService from ..services.paypal_service import PayPalService
router = APIRouter(prefix="/payments", tags=["payments"]) router = APIRouter(prefix="/payments", tags=["payments"])
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str = "Payment failed or canceled"):
"""
Helper function to cancel a booking when payment fails or is canceled.
This bypasses the normal cancellation restrictions and sends cancellation email.
"""
if booking.status == BookingStatus.cancelled:
return # Already cancelled
booking.status = BookingStatus.cancelled
db.commit()
db.refresh(booking)
# Send cancellation email (non-blocking)
try:
from ..models.system_settings import SystemSettings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
if booking.user:
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status="cancelled",
client_url=client_url
)
await send_email(
to=booking.user.email,
subject=f"Booking Cancelled - {booking.booking_number}",
html=email_html
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send cancellation email: {e}")
@router.get("/") @router.get("/")
async def get_payments( async def get_payments(
booking_id: Optional[int] = Query(None), booking_id: Optional[int] = Query(None),
@@ -29,11 +67,11 @@ async def get_payments(
): ):
"""Get all payments""" """Get all payments"""
try: try:
query = db.query(Payment) # Build base query
# Filter by booking_id
if booking_id: if booking_id:
query = query.filter(Payment.booking_id == booking_id) query = db.query(Payment).filter(Payment.booking_id == booking_id)
else:
query = db.query(Payment)
# Filter by status # Filter by status
if status_filter: if status_filter:
@@ -46,7 +84,14 @@ async def get_payments(
if current_user.role_id != 1: # Not admin if current_user.role_id != 1: # Not admin
query = query.join(Booking).filter(Booking.user_id == current_user.id) query = query.join(Booking).filter(Booking.user_id == current_user.id)
# Get total count before applying eager loading
total = query.count() total = query.count()
# Load payments with booking and user relationships using selectinload to avoid join conflicts
query = query.options(
selectinload(Payment.booking).selectinload(Booking.user)
)
offset = (page - 1) * limit offset = (page - 1) * limit
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all() payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
@@ -72,6 +117,14 @@ async def get_payments(
"id": payment.booking.id, "id": payment.booking.id,
"booking_number": payment.booking.booking_number, "booking_number": payment.booking.booking_number,
} }
# Include user information if available
if payment.booking.user:
payment_dict["booking"]["user"] = {
"id": payment.booking.user.id,
"name": payment.booking.user.full_name,
"full_name": payment.booking.user.full_name,
"email": payment.booking.user.email,
}
result.append(payment_dict) result.append(payment_dict)
@@ -87,8 +140,13 @@ async def get_payments(
}, },
}, },
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) import logging
logger = logging.getLogger(__name__)
logger.error(f"Error fetching payments: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Error fetching payments: {str(e)}")
@router.get("/booking/{booking_id}") @router.get("/booking/{booking_id}")
@@ -108,8 +166,10 @@ async def get_payments_by_booking_id(
if current_user.role_id != 1 and booking.user_id != current_user.id: if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
# Get all payments for this booking # Get all payments for this booking with user relationship
payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all() payments = db.query(Payment).options(
joinedload(Payment.booking).joinedload(Booking.user)
).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
result = [] result = []
for payment in payments: for payment in payments:
@@ -133,6 +193,14 @@ async def get_payments_by_booking_id(
"id": payment.booking.id, "id": payment.booking.id,
"booking_number": payment.booking.booking_number, "booking_number": payment.booking.booking_number,
} }
# Include user information if available
if payment.booking.user:
payment_dict["booking"]["user"] = {
"id": payment.booking.user.id,
"name": payment.booking.user.full_name,
"full_name": payment.booking.user.full_name,
"email": payment.booking.user.email,
}
result.append(payment_dict) result.append(payment_dict)
@@ -241,14 +309,34 @@ async def create_payment(
# Send payment confirmation email if payment was marked as paid (non-blocking) # Send payment confirmation email if payment was marked as paid (non-blocking)
if payment.payment_status == PaymentStatus.completed and booking.user: if payment.payment_status == PaymentStatus.completed and booking.user:
try: try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") from ..models.system_settings import SystemSettings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
email_html = payment_confirmation_email_template( email_html = payment_confirmation_email_template(
booking_number=booking.booking_number, booking_number=booking.booking_number,
guest_name=booking.user.full_name, guest_name=booking.user.full_name,
amount=float(payment.amount), amount=float(payment.amount),
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
transaction_id=payment.transaction_id, transaction_id=payment.transaction_id,
client_url=client_url payment_type=payment.payment_type.value if payment.payment_type else None,
total_price=float(booking.total_price),
client_url=client_url,
currency_symbol=currency_symbol
) )
await send_email( await send_email(
to=booking.user.email, to=booking.user.email,
@@ -256,7 +344,9 @@ async def create_payment(
html=email_html html=email_html
) )
except Exception as e: except Exception as e:
print(f"Failed to send payment confirmation email: {e}") import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send payment confirmation email: {e}")
return { return {
"status": "success", "status": "success",
@@ -284,16 +374,28 @@ async def update_payment_status(
raise HTTPException(status_code=404, detail="Payment not found") raise HTTPException(status_code=404, detail="Payment not found")
status_value = status_data.get("status") status_value = status_data.get("status")
old_status = payment.payment_status
if status_value: if status_value:
try: try:
payment.payment_status = PaymentStatus(status_value) new_status = PaymentStatus(status_value)
payment.payment_status = new_status
# Auto-cancel booking if payment is marked as failed or refunded
if new_status in [PaymentStatus.failed, PaymentStatus.refunded]:
booking = db.query(Booking).filter(Booking.id == payment.booking_id).first()
if booking and booking.status != BookingStatus.cancelled:
await cancel_booking_on_payment_failure(
booking,
db,
reason=f"Payment {new_status.value}"
)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid payment status") raise HTTPException(status_code=400, detail="Invalid payment status")
if status_data.get("transaction_id"): if status_data.get("transaction_id"):
payment.transaction_id = status_data["transaction_id"] payment.transaction_id = status_data["transaction_id"]
old_status = payment.payment_status
if status_data.get("mark_as_paid"): if status_data.get("mark_as_paid"):
payment.payment_status = PaymentStatus.completed payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow() payment.payment_date = datetime.utcnow()
@@ -304,17 +406,50 @@ async def update_payment_status(
# Send payment confirmation email if payment was just completed (non-blocking) # Send payment confirmation email if payment was just completed (non-blocking)
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
try: try:
from ..models.system_settings import SystemSettings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
# Refresh booking relationship # Refresh booking relationship
payment = db.query(Payment).filter(Payment.id == id).first() payment = db.query(Payment).filter(Payment.id == id).first()
if payment.booking and payment.booking.user: if payment.booking and payment.booking.user:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") # Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
email_html = payment_confirmation_email_template( email_html = payment_confirmation_email_template(
booking_number=payment.booking.booking_number, booking_number=payment.booking.booking_number,
guest_name=payment.booking.user.full_name, guest_name=payment.booking.user.full_name,
amount=float(payment.amount), amount=float(payment.amount),
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
transaction_id=payment.transaction_id, transaction_id=payment.transaction_id,
client_url=client_url client_url=client_url,
currency_symbol=currency_symbol
) )
await send_email( await send_email(
to=payment.booking.user.email, to=payment.booking.user.email,
@@ -325,10 +460,22 @@ async def update_payment_status(
# If this is a deposit payment, update booking deposit_paid status # If this is a deposit payment, update booking deposit_paid status
if payment.payment_type == PaymentType.deposit and payment.booking: if payment.payment_type == PaymentType.deposit and payment.booking:
payment.booking.deposit_paid = True payment.booking.deposit_paid = True
# Optionally auto-confirm booking if deposit is paid # Restore cancelled bookings or confirm pending bookings when deposit is paid
if payment.booking.status == BookingStatus.pending: if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
payment.booking.status = BookingStatus.confirmed payment.booking.status = BookingStatus.confirmed
db.commit() db.commit()
# If this is a full payment, also restore cancelled bookings
elif payment.payment_type == PaymentType.full and payment.booking:
# Calculate total paid from all completed payments
total_paid = sum(
float(p.amount) for p in payment.booking.payments
if p.payment_status == PaymentStatus.completed
)
# Confirm booking if fully paid, and restore cancelled bookings
if total_paid >= float(payment.booking.total_price):
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
payment.booking.status = BookingStatus.confirmed
db.commit()
except Exception as e: except Exception as e:
print(f"Failed to send payment confirmation email: {e}") print(f"Failed to send payment confirmation email: {e}")
@@ -395,6 +542,29 @@ async def create_stripe_payment_intent(
if current_user.role_id != 1 and booking.user_id != current_user.id: if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
# For deposit payments, verify the amount matches the deposit payment record
# This ensures users are only charged the deposit (20%) and not the full amount
if booking.requires_deposit and not booking.deposit_paid:
deposit_payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_type == PaymentType.deposit,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
if deposit_payment:
expected_deposit_amount = float(deposit_payment.amount)
# Allow small floating point differences (0.01)
if abs(amount - expected_deposit_amount) > 0.01:
logger.warning(
f"Amount mismatch for deposit payment: "
f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, "
f"Booking total ${float(booking.total_price):,.2f}"
)
raise HTTPException(
status_code=400,
detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})."
)
# Create payment intent # Create payment intent
intent = StripeService.create_payment_intent( intent = StripeService.create_payment_intent(
amount=amount, amount=amount,
@@ -472,7 +642,7 @@ async def confirm_stripe_payment(
) )
# Confirm payment (this commits the transaction internally) # Confirm payment (this commits the transaction internally)
payment = StripeService.confirm_payment( payment = await StripeService.confirm_payment(
payment_intent_id=payment_intent_id, payment_intent_id=payment_intent_id,
db=db, db=db,
booking_id=booking_id booking_id=booking_id
@@ -495,14 +665,34 @@ async def confirm_stripe_payment(
# This won't affect the transaction since it's already committed # This won't affect the transaction since it's already committed
if booking and booking.user: if booking and booking.user:
try: try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") from ..models.system_settings import SystemSettings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
email_html = payment_confirmation_email_template( email_html = payment_confirmation_email_template(
booking_number=booking.booking_number, booking_number=booking.booking_number,
guest_name=booking.user.full_name, guest_name=booking.user.full_name,
amount=payment["amount"], amount=payment["amount"],
payment_method="stripe", payment_method="stripe",
transaction_id=payment["transaction_id"], transaction_id=payment["transaction_id"],
client_url=client_url payment_type=payment.get("payment_type"),
total_price=float(booking.total_price),
client_url=client_url,
currency_symbol=currency_symbol
) )
await send_email( await send_email(
to=booking.user.email, to=booking.user.email,
@@ -574,7 +764,7 @@ async def stripe_webhook(
detail="Missing stripe-signature header" detail="Missing stripe-signature header"
) )
result = StripeService.handle_webhook( result = await StripeService.handle_webhook(
payload=payload, payload=payload,
signature=signature, signature=signature,
db=db db=db
@@ -640,6 +830,31 @@ async def create_paypal_order(
if current_user.role_id != 1 and booking.user_id != current_user.id: if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
# For deposit payments, verify the amount matches the deposit payment record
# This ensures users are only charged the deposit (20%) and not the full amount
if booking.requires_deposit and not booking.deposit_paid:
deposit_payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_type == PaymentType.deposit,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
if deposit_payment:
expected_deposit_amount = float(deposit_payment.amount)
# Allow small floating point differences (0.01)
if abs(amount - expected_deposit_amount) > 0.01:
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"Amount mismatch for deposit payment: "
f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, "
f"Booking total ${float(booking.total_price):,.2f}"
)
raise HTTPException(
status_code=400,
detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})."
)
# Get return URLs from request or use defaults # Get return URLs from request or use defaults
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return") return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return")
@@ -689,6 +904,63 @@ async def create_paypal_order(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/paypal/cancel")
async def cancel_paypal_payment(
payment_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Mark PayPal payment as failed and cancel booking when user cancels on PayPal"""
try:
booking_id = payment_data.get("booking_id")
if not booking_id:
raise HTTPException(
status_code=400,
detail="booking_id is required"
)
# Find pending PayPal payment for this booking
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_method == PaymentMethod.paypal,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
# Also check for deposit payments
if not payment:
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_type == PaymentType.deposit,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
if payment:
payment.payment_status = PaymentStatus.failed
db.commit()
db.refresh(payment)
# Auto-cancel booking
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if booking and booking.status != BookingStatus.cancelled:
await cancel_booking_on_payment_failure(
booking,
db,
reason="PayPal payment canceled by user"
)
return {
"status": "success",
"message": "Payment canceled and booking cancelled"
}
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/paypal/capture") @router.post("/paypal/capture")
async def capture_paypal_payment( async def capture_paypal_payment(
payment_data: dict, payment_data: dict,
@@ -707,7 +979,7 @@ async def capture_paypal_payment(
) )
# Confirm payment (this commits the transaction internally) # Confirm payment (this commits the transaction internally)
payment = PayPalService.confirm_payment( payment = await PayPalService.confirm_payment(
order_id=order_id, order_id=order_id,
db=db, db=db,
booking_id=booking_id booking_id=booking_id
@@ -727,14 +999,34 @@ async def capture_paypal_payment(
# Send payment confirmation email (non-blocking) # Send payment confirmation email (non-blocking)
if booking and booking.user: if booking and booking.user:
try: try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") from ..models.system_settings import SystemSettings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
email_html = payment_confirmation_email_template( email_html = payment_confirmation_email_template(
booking_number=booking.booking_number, booking_number=booking.booking_number,
guest_name=booking.user.full_name, guest_name=booking.user.full_name,
amount=payment["amount"], amount=payment["amount"],
payment_method="paypal", payment_method="paypal",
transaction_id=payment["transaction_id"], transaction_id=payment["transaction_id"],
client_url=client_url payment_type=payment.get("payment_type"),
total_price=float(booking.total_price),
client_url=client_url,
currency_symbol=currency_symbol
) )
await send_email( await send_email(
to=booking.user.email, to=booking.user.email,

View File

@@ -128,7 +128,8 @@ async def validate_promotion(
"""Validate and apply promotion""" """Validate and apply promotion"""
try: try:
code = validation_data.get("code") code = validation_data.get("code")
booking_amount = float(validation_data.get("booking_amount", 0)) # Accept both booking_value (from frontend) and booking_amount (for backward compatibility)
booking_amount = float(validation_data.get("booking_value") or validation_data.get("booking_amount", 0))
promotion = db.query(Promotion).filter(Promotion.code == code).first() promotion = db.query(Promotion).filter(Promotion.code == code).first()
if not promotion: if not promotion:
@@ -161,17 +162,30 @@ async def validate_promotion(
final_amount = booking_amount - discount_amount final_amount = booking_amount - discount_amount
return { return {
"success": True,
"status": "success", "status": "success",
"data": { "data": {
"promotion": { "promotion": {
"id": promotion.id, "id": promotion.id,
"code": promotion.code, "code": promotion.code,
"name": promotion.name, "name": promotion.name,
"description": promotion.description,
"discount_type": promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type),
"discount_value": float(promotion.discount_value) if promotion.discount_value else 0,
"min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None,
"max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None,
"start_date": promotion.start_date.isoformat() if promotion.start_date else None,
"end_date": promotion.end_date.isoformat() if promotion.end_date else None,
"usage_limit": promotion.usage_limit,
"used_count": promotion.used_count,
"status": "active" if promotion.is_active else "inactive",
}, },
"discount": discount_amount,
"original_amount": booking_amount, "original_amount": booking_amount,
"discount_amount": discount_amount, "discount_amount": discount_amount,
"final_amount": final_amount, "final_amount": final_amount,
} },
"message": "Promotion validated successfully"
} }
except HTTPException: except HTTPException:
raise raise

View File

@@ -895,6 +895,7 @@ class UpdateCompanySettingsRequest(BaseModel):
company_phone: Optional[str] = None company_phone: Optional[str] = None
company_email: Optional[str] = None company_email: Optional[str] = None
company_address: Optional[str] = None company_address: Optional[str] = None
tax_rate: Optional[float] = None
@router.get("/company") @router.get("/company")
@@ -911,6 +912,7 @@ async def get_company_settings(
"company_phone", "company_phone",
"company_email", "company_email",
"company_address", "company_address",
"tax_rate",
] ]
settings_dict = {} settings_dict = {}
@@ -944,6 +946,7 @@ async def get_company_settings(
"company_phone": settings_dict.get("company_phone", ""), "company_phone": settings_dict.get("company_phone", ""),
"company_email": settings_dict.get("company_email", ""), "company_email": settings_dict.get("company_email", ""),
"company_address": settings_dict.get("company_address", ""), "company_address": settings_dict.get("company_address", ""),
"tax_rate": float(settings_dict.get("tax_rate", 0)) if settings_dict.get("tax_rate") else 0.0,
"updated_at": updated_at, "updated_at": updated_at,
"updated_by": updated_by, "updated_by": updated_by,
} }
@@ -972,6 +975,8 @@ async def update_company_settings(
db_settings["company_email"] = request_data.company_email db_settings["company_email"] = request_data.company_email
if request_data.company_address is not None: if request_data.company_address is not None:
db_settings["company_address"] = request_data.company_address db_settings["company_address"] = request_data.company_address
if request_data.tax_rate is not None:
db_settings["tax_rate"] = str(request_data.tax_rate)
for key, value in db_settings.items(): for key, value in db_settings.items():
# Find or create setting # Find or create setting
@@ -997,7 +1002,7 @@ async def update_company_settings(
# Get updated settings # Get updated settings
updated_settings = {} updated_settings = {}
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address"]: for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate"]:
setting = db.query(SystemSettings).filter( setting = db.query(SystemSettings).filter(
SystemSettings.key == key SystemSettings.key == key
).first() ).first()
@@ -1032,6 +1037,7 @@ async def update_company_settings(
"company_phone": updated_settings.get("company_phone", ""), "company_phone": updated_settings.get("company_phone", ""),
"company_email": updated_settings.get("company_email", ""), "company_email": updated_settings.get("company_email", ""),
"company_address": updated_settings.get("company_address", ""), "company_address": updated_settings.get("company_address", ""),
"tax_rate": float(updated_settings.get("tax_rate", 0)) if updated_settings.get("tax_rate") else 0.0,
"updated_at": updated_at, "updated_at": updated_at,
"updated_by": updated_by, "updated_by": updated_by,
} }
@@ -1243,3 +1249,272 @@ async def upload_company_favicon(
logger.error(f"Error uploading favicon: {e}", exc_info=True) logger.error(f"Error uploading favicon: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/recaptcha")
async def get_recaptcha_settings(
db: Session = Depends(get_db)
):
"""Get reCAPTCHA settings (Public endpoint for frontend)"""
try:
site_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_site_key"
).first()
enabled_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_enabled"
).first()
result = {
"recaptcha_site_key": "",
"recaptcha_enabled": False,
}
if site_key_setting:
result["recaptcha_site_key"] = site_key_setting.value or ""
if enabled_setting:
result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False
return {
"status": "success",
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/recaptcha/admin")
async def get_recaptcha_settings_admin(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get reCAPTCHA settings (Admin only - includes secret key)"""
try:
site_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_site_key"
).first()
secret_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_secret_key"
).first()
enabled_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_enabled"
).first()
# Mask secret for security (only show last 4 characters)
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
return "*" * (len(key_value) - 4) + key_value[-4:]
result = {
"recaptcha_site_key": "",
"recaptcha_secret_key": "",
"recaptcha_secret_key_masked": "",
"recaptcha_enabled": False,
"has_site_key": False,
"has_secret_key": False,
}
if site_key_setting:
result["recaptcha_site_key"] = site_key_setting.value or ""
result["has_site_key"] = bool(site_key_setting.value)
result["updated_at"] = site_key_setting.updated_at.isoformat() if site_key_setting.updated_at else None
result["updated_by"] = site_key_setting.updated_by.full_name if site_key_setting.updated_by else None
if secret_key_setting:
result["recaptcha_secret_key"] = secret_key_setting.value or ""
result["recaptcha_secret_key_masked"] = mask_key(secret_key_setting.value or "")
result["has_secret_key"] = bool(secret_key_setting.value)
if enabled_setting:
result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False
return {
"status": "success",
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/recaptcha")
async def update_recaptcha_settings(
recaptcha_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update reCAPTCHA settings (Admin only)"""
try:
site_key = recaptcha_data.get("recaptcha_site_key", "").strip()
secret_key = recaptcha_data.get("recaptcha_secret_key", "").strip()
enabled = recaptcha_data.get("recaptcha_enabled", False)
# Update or create site key setting
if site_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_site_key"
).first()
if setting:
setting.value = site_key
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key="recaptcha_site_key",
value=site_key,
description="Google reCAPTCHA site key for frontend",
updated_by_id=current_user.id
)
db.add(setting)
# Update or create secret key setting
if secret_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_secret_key"
).first()
if setting:
setting.value = secret_key
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key="recaptcha_secret_key",
value=secret_key,
description="Google reCAPTCHA secret key for backend verification",
updated_by_id=current_user.id
)
db.add(setting)
# Update or create enabled setting
setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_enabled"
).first()
if setting:
setting.value = str(enabled).lower()
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key="recaptcha_enabled",
value=str(enabled).lower(),
description="Enable or disable reCAPTCHA verification",
updated_by_id=current_user.id
)
db.add(setting)
db.commit()
# Return masked values
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
return "*" * (len(key_value) - 4) + key_value[-4:]
return {
"status": "success",
"message": "reCAPTCHA settings updated successfully",
"data": {
"recaptcha_site_key": site_key if site_key else "",
"recaptcha_secret_key": secret_key if secret_key else "",
"recaptcha_secret_key_masked": mask_key(secret_key) if secret_key else "",
"recaptcha_enabled": enabled,
"has_site_key": bool(site_key),
"has_secret_key": bool(secret_key),
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/recaptcha/verify")
async def verify_recaptcha(
verification_data: dict,
db: Session = Depends(get_db)
):
"""Verify reCAPTCHA token (Public endpoint)"""
try:
token = verification_data.get("token", "").strip()
if not token:
raise HTTPException(
status_code=400,
detail="reCAPTCHA token is required"
)
# Get reCAPTCHA settings
enabled_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_enabled"
).first()
secret_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_secret_key"
).first()
# Check if reCAPTCHA is enabled
is_enabled = False
if enabled_setting:
is_enabled = enabled_setting.value.lower() == "true" if enabled_setting.value else False
if not is_enabled:
# If disabled, always return success
return {
"status": "success",
"data": {
"verified": True,
"message": "reCAPTCHA is disabled"
}
}
if not secret_key_setting or not secret_key_setting.value:
raise HTTPException(
status_code=500,
detail="reCAPTCHA secret key is not configured"
)
# Verify with Google reCAPTCHA API
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"https://www.google.com/recaptcha/api/siteverify",
data={
"secret": secret_key_setting.value,
"response": token
},
timeout=10.0
)
result = response.json()
if result.get("success"):
return {
"status": "success",
"data": {
"verified": True,
"score": result.get("score"), # For v3
"action": result.get("action") # For v3
}
}
else:
error_codes = result.get("error-codes", [])
return {
"status": "error",
"data": {
"verified": False,
"error_codes": error_codes
}
}
except httpx.TimeoutException:
raise HTTPException(
status_code=408,
detail="reCAPTCHA verification timeout"
)
except Exception as e:
logger.error(f"Error verifying reCAPTCHA: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -11,14 +11,15 @@ from ..models.payment import Payment, PaymentStatus
from ..models.user import User from ..models.user import User
def generate_invoice_number(db: Session) -> str: def generate_invoice_number(db: Session, is_proforma: bool = False) -> str:
"""Generate a unique invoice number""" """Generate a unique invoice number"""
# Format: INV-YYYYMMDD-XXXX # Format: INV-YYYYMMDD-XXXX or PRO-YYYYMMDD-XXXX for proforma
prefix = "PRO" if is_proforma else "INV"
today = datetime.utcnow().strftime("%Y%m%d") today = datetime.utcnow().strftime("%Y%m%d")
# Get the last invoice number for today # Get the last invoice number for today
last_invoice = db.query(Invoice).filter( last_invoice = db.query(Invoice).filter(
Invoice.invoice_number.like(f"INV-{today}-%") Invoice.invoice_number.like(f"{prefix}-{today}-%")
).order_by(Invoice.invoice_number.desc()).first() ).order_by(Invoice.invoice_number.desc()).first()
if last_invoice: if last_invoice:
@@ -31,7 +32,7 @@ def generate_invoice_number(db: Session) -> str:
else: else:
sequence = 1 sequence = 1
return f"INV-{today}-{sequence:04d}" return f"{prefix}-{today}-{sequence:04d}"
class InvoiceService: class InvoiceService:
@@ -45,6 +46,8 @@ class InvoiceService:
tax_rate: float = 0.0, tax_rate: float = 0.0,
discount_amount: float = 0.0, discount_amount: float = 0.0,
due_days: int = 30, due_days: int = 30,
is_proforma: bool = False,
invoice_amount: Optional[float] = None, # For partial invoices (e.g., deposit)
**kwargs **kwargs
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
@@ -77,11 +80,17 @@ class InvoiceService:
raise ValueError("User not found") raise ValueError("User not found")
# Generate invoice number # Generate invoice number
invoice_number = generate_invoice_number(db) invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
# If invoice_amount is specified, we need to adjust item calculations
# This will be handled in the item creation section below
# Calculate amounts - subtotal will be recalculated after adding items # Calculate amounts - subtotal will be recalculated after adding items
# Initial subtotal is booking total (room + services) # Initial subtotal is booking total (room + services) or invoice_amount if specified
subtotal = float(booking.total_price) if invoice_amount is not None:
subtotal = float(invoice_amount)
else:
subtotal = float(booking.total_price)
# Calculate tax and total amounts # Calculate tax and total amounts
tax_amount = (subtotal - discount_amount) * (tax_rate / 100) tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
@@ -121,6 +130,7 @@ class InvoiceService:
amount_paid=amount_paid, amount_paid=amount_paid,
balance_due=balance_due, balance_due=balance_due,
status=status, status=status,
is_proforma=is_proforma,
company_name=kwargs.get("company_name"), company_name=kwargs.get("company_name"),
company_address=kwargs.get("company_address"), company_address=kwargs.get("company_address"),
company_phone=kwargs.get("company_phone"), company_phone=kwargs.get("company_phone"),
@@ -146,17 +156,28 @@ class InvoiceService:
services_total = sum( services_total = sum(
float(su.total_price) for su in booking.service_usages float(su.total_price) for su in booking.service_usages
) )
room_price = float(booking.total_price) - services_total booking_total = float(booking.total_price)
room_price = booking_total - services_total
# Calculate number of nights # Calculate number of nights
nights = (booking.check_out_date - booking.check_in_date).days nights = (booking.check_out_date - booking.check_in_date).days
if nights <= 0: if nights <= 0:
nights = 1 nights = 1
# If invoice_amount is specified (for partial invoices), calculate proportion
if invoice_amount is not None and invoice_amount < booking_total:
# Calculate proportion for partial invoice
proportion = float(invoice_amount) / booking_total
room_price = room_price * proportion
services_total = services_total * proportion
item_description_suffix = f" (Partial: {proportion * 100:.0f}%)"
else:
item_description_suffix = ""
# Room item # Room item
room_item = InvoiceItem( room_item = InvoiceItem(
invoice_id=invoice.id, invoice_id=invoice.id,
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''})", description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''}){item_description_suffix}",
quantity=nights, quantity=nights,
unit_price=room_price / nights if nights > 0 else room_price, unit_price=room_price / nights if nights > 0 else room_price,
tax_rate=tax_rate, tax_rate=tax_rate,
@@ -168,14 +189,20 @@ class InvoiceService:
# Add service items if any # Add service items if any
for service_usage in booking.service_usages: for service_usage in booking.service_usages:
service_item_price = float(service_usage.total_price)
if invoice_amount is not None and invoice_amount < booking_total:
# Apply proportion to service items
proportion = float(invoice_amount) / booking_total
service_item_price = service_item_price * proportion
service_item = InvoiceItem( service_item = InvoiceItem(
invoice_id=invoice.id, invoice_id=invoice.id,
description=f"Service: {service_usage.service.name}", description=f"Service: {service_usage.service.name}{item_description_suffix}",
quantity=float(service_usage.quantity), quantity=float(service_usage.quantity),
unit_price=float(service_usage.unit_price), unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price,
tax_rate=tax_rate, tax_rate=tax_rate,
discount_amount=0.0, discount_amount=0.0,
line_total=float(service_usage.total_price), line_total=service_item_price,
service_id=service_usage.service_id, service_id=service_usage.service_id,
) )
db.add(service_item) db.add(service_item)
@@ -391,6 +418,7 @@ class InvoiceService:
"notes": invoice.notes, "notes": invoice.notes,
"terms_and_conditions": invoice.terms_and_conditions, "terms_and_conditions": invoice.terms_and_conditions,
"payment_instructions": invoice.payment_instructions, "payment_instructions": invoice.payment_instructions,
"is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False,
"items": [ "items": [
{ {
"id": item.id, "id": item.id,

View File

@@ -1,6 +1,7 @@
""" """
PayPal payment service for processing PayPal payments PayPal payment service for processing PayPal payments
""" """
import logging
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
from paypalcheckoutsdk.payments import CapturesRefundRequest from paypalcheckoutsdk.payments import CapturesRefundRequest
@@ -13,6 +14,8 @@ from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
import json import json
logger = logging.getLogger(__name__)
def get_paypal_client_id(db: Session) -> Optional[str]: def get_paypal_client_id(db: Session) -> Optional[str]:
"""Get PayPal client ID from database or environment variable""" """Get PayPal client ID from database or environment variable"""
@@ -282,7 +285,7 @@ class PayPalService:
raise ValueError(f"PayPal error: {error_msg}") raise ValueError(f"PayPal error: {error_msg}")
@staticmethod @staticmethod
def confirm_payment( async def confirm_payment(
order_id: str, order_id: str,
db: Session, db: Session,
booking_id: Optional[int] = None booking_id: Optional[int] = None
@@ -337,6 +340,15 @@ class PayPalService:
Payment.payment_status == PaymentStatus.pending Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first() ).order_by(Payment.created_at.desc()).first()
# If still not found, try to find pending deposit payment (for cash bookings with deposit)
# This allows updating the payment_method from the default to paypal
if not payment:
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_type == PaymentType.deposit,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
amount = capture_data["amount"] amount = capture_data["amount"]
capture_id = capture_data.get("capture_id") capture_id = capture_data.get("capture_id")
@@ -347,6 +359,7 @@ class PayPalService:
payment.payment_date = datetime.utcnow() payment.payment_date = datetime.utcnow()
# If pending, keep as pending # If pending, keep as pending
payment.amount = amount payment.amount = amount
payment.payment_method = PaymentMethod.paypal # Update payment method to PayPal
if capture_id: if capture_id:
payment.transaction_id = f"{order_id}|{capture_id}" payment.transaction_id = f"{order_id}|{capture_id}"
else: else:
@@ -380,18 +393,142 @@ class PayPalService:
if payment.payment_status == PaymentStatus.completed: if payment.payment_status == PaymentStatus.completed:
db.refresh(booking) db.refresh(booking)
# Calculate total paid from all completed payments (now includes current payment)
# This needs to be calculated before the if/elif blocks
total_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
# Update invoice status based on payment
from ..models.invoice import Invoice, InvoiceStatus
# Find invoices for this booking and update their status
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
for invoice in invoices:
# Update invoice amount_paid and balance_due
invoice.amount_paid = total_paid
invoice.balance_due = float(invoice.total_amount) - total_paid
# Update invoice status
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.amount_paid > 0:
invoice.status = InvoiceStatus.sent
booking_was_confirmed = False
should_send_email = False
if payment.payment_type == PaymentType.deposit: if payment.payment_type == PaymentType.deposit:
booking.deposit_paid = True booking.deposit_paid = True
if booking.status == BookingStatus.pending: # Restore cancelled bookings or confirm pending bookings
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but deposit was just paid
should_send_email = True
elif payment.payment_type == PaymentType.full: elif payment.payment_type == PaymentType.full:
total_paid = sum( # Confirm booking and restore cancelled bookings when payment succeeds
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price): if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
booking.status = BookingStatus.confirmed if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but full payment was just completed
should_send_email = True
# Send booking confirmation email if booking was just confirmed or payment completed
if should_send_email:
try:
from ..utils.mailer import send_email
from ..utils.email_templates import booking_confirmation_email_template
from ..models.system_settings import SystemSettings
from ..models.room import Room
from sqlalchemy.orm import selectinload
import os
from ..config.settings import settings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
# Load booking with room details for email
booking_with_room = db.query(Booking).options(
selectinload(Booking.room).selectinload(Room.room_type)
).filter(Booking.id == booking_id).first()
room = booking_with_room.room if booking_with_room else None
room_type_name = room.room_type.name if room and room.room_type else "Room"
# Calculate amount paid and remaining due
amount_paid = total_paid
payment_type_str = payment.payment_type.value if payment.payment_type else None
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
num_guests=booking.num_guests,
total_price=float(booking.total_price),
requires_deposit=False, # Payment completed, no deposit message needed
deposit_amount=None,
amount_paid=amount_paid,
payment_type=payment_type_str,
client_url=client_url,
currency_symbol=currency_symbol
)
if booking.user:
await send_email(
to=booking.user.email,
subject=f"Booking Confirmed - {booking.booking_number}",
html=email_html
)
logger.info(f"Booking confirmation email sent to {booking.user.email}")
except Exception as email_error:
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..models.invoice import InvoiceStatus
# Load user for email
from ..models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
if user:
await send_email(
to=user.email,
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
html=invoice_html
)
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
except Exception as email_error:
logger.error(f"Failed to send invoice email: {str(email_error)}")
db.commit() db.commit()
db.refresh(booking) db.refresh(booking)

View File

@@ -1,6 +1,7 @@
""" """
Stripe payment service for processing card payments Stripe payment service for processing card payments
""" """
import logging
import stripe import stripe
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from ..config.settings import settings from ..config.settings import settings
@@ -10,6 +11,8 @@ from ..models.system_settings import SystemSettings
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
logger = logging.getLogger(__name__)
def get_stripe_secret_key(db: Session) -> Optional[str]: def get_stripe_secret_key(db: Session) -> Optional[str]:
"""Get Stripe secret key from database or environment variable""" """Get Stripe secret key from database or environment variable"""
@@ -183,7 +186,7 @@ class StripeService:
raise ValueError(f"Stripe error: {str(e)}") raise ValueError(f"Stripe error: {str(e)}")
@staticmethod @staticmethod
def confirm_payment( async def confirm_payment(
payment_intent_id: str, payment_intent_id: str,
db: Session, db: Session,
booking_id: Optional[int] = None booking_id: Optional[int] = None
@@ -230,6 +233,15 @@ class StripeService:
Payment.payment_method == PaymentMethod.stripe Payment.payment_method == PaymentMethod.stripe
).first() ).first()
# If not found, try to find pending deposit payment (for cash bookings with deposit)
# This allows updating the payment_method from the default to stripe
if not payment:
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_type == PaymentType.deposit,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
amount = intent_data["amount"] amount = intent_data["amount"]
if payment: if payment:
@@ -240,6 +252,7 @@ class StripeService:
payment.payment_date = datetime.utcnow() payment.payment_date = datetime.utcnow()
# If processing, keep as pending (will be updated by webhook) # If processing, keep as pending (will be updated by webhook)
payment.amount = amount payment.amount = amount
payment.payment_method = PaymentMethod.stripe # Update payment method to Stripe
else: else:
# Create new payment record # Create new payment record
payment_type = PaymentType.full payment_type = PaymentType.full
@@ -271,25 +284,148 @@ class StripeService:
# Refresh booking to get updated payments relationship # Refresh booking to get updated payments relationship
db.refresh(booking) db.refresh(booking)
# Calculate total paid from all completed payments (now includes current payment)
# This needs to be calculated before the if/elif blocks
total_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
# Update invoice status based on payment
from ..models.invoice import Invoice, InvoiceStatus
from ..services.invoice_service import InvoiceService
# Find invoices for this booking and update their status
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
for invoice in invoices:
# Update invoice amount_paid and balance_due
invoice.amount_paid = total_paid
invoice.balance_due = float(invoice.total_amount) - total_paid
# Update invoice status
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.amount_paid > 0:
invoice.status = InvoiceStatus.sent
booking_was_confirmed = False
should_send_email = False
if payment.payment_type == PaymentType.deposit: if payment.payment_type == PaymentType.deposit:
# Mark deposit as paid and confirm booking # Mark deposit as paid and confirm booking
booking.deposit_paid = True booking.deposit_paid = True
if booking.status == BookingStatus.pending: # Restore cancelled bookings or confirm pending bookings
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but deposit was just paid
should_send_email = True
elif payment.payment_type == PaymentType.full: elif payment.payment_type == PaymentType.full:
# Calculate total paid from all completed payments (now includes current payment)
total_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
# Confirm booking if: # Confirm booking if:
# 1. Total paid (all payments) covers the booking price, OR # 1. Total paid (all payments) covers the booking price, OR
# 2. This single payment covers the entire booking amount # 2. This single payment covers the entire booking amount
# Also restore cancelled bookings when payment succeeds
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price): if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
booking.status = BookingStatus.confirmed if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but full payment was just completed
should_send_email = True
# Commit booking status update # Send booking confirmation email if booking was just confirmed or payment completed
if should_send_email:
try:
from ..utils.mailer import send_email
from ..utils.email_templates import booking_confirmation_email_template
from ..models.system_settings import SystemSettings
from ..models.room import Room
from sqlalchemy.orm import selectinload
import os
from ..config.settings import settings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
currency_symbol = currency_symbols.get(currency, currency)
# Load booking with room details for email
booking_with_room = db.query(Booking).options(
selectinload(Booking.room).selectinload(Room.room_type)
).filter(Booking.id == booking_id).first()
room = booking_with_room.room if booking_with_room else None
room_type_name = room.room_type.name if room and room.room_type else "Room"
# Calculate amount paid and remaining due
amount_paid = total_paid
payment_type_str = payment.payment_type.value if payment.payment_type else None
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
num_guests=booking.num_guests,
total_price=float(booking.total_price),
requires_deposit=False, # Payment completed, no deposit message needed
deposit_amount=None,
amount_paid=amount_paid,
payment_type=payment_type_str,
client_url=client_url,
currency_symbol=currency_symbol
)
if booking.user:
await send_email(
to=booking.user.email,
subject=f"Booking Confirmed - {booking.booking_number}",
html=email_html
)
logger.info(f"Booking confirmation email sent to {booking.user.email}")
except Exception as email_error:
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
# Load user for email
from ..models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
if user:
await send_email(
to=user.email,
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
html=invoice_html
)
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
except Exception as email_error:
logger.error(f"Failed to send invoice email: {str(email_error)}")
# Commit booking and invoice status updates
db.commit() db.commit()
db.refresh(booking) db.refresh(booking)
@@ -335,7 +471,7 @@ class StripeService:
raise ValueError(f"Error confirming payment: {error_msg}") raise ValueError(f"Error confirming payment: {error_msg}")
@staticmethod @staticmethod
def handle_webhook( async def handle_webhook(
payload: bytes, payload: bytes,
signature: str, signature: str,
db: Session db: Session
@@ -375,14 +511,16 @@ class StripeService:
booking_id = metadata.get("booking_id") booking_id = metadata.get("booking_id")
if booking_id: if booking_id:
try: try:
StripeService.confirm_payment( await StripeService.confirm_payment(
payment_intent_id=payment_intent_id, payment_intent_id=payment_intent_id,
db=db, db=db,
booking_id=int(booking_id) booking_id=int(booking_id)
) )
except Exception as e: except Exception as e:
print(f"Error processing webhook for booking {booking_id}: {str(e)}") import logging
logger = logging.getLogger(__name__)
logger.error(f"Error processing webhook for booking {booking_id}: {str(e)}")
elif event["type"] == "payment_intent.payment_failed": elif event["type"] == "payment_intent.payment_failed":
payment_intent = event["data"]["object"] payment_intent = event["data"]["object"]
@@ -401,6 +539,42 @@ class StripeService:
payment.payment_status = PaymentStatus.failed payment.payment_status = PaymentStatus.failed
db.commit() db.commit()
# Auto-cancel booking when payment fails
booking = db.query(Booking).filter(Booking.id == int(booking_id)).first()
if booking and booking.status != BookingStatus.cancelled:
booking.status = BookingStatus.cancelled
db.commit()
db.refresh(booking)
# Send cancellation email (non-blocking)
try:
if booking.user:
from ..utils.mailer import send_email
from ..utils.email_templates import booking_status_changed_email_template
from ..models.system_settings import SystemSettings
from ..config.settings import settings
import os
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status="cancelled",
client_url=client_url
)
await send_email(
to=booking.user.email,
subject=f"Booking Cancelled - {booking.booking_number}",
html=email_html
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send cancellation email: {e}")
return { return {
"status": "success", "status": "success",
"event_type": event["type"], "event_type": event["type"],

View File

@@ -260,19 +260,66 @@ def booking_confirmation_email_template(
total_price: float, total_price: float,
requires_deposit: bool, requires_deposit: bool,
deposit_amount: Optional[float] = None, deposit_amount: Optional[float] = None,
client_url: str = "http://localhost:5173" amount_paid: Optional[float] = None,
payment_type: Optional[str] = None,
original_price: Optional[float] = None,
discount_amount: Optional[float] = None,
promotion_code: Optional[str] = None,
client_url: str = "http://localhost:5173",
currency_symbol: str = "$"
) -> str: ) -> str:
"""Booking confirmation email template""" """Booking confirmation email template"""
deposit_info = "" deposit_info = ""
if requires_deposit and deposit_amount: if requires_deposit and deposit_amount and amount_paid is None:
deposit_info = f""" deposit_info = f"""
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);"> <div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);">
<p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p> <p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p>
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">{deposit_amount:.2f}</strong> to confirm your booking.</p> <p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">{currency_symbol}{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p> <p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
</div> </div>
""" """
# Payment breakdown section (shown when payment is completed)
payment_breakdown = ""
if amount_paid is not None:
remaining_due = total_price - amount_paid
payment_type_label = "Deposit Payment" if payment_type == "deposit" else "Full Payment"
payment_breakdown = f"""
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.2);">
<h3 style="margin-top: 0; margin-bottom: 20px; color: #065F46; font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600;">Payment Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Payment Type:</td>
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
</tr>
<tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Amount Paid:</td>
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 18px;">{currency_symbol}{amount_paid:.2f}</td>
</tr>
<tr>
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
</tr>
"""
if remaining_due > 0:
payment_breakdown += f"""
<tr style="background-color: rgba(245, 158, 11, 0.1); border-top: 2px solid #F59E0B;">
<td style="padding: 10px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
<td style="padding: 10px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
</tr>
"""
else:
payment_breakdown += f"""
<tr style="background-color: rgba(16, 185, 129, 0.1); border-top: 2px solid #10B981;">
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 600;">Status:</td>
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 16px;">✅ Fully Paid</td>
</tr>
"""
payment_breakdown += """
</table>
</div>
"""
content = f""" content = f"""
<div style="text-align: center; margin-bottom: 30px;"> <div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;"> <div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
@@ -308,13 +355,25 @@ def booking_confirmation_email_template(
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td> <td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td> <td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
</tr> </tr>
{f'''
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Subtotal:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{original_price:.2f}</td>
</tr>
<tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 12px 0; color: #065F46; font-size: 14px; font-weight: 500;">Promotion Discount{f' ({promotion_code})' if promotion_code else ''}:</td>
<td style="padding: 12px 0; font-weight: 700; color: #059669; font-size: 15px;">-{currency_symbol}{discount_amount:.2f}</td>
</tr>
''' if original_price and discount_amount and discount_amount > 0 else ''}
<tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;"> <tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
<td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td> <td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{total_price:.2f}</td> <td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{currency_symbol}{total_price:.2f}</td>
</tr> </tr>
</table> </table>
</div> </div>
{payment_breakdown}
{deposit_info} {deposit_info}
<div style="text-align: center; margin-top: 40px;"> <div style="text-align: center; margin-top: 40px;">
@@ -334,7 +393,10 @@ def payment_confirmation_email_template(
amount: float, amount: float,
payment_method: str, payment_method: str,
transaction_id: Optional[str] = None, transaction_id: Optional[str] = None,
client_url: str = "http://localhost:5173" payment_type: Optional[str] = None,
total_price: Optional[float] = None,
client_url: str = "http://localhost:5173",
currency_symbol: str = "$"
) -> str: ) -> str:
"""Payment confirmation email template""" """Payment confirmation email template"""
transaction_info = "" transaction_info = ""
@@ -346,6 +408,34 @@ def payment_confirmation_email_template(
</tr> </tr>
""" """
payment_type_info = ""
if payment_type:
payment_type_label = "Deposit Payment (20%)" if payment_type == "deposit" else "Full Payment"
payment_type_info = f"""
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Type:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
</tr>
"""
total_price_info = ""
remaining_due_info = ""
if total_price is not None:
total_price_info = f"""
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
</tr>
"""
if payment_type == "deposit" and total_price > amount:
remaining_due = total_price - amount
remaining_due_info = f"""
<tr style="background-color: rgba(245, 158, 11, 0.1);">
<td style="padding: 12px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
<td style="padding: 12px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
</tr>
"""
content = f""" content = f"""
<div style="text-align: center; margin-bottom: 30px;"> <div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;"> <div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
@@ -370,10 +460,13 @@ def payment_confirmation_email_template(
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td> <td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
</tr> </tr>
{transaction_info} {transaction_info}
{payment_type_info}
{total_price_info}
<tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;"> <tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;">
<td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td> <td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td>
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">{amount:.2f}</td> <td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">{currency_symbol}{amount:.2f}</td>
</tr> </tr>
{remaining_due_info}
</table> </table>
</div> </div>

View File

@@ -13,12 +13,14 @@
"@stripe/react-stripe-js": "^2.9.0", "@stripe/react-stripe-js": "^2.9.0",
"@stripe/stripe-js": "^2.4.0", "@stripe/stripe-js": "^2.4.0",
"@types/react-datepicker": "^6.2.0", "@types/react-datepicker": "^6.2.0",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.6.2", "axios": "^1.6.2",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-datepicker": "^8.9.0", "react-datepicker": "^8.9.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"react-toastify": "^9.1.3", "react-toastify": "^9.1.3",
@@ -1610,6 +1612,15 @@
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
}, },
"node_modules/@types/react-google-recaptcha": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/semver": { "node_modules/@types/semver": {
"version": "7.7.1", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@@ -3179,6 +3190,15 @@
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4124,6 +4144,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-async-script": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0",
"prop-types": "^15.5.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-datepicker": { "node_modules/react-datepicker": {
"version": "8.9.0", "version": "8.9.0",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz", "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz",
@@ -4187,6 +4220,19 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-google-recaptcha": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.5.0",
"react-async-script": "^1.2.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.65.0", "version": "7.65.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",

View File

@@ -11,19 +11,21 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@paypal/react-paypal-js": "^8.1.3",
"@stripe/react-stripe-js": "^2.9.0", "@stripe/react-stripe-js": "^2.9.0",
"@stripe/stripe-js": "^2.4.0", "@stripe/stripe-js": "^2.4.0",
"@types/react-datepicker": "^6.2.0", "@types/react-datepicker": "^6.2.0",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.6.2", "axios": "^1.6.2",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-datepicker": "^8.9.0", "react-datepicker": "^8.9.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"react-toastify": "^9.1.3", "react-toastify": "^9.1.3",
"@paypal/react-paypal-js": "^8.1.3",
"yup": "^1.3.3", "yup": "^1.3.3",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },

View File

@@ -216,7 +216,7 @@ function App() {
} }
/> />
<Route <Route
path="deposit-payment/:bookingId" path="payment/deposit/:bookingId"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<DepositPaymentPage /> <DepositPaymentPage />

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useRef, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { recaptchaService } from '../../services/api/systemSettingsService';
interface RecaptchaProps {
onChange?: (token: string | null) => void;
onError?: (error: string) => void;
theme?: 'light' | 'dark';
size?: 'normal' | 'compact';
className?: string;
}
const Recaptcha: React.FC<RecaptchaProps> = ({
onChange,
onError,
theme = 'dark',
size = 'normal',
className = '',
}) => {
const recaptchaRef = useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
const [enabled, setEnabled] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const fetchSettings = async () => {
try {
const response = await recaptchaService.getRecaptchaSettings();
if (response.status === 'success' && response.data) {
setSiteKey(response.data.recaptcha_site_key || '');
setEnabled(response.data.recaptcha_enabled || false);
}
} catch (error) {
console.error('Error fetching reCAPTCHA settings:', error);
if (onError) {
onError('Failed to load reCAPTCHA settings');
}
} finally {
setLoading(false);
}
};
fetchSettings();
}, [onError]);
const handleChange = (token: string | null) => {
if (onChange) {
onChange(token);
}
};
const handleExpired = () => {
if (onChange) {
onChange(null);
}
};
const handleError = () => {
if (onError) {
onError('reCAPTCHA error occurred');
}
if (onChange) {
onChange(null);
}
};
if (loading) {
return null;
}
if (!enabled || !siteKey) {
return null;
}
return (
<div className={className}>
<ReCAPTCHA
ref={recaptchaRef}
sitekey={siteKey}
onChange={handleChange}
onExpired={handleExpired}
onError={handleError}
theme={theme}
size={size}
/>
</div>
);
};
export default Recaptcha;

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createPayPalOrder } from '../../services/api/paymentService'; import { createPayPalOrder } from '../../services/api/paymentService';
import { Loader2, AlertCircle } from 'lucide-react'; import { Loader2, AlertCircle } from 'lucide-react';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface PayPalPaymentWrapperProps { interface PayPalPaymentWrapperProps {
bookingId: number; bookingId: number;
@@ -12,9 +13,12 @@ interface PayPalPaymentWrapperProps {
const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
bookingId, bookingId,
amount, amount,
currency = 'USD', currency: propCurrency,
onError, onError,
}) => { }) => {
// Get currency from context if not provided as prop
const { currency: contextCurrency } = useFormatCurrency();
const currency = propCurrency || contextCurrency || 'USD';
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [approvalUrl, setApprovalUrl] = useState<string | null>(null); const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
@@ -75,22 +79,29 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" /> <div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
<span className="ml-2 text-gray-600">Initializing PayPal payment...</span> rounded-full flex items-center justify-center
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
<Loader2 className="w-8 h-8 animate-spin text-[#d4af37]" />
</div>
<span className="ml-4 text-gray-300 font-light tracking-wide">
Initializing PayPal payment...
</span>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6"> <div className="bg-gradient-to-br from-red-900/20 to-red-800/10
<div className="flex items-start gap-3"> border border-red-500/30 rounded-xl p-6 backdrop-blur-sm">
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" /> <div className="flex items-start gap-4">
<AlertCircle className="w-6 h-6 text-red-400 mt-0.5 flex-shrink-0" />
<div> <div>
<h3 className="text-lg font-semibold text-red-900 mb-1"> <h3 className="text-lg font-serif font-semibold text-red-300 mb-2 tracking-wide">
Payment Initialization Failed Payment Initialization Failed
</h3> </h3>
<p className="text-sm text-red-800"> <p className="text-sm text-red-200/80 font-light tracking-wide">
{error || 'Unable to initialize PayPal payment. Please try again.'} {error || 'Unable to initialize PayPal payment. Please try again.'}
</p> </p>
</div> </div>
@@ -102,57 +113,65 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
if (!approvalUrl) { if (!approvalUrl) {
return ( return (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" /> <div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
<span className="ml-2 text-gray-600">Loading PayPal...</span> rounded-full flex items-center justify-center
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
<Loader2 className="w-8 h-8 animate-spin text-[#d4af37]" />
</div>
<span className="ml-4 text-gray-300 font-light tracking-wide">
Loading PayPal...
</span>
</div> </div>
); );
} }
return ( return (
<div className="bg-white rounded-lg border border-gray-200 p-6"> <div className="text-center">
<div className="text-center"> <div className="mb-6">
<div className="mb-4"> <svg
<svg className="mx-auto h-14 w-auto"
className="mx-auto h-12 w-auto" viewBox="0 0 283 64"
viewBox="0 0 283 64" fill="none"
fill="none" xmlns="http://www.w3.org/2000/svg"
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-lg font-semibold text-gray-900 mb-2">
Complete Payment with PayPal
</h3>
<p className="text-sm text-gray-600 mb-6">
You will be redirected to PayPal to securely complete your payment of{' '}
<span className="font-semibold">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount)}
</span>
</p>
<button
onClick={handlePayPalClick}
className="w-full bg-[#0070ba] hover:bg-[#005ea6] text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
> >
<svg <path
className="w-6 h-6" 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"
viewBox="0 0 24 24" fill="#003087"
fill="currentColor" />
> </svg>
<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>
Pay with PayPal
</button>
<p className="text-xs text-gray-500 mt-4">
Secure payment powered by PayPal
</p>
</div> </div>
<h3 className="text-xl font-serif font-semibold text-[#d4af37] mb-3 tracking-wide">
Complete Payment with PayPal
</h3>
<p className="text-gray-300/80 font-light text-lg mb-8 tracking-wide">
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>
<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-4 px-8 rounded-sm transition-all duration-300
flex items-center justify-center gap-3 shadow-lg shadow-blue-500/30
hover:shadow-xl hover:shadow-blue-500/40 tracking-wide"
>
<svg
className="w-6 h-6"
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>
Pay with PayPal
</button>
<p className="text-xs text-gray-400/70 mt-6 font-light tracking-wide">
Secure payment powered by PayPal
</p>
</div> </div>
); );
}; };

View File

@@ -71,15 +71,15 @@ const RatingStars: React.FC<RatingStarsProps> = ({
<Star <Star
className={`${sizeClasses[size]} ${ className={`${sizeClasses[size]} ${
isFilled isFilled
? 'text-yellow-500 fill-yellow-500' ? 'text-[#d4af37] fill-[#d4af37]'
: 'text-gray-300' : 'text-gray-500'
}`} }`}
/> />
</button> </button>
); );
})} })}
{showNumber && ( {showNumber && (
<span className="ml-2 text-sm font-semibold text-gray-700"> <span className="ml-2 text-xs sm:text-sm font-semibold text-white">
{rating.toFixed(1)} {rating.toFixed(1)}
</span> </span>
)} )}

View File

@@ -10,6 +10,8 @@ import {
type Review, type Review,
} from '../../services/api/reviewService'; } from '../../services/api/reviewService';
import useAuthStore from '../../store/useAuthStore'; import useAuthStore from '../../store/useAuthStore';
import Recaptcha from '../common/Recaptcha';
import { recaptchaService } from '../../services/api/systemSettingsService';
interface ReviewSectionProps { interface ReviewSectionProps {
roomId: number; roomId: number;
@@ -42,6 +44,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [averageRating, setAverageRating] = useState<number>(0); const [averageRating, setAverageRating] = useState<number>(0);
const [totalReviews, setTotalReviews] = useState<number>(0); const [totalReviews, setTotalReviews] = useState<number>(0);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const { const {
register, register,
@@ -87,6 +90,22 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
return; 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 { try {
setSubmitting(true); setSubmitting(true);
const response = await createReview({ const response = await createReview({
@@ -101,12 +120,14 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
); );
reset(); reset();
fetchReviews(); fetchReviews();
setRecaptchaToken(null);
} }
} catch (error: any) { } catch (error: any) {
const message = const message =
error.response?.data?.message || error.response?.data?.message ||
'Unable to submit review'; 'Unable to submit review';
toast.error(message); toast.error(message);
setRecaptchaToken(null);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -121,24 +142,26 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
}; };
return ( return (
<div className="space-y-8"> <div className="space-y-4">
{/* Rating Summary */} {/* Rating Summary */}
<div className="bg-gray-50 rounded-lg p-6"> <div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
<h3 className="text-2xl font-bold text-gray-900 mb-4"> <h3 className="text-sm sm:text-base font-serif font-semibold text-white mb-3 tracking-wide">
Customer Reviews Customer Reviews
</h3> </h3>
<div className="flex items-center gap-6"> <div className="flex items-center gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-5xl font-bold text-gray-900"> <div className="text-2xl sm:text-3xl font-serif font-bold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
{averageRating > 0 {averageRating > 0
? averageRating.toFixed(1) ? averageRating.toFixed(1)
: 'N/A'} : 'N/A'}
</div> </div>
<RatingStars <div className="mt-1">
rating={averageRating} <RatingStars
size="md" rating={averageRating}
/> size="sm"
<div className="text-sm text-gray-600 mt-2"> />
</div>
<div className="text-[10px] sm:text-xs text-gray-400 mt-1.5 font-light">
{totalReviews} review{totalReviews !== 1 ? 's' : ''} {totalReviews} review{totalReviews !== 1 ? 's' : ''}
</div> </div>
</div> </div>
@@ -147,29 +170,29 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
{/* Review Form */} {/* Review Form */}
{isAuthenticated ? ( {isAuthenticated ? (
<div className="bg-white rounded-lg shadow-md p-6"> <div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
<h4 className="text-xl font-semibold mb-4"> <h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
Write Your Review Write Your Review
</h4> </h4>
<form onSubmit={handleSubmit(onSubmit)} <form onSubmit={handleSubmit(onSubmit)}
className="space-y-4" className="space-y-3"
> >
<div> <div>
<label className="block text-sm font-medium <label className="block text-[10px] sm:text-xs font-light
text-gray-700 mb-2" text-gray-300 mb-1.5 tracking-wide"
> >
Your Rating Your Rating
</label> </label>
<RatingStars <RatingStars
rating={rating} rating={rating}
size="lg" size="sm"
interactive interactive
onRatingChange={(value) => onRatingChange={(value) =>
setValue('rating', value) setValue('rating', value)
} }
/> />
{errors.rating && ( {errors.rating && (
<p className="text-red-600 text-sm mt-1"> <p className="text-red-400 text-[10px] sm:text-xs mt-1 font-light">
{errors.rating.message} {errors.rating.message}
</p> </p>
)} )}
@@ -178,51 +201,66 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
<div> <div>
<label <label
htmlFor="comment" htmlFor="comment"
className="block text-sm font-medium className="block text-[10px] sm:text-xs font-light
text-gray-700 mb-2" text-gray-300 mb-1.5 tracking-wide"
> >
Comment Comment
</label> </label>
<textarea <textarea
{...register('comment')} {...register('comment')}
id="comment" id="comment"
rows={4} rows={3}
className="w-full px-4 py-2 border className="w-full px-2.5 py-1.5 bg-[#0a0a0a] border
border-gray-300 rounded-lg border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 text-xs sm:text-sm
focus:ring-2 focus:ring-blue-500 focus:ring-2 focus:ring-[#d4af37]/50
focus:border-transparent" focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide resize-none"
placeholder="Share your experience..." placeholder="Share your experience..."
/> />
{errors.comment && ( {errors.comment && (
<p className="text-red-600 text-sm mt-1"> <p className="text-red-400 text-[10px] sm:text-xs mt-1 font-light">
{errors.comment.message} {errors.comment.message}
</p> </p>
)} )}
</div> </div>
{/* reCAPTCHA */}
<div className="flex justify-center">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
console.error('reCAPTCHA error:', error);
setRecaptchaToken(null);
}}
theme="dark"
size="normal"
/>
</div>
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
className="px-6 py-3 bg-blue-600 text-white className="px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
rounded-lg hover:bg-blue-700 text-[#0f0f0f] rounded-sm hover:from-[#f5d76e] hover:to-[#d4af37]
disabled:bg-gray-400 disabled:bg-gray-800 disabled:text-gray-500
disabled:cursor-not-allowed disabled:cursor-not-allowed
transition-colors font-medium" transition-all duration-300 font-medium text-xs sm:text-sm
shadow-sm shadow-[#d4af37]/30 tracking-wide"
> >
{submitting ? 'Submitting...' : 'Submit Review'} {submitting ? 'Submitting...' : 'Submit Review'}
</button> </button>
</form> </form>
</div> </div>
) : ( ) : (
<div className="bg-blue-50 border border-blue-200 <div className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 border border-[#d4af37]/30
rounded-lg p-6 text-center" rounded-lg p-3 sm:p-4 text-center backdrop-blur-sm"
> >
<p className="text-blue-800"> <p className="text-[#d4af37] text-xs sm:text-sm font-light">
Please{' '} Please{' '}
<a <a
href="/login" href="/login"
className="font-semibold underline className="font-semibold underline
hover:text-blue-900" hover:text-[#f5d76e] transition-colors"
> >
login login
</a>{' '} </a>{' '}
@@ -233,71 +271,70 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
{/* Reviews List */} {/* Reviews List */}
<div> <div>
<h4 className="text-xl font-semibold mb-6"> <h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
All Reviews ({totalReviews}) All Reviews ({totalReviews})
</h4> </h4>
{loading ? ( {loading ? (
<div className="space-y-4"> <div className="space-y-2.5">
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<div <div
key={index} key={index}
className="bg-gray-100 rounded-lg p-6 className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3
animate-pulse" animate-pulse"
> >
<div className="h-4 bg-gray-300 <div className="h-3 bg-gray-700
rounded w-1/4 mb-2" rounded w-1/4 mb-2"
/> />
<div className="h-4 bg-gray-300 <div className="h-3 bg-gray-700
rounded w-3/4" rounded w-3/4"
/> />
</div> </div>
))} ))}
</div> </div>
) : reviews.length === 0 ? ( ) : reviews.length === 0 ? (
<div className="text-center py-12 bg-gray-50 <div className="text-center py-6 sm:py-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-4"
rounded-lg"
> >
<p className="text-gray-600 text-lg"> <p className="text-gray-300 text-sm sm:text-base font-light">
No reviews yet No reviews yet
</p> </p>
<p className="text-gray-500 text-sm mt-2"> <p className="text-gray-400 text-xs sm:text-sm mt-1.5 font-light">
Be the first to review this room! Be the first to review this room!
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-2.5 sm:space-y-3">
{reviews.map((review) => ( {reviews.map((review) => (
<div <div
key={review.id} key={review.id}
className="bg-white rounded-lg shadow-md className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20
p-6" p-3 sm:p-4 backdrop-blur-xl shadow-sm shadow-[#d4af37]/5"
> >
<div className="flex items-start <div className="flex items-start
justify-between mb-3" justify-between mb-2"
> >
<div> <div>
<h5 className="font-semibold <h5 className="font-semibold
text-gray-900" text-white text-xs sm:text-sm"
> >
{review.user?.full_name || 'Guest'} {review.user?.full_name || 'Guest'}
</h5> </h5>
<div className="flex items-center <div className="flex items-center
gap-2 mt-1" gap-1.5 mt-1"
> >
<RatingStars <RatingStars
rating={review.rating} rating={review.rating}
size="sm" size="sm"
/> />
<span className="text-sm <span className="text-[10px] sm:text-xs
text-gray-500" text-gray-400 font-light"
> >
{formatDate(review.created_at)} {formatDate(review.created_at)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<p className="text-gray-700 leading-relaxed"> <p className="text-gray-300 leading-relaxed text-xs sm:text-sm font-light">
{review.comment} {review.comment}
</p> </p>
</div> </div>

View File

@@ -5,6 +5,8 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService'; import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext'; import { useCompanySettings } from '../contexts/CompanySettingsContext';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import Recaptcha from '../components/common/Recaptcha';
import { recaptchaService } from '../services/api/systemSettingsService';
const ContactPage: React.FC = () => { const ContactPage: React.FC = () => {
const { settings } = useCompanySettings(); const { settings } = useCompanySettings();
@@ -18,6 +20,7 @@ const ContactPage: React.FC = () => {
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
@@ -53,6 +56,22 @@ const ContactPage: React.FC = () => {
return; 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); setLoading(true);
try { try {
await submitContactForm(formData); await submitContactForm(formData);
@@ -67,9 +86,11 @@ const ContactPage: React.FC = () => {
message: '', message: '',
}); });
setErrors({}); setErrors({});
setRecaptchaToken(null);
} catch (error: any) { } catch (error: any) {
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.'; const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
toast.error(errorMessage); toast.error(errorMessage);
setRecaptchaToken(null);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -400,6 +421,20 @@ const ContactPage: React.FC = () => {
)} )}
</div> </div>
{/* reCAPTCHA */}
<div className="pt-2 sm:pt-3">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
console.error('reCAPTCHA error:', error);
setRecaptchaToken(null);
}}
theme="dark"
size="normal"
className="flex justify-center"
/>
</div>
{/* Submit Button */} {/* Submit Button */}
<div className="pt-2 sm:pt-3 md:pt-4"> <div className="pt-2 sm:pt-3 md:pt-4">
<button <button

View File

@@ -214,9 +214,37 @@ const BookingManagementPage: React.FC = () => {
</div> </div>
</td> </td>
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold text-slate-900 bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent"> {(() => {
{formatCurrency(booking.total_price)} const completedPayments = booking.payments?.filter(
</div> (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 (
<div>
<div className="text-sm font-bold text-slate-900 bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
{formatCurrency(booking.total_price)}
</div>
{hasPayments && (
<div className="text-xs mt-1">
<div className="text-green-600 font-medium">
Paid: {formatCurrency(amountPaid)}
</div>
{remainingDue > 0 && (
<div className="text-amber-600 font-medium">
Due: {formatCurrency(remainingDue)}
</div>
)}
</div>
)}
</div>
);
})()}
</td> </td>
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
{getStatusBadge(booking.status)} {getStatusBadge(booking.status)}
@@ -369,12 +397,219 @@ const BookingManagementPage: React.FC = () => {
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p> <p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
</div> </div>
{/* Total Price - Highlighted */} {/* Payment Method & Status */}
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg"> <div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Total Price</label> <label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<p className="text-4xl font-bold bg-gradient-to-r from-amber-600 via-amber-700 to-amber-600 bg-clip-text text-transparent"> <div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
{formatCurrency(selectedBooking.total_price)} Payment Information
</p> </label>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
<p className="text-base font-semibold text-slate-900">
{selectedBooking.payment_method === 'cash'
? '💵 Pay at Hotel'
: selectedBooking.payment_method === 'stripe'
? '💳 Stripe (Card)'
: selectedBooking.payment_method === 'paypal'
? '💳 PayPal'
: selectedBooking.payment_method || 'N/A'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
<p className={`text-base font-semibold ${
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
? 'text-green-600'
: selectedBooking.payment_status === 'pending'
? 'text-yellow-600'
: 'text-red-600'
}`}>
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
? '✅ Paid'
: selectedBooking.payment_status === 'pending'
? '⏳ Pending'
: selectedBooking.payment_status === 'failed'
? '❌ Failed'
: selectedBooking.payment_status || 'Unpaid'}
</p>
</div>
</div>
</div>
{/* Service Usages */}
{selectedBooking.service_usages && selectedBooking.service_usages.length > 0 && (
<div className="bg-gradient-to-br from-purple-50/50 to-pink-50/50 p-6 rounded-xl border border-purple-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-purple-400 to-purple-600 rounded-full"></div>
Additional Services
</label>
<div className="space-y-2">
{selectedBooking.service_usages.map((service: any, idx: number) => (
<div key={service.id || idx} className="flex justify-between items-center py-2 border-b border-purple-100 last:border-0">
<div>
<p className="text-sm font-medium text-slate-900">{service.service_name || service.name || 'Service'}</p>
<p className="text-xs text-slate-500">
{formatCurrency(service.unit_price || service.price || 0)} × {service.quantity || 1}
</p>
</div>
<p className="text-sm font-semibold text-slate-900">
{formatCurrency(service.total_price || (service.unit_price || service.price || 0) * (service.quantity || 1))}
</p>
</div>
))}
</div>
</div>
)}
{/* 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 && (
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-teal-400 to-teal-600 rounded-full"></div>
Payment History
</label>
<div className="space-y-3">
{allPayments.map((payment: any, idx: number) => (
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-sm font-semibold text-slate-900">
{formatCurrency(payment.amount || 0)}
</p>
<p className="text-xs text-slate-500 mt-1">
{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'}
</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
payment.payment_status === 'completed' || payment.payment_status === 'paid'
? 'bg-green-100 text-green-700'
: payment.payment_status === 'pending'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
</span>
</div>
{payment.transaction_id && (
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
)}
{payment.payment_date && (
<p className="text-xs text-slate-400 mt-1">
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Payment Summary - Always show, even if no payments */}
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
{formatCurrency(amountPaid)}
</p>
{hasPayments && completedPayments.length > 0 && (
<p className="text-xs text-green-600 mt-2">
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
{amountPaid > 0 && selectedBooking.total_price > 0 && (
<span className="ml-2">
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
</span>
)}
</p>
)}
{amountPaid === 0 && !hasPayments && (
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
)}
</div>
{/* Remaining Due - Show prominently if there's remaining balance */}
{remainingDue > 0 && (
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
<p className="text-3xl font-bold text-amber-600">
{formatCurrency(remainingDue)}
</p>
{selectedBooking.total_price > 0 && (
<p className="text-xs text-amber-600 mt-2">
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
</p>
)}
</div>
)}
{/* Total Booking Price - Show as reference */}
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
<p className="text-2xl font-bold text-slate-700">
{formatCurrency(selectedBooking.total_price)}
</p>
<p className="text-xs text-slate-500 mt-2">
This is the total amount for the booking
</p>
</div>
</>
);
})()}
{/* Booking Metadata */}
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-slate-400 to-slate-600 rounded-full"></div>
Booking Metadata
</label>
<div className="grid grid-cols-2 gap-4">
{selectedBooking.createdAt && (
<div>
<p className="text-xs text-slate-500 mb-1">Created At</p>
<p className="text-sm font-medium text-slate-900">
{new Date(selectedBooking.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
)}
{selectedBooking.updatedAt && (
<div>
<p className="text-xs text-slate-500 mb-1">Last Updated</p>
<p className="text-sm font-medium text-slate-900">
{new Date(selectedBooking.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
)}
{selectedBooking.requires_deposit !== undefined && (
<div>
<p className="text-xs text-slate-500 mb-1">Deposit Required</p>
<p className="text-sm font-medium text-slate-900">
{selectedBooking.requires_deposit ? 'Yes (20%)' : 'No'}
</p>
</div>
)}
{selectedBooking.deposit_paid !== undefined && (
<div>
<p className="text-xs text-slate-500 mb-1">Deposit Paid</p>
<p className={`text-sm font-medium ${selectedBooking.deposit_paid ? 'text-green-600' : 'text-amber-600'}`}>
{selectedBooking.deposit_paid ? '✅ Yes' : '❌ No'}
</p>
</div>
)}
</div>
</div> </div>
{/* Notes */} {/* Notes */}

View File

@@ -742,6 +742,7 @@ const BusinessDashboardPage: React.FC = () => {
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Booking Number</th> <th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Booking Number</th>
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Customer</th> <th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Method</th> <th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Method</th>
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Amount</th> <th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Amount</th>
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Payment Date</th> <th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Payment Date</th>
</tr> </tr>
@@ -756,11 +757,28 @@ const BusinessDashboardPage: React.FC = () => {
<div className="text-sm font-semibold text-emerald-600">{payment.booking?.booking_number}</div> <div className="text-sm font-semibold text-emerald-600">{payment.booking?.booking_number}</div>
</td> </td>
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{payment.booking?.user?.name}</div> <div className="text-sm font-medium text-gray-900">
{payment.booking?.user?.name || payment.booking?.user?.full_name || 'N/A'}
</div>
</td> </td>
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
{getPaymentMethodBadge(payment.payment_method)} {getPaymentMethodBadge(payment.payment_method)}
</td> </td>
<td className="px-8 py-5 whitespace-nowrap">
{payment.payment_type === 'deposit' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
Deposit (20%)
</span>
) : payment.payment_type === 'remaining' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
Remaining
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
Full Payment
</span>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent"> <div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(payment.amount)} {formatCurrency(payment.amount)}

View File

@@ -35,7 +35,17 @@ const CheckInPage: React.FC = () => {
const response = await bookingService.checkBookingByNumber(bookingNumber); const response = await bookingService.checkBookingByNumber(bookingNumber);
setBooking(response.data.booking); setBooking(response.data.booking);
setActualRoomNumber(response.data.booking.room?.room_number || ''); 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) { } catch (error: any) {
toast.error(error.response?.data?.message || 'Booking not found'); toast.error(error.response?.data?.message || 'Booking not found');
setBooking(null); setBooking(null);
@@ -89,12 +99,21 @@ const CheckInPage: React.FC = () => {
// Calculate additional fee // Calculate additional fee
calculateAdditionalFee(); calculateAdditionalFee();
await bookingService.updateBooking(booking.id, { const response = await bookingService.updateBooking(booking.id, {
status: 'checked_in', status: 'checked_in',
// Can send additional data about guests, room_number, additional_fee // Can send additional data about guests, room_number, additional_fee
} as any); } 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 // Reset form
setBooking(null); setBooking(null);
@@ -201,6 +220,150 @@ const CheckInPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Payment Warning Alert */}
{booking.payment_balance && booking.payment_balance.remaining_balance > 0.01 && (
<div className="mt-6 p-4 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg border-2 border-amber-400">
<div className="flex items-start gap-3">
<AlertCircle className="w-6 h-6 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-bold text-amber-900 mb-2">
Payment Reminder
</h3>
<p className="text-amber-800 mb-3">
This guest has <strong>not fully paid</strong> for their booking. Please collect the remaining balance during check-in.
</p>
<div className="grid grid-cols-2 gap-4 bg-white/50 p-3 rounded-lg">
<div>
<span className="text-sm text-amber-700">Total Price:</span>
<p className="text-lg font-bold text-gray-900">{formatCurrency(booking.payment_balance.total_price)}</p>
</div>
<div>
<span className="text-sm text-amber-700">Amount Paid:</span>
<p className="text-lg font-bold text-green-600">{formatCurrency(booking.payment_balance.total_paid)}</p>
</div>
<div>
<span className="text-sm text-amber-700">Payment Progress:</span>
<p className="text-lg font-bold text-blue-600">{booking.payment_balance.payment_percentage.toFixed(1)}%</p>
</div>
<div>
<span className="text-sm text-amber-700">Remaining Balance:</span>
<p className="text-xl font-bold text-red-600">{formatCurrency(booking.payment_balance.remaining_balance)}</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Payment Information */}
<div className="mt-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<h3 className="text-md font-semibold text-gray-900 mb-3 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
Payment Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Payment Method:</span>
<span className="font-semibold">
{booking.payment_method === 'cash'
? '💵 Pay at Hotel'
: booking.payment_method === 'stripe'
? '💳 Stripe (Card)'
: booking.payment_method === 'paypal'
? '💳 PayPal'
: booking.payment_method || 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Payment Status:</span>
<span className={`font-semibold ${
booking.payment_status === 'paid' || booking.payment_status === 'completed'
? 'text-green-600'
: booking.payment_status === 'pending'
? 'text-yellow-600'
: 'text-red-600'
}`}>
{booking.payment_status === 'paid' || booking.payment_status === 'completed'
? '✅ Paid'
: booking.payment_status === 'pending'
? '⏳ Pending'
: booking.payment_status === 'failed'
? '❌ Failed'
: booking.payment_status || 'Unpaid'}
</span>
</div>
</div>
</div>
<div>
<div className="space-y-2">
{(() => {
// 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 (
<>
<div className="flex justify-between">
<span className="text-gray-600">Total Price:</span>
<span className="font-semibold text-gray-900">{formatCurrency(paymentBalance.total_price)}</span>
</div>
{hasPayments && (
<>
<div className="flex justify-between">
<span className="text-gray-600">Amount Paid:</span>
<span className="font-semibold text-green-600">{formatCurrency(paymentBalance.total_paid)}</span>
</div>
{paymentBalance.remaining_balance > 0.01 && (
<div className="flex justify-between">
<span className="text-gray-600">Remaining Due:</span>
<span className="font-semibold text-red-600">{formatCurrency(paymentBalance.remaining_balance)}</span>
</div>
)}
{completedPayments.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<p className="text-xs text-gray-500 mb-1">Payment Details:</p>
{completedPayments.map((payment, idx) => (
<div key={payment.id || idx} className="text-xs text-gray-600">
{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}`}
</div>
))}
</div>
)}
</>
)}
</>
);
})()}
</div>
</div>
</div>
</div>
</div>
{booking.status !== 'confirmed' && ( {booking.status !== 'confirmed' && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2"> <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" /> <AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />

View File

@@ -158,6 +158,7 @@ const PaymentManagementPage: React.FC = () => {
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th> <th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th> <th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th> <th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th> <th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th> <th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
</tr> </tr>
@@ -181,6 +182,21 @@ const PaymentManagementPage: React.FC = () => {
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
{getMethodBadge(payment.payment_method)} {getMethodBadge(payment.payment_method)}
</td> </td>
<td className="px-8 py-5 whitespace-nowrap">
{payment.payment_type === 'deposit' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
Deposit (20%)
</span>
) : payment.payment_type === 'remaining' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
Remaining
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
Full Payment
</span>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent"> <div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(payment.amount)} {formatCurrency(payment.amount)}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
LogIn, LogIn,
LogOut, LogOut,
@@ -189,6 +189,14 @@ const ReceptionDashboardPage: React.FC = () => {
return total; 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 () => { const handleCheckIn = async () => {
if (!checkInBooking) return; if (!checkInBooking) return;
@@ -272,8 +280,11 @@ const ReceptionDashboardPage: React.FC = () => {
return 0; return 0;
}; };
const calculateDeposit = () => { const calculateTotalPaid = () => {
return checkOutBooking?.total_price ? checkOutBooking.total_price * 0.3 : 0; if (!checkOutBooking?.payments) return 0;
return checkOutBooking.payments
.filter(payment => payment.payment_status === 'completed')
.reduce((sum, payment) => sum + (payment.amount || 0), 0);
}; };
const calculateSubtotal = () => { const calculateSubtotal = () => {
@@ -285,7 +296,9 @@ const ReceptionDashboardPage: React.FC = () => {
}; };
const calculateRemaining = () => { const calculateRemaining = () => {
return calculateTotal() - calculateDeposit(); const total = calculateTotal();
const totalPaid = calculateTotalPaid();
return total - totalPaid;
}; };
const handleCheckOut = async () => { const handleCheckOut = async () => {
@@ -326,17 +339,7 @@ const ReceptionDashboardPage: React.FC = () => {
}; };
// Bookings Management Functions // Bookings Management Functions
useEffect(() => { const fetchBookings = useCallback(async () => {
setBookingCurrentPage(1);
}, [bookingFilters]);
useEffect(() => {
if (activeTab === 'bookings') {
fetchBookings();
}
}, [bookingFilters, bookingCurrentPage, activeTab]);
const fetchBookings = async () => {
try { try {
setBookingsLoading(true); setBookingsLoading(true);
const response = await bookingService.getAllBookings({ const response = await bookingService.getAllBookings({
@@ -354,7 +357,17 @@ const ReceptionDashboardPage: React.FC = () => {
} finally { } finally {
setBookingsLoading(false); 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) => { const handleUpdateBookingStatus = async (id: number, status: string) => {
try { try {
@@ -426,19 +439,63 @@ const ReceptionDashboardPage: React.FC = () => {
}; };
// Rooms Management Functions // 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<number, { id: number; name: string }>();
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(() => { useEffect(() => {
setRoomCurrentPage(1); setRoomCurrentPage(1);
setSelectedRooms([]); setSelectedRooms([]);
}, [roomFilters]); }, [roomFilters.search, roomFilters.status, roomFilters.type]);
useEffect(() => { useEffect(() => {
if (activeTab === 'rooms') { if (activeTab === 'rooms') {
fetchRooms(); fetchRooms();
fetchAvailableAmenities(); fetchAvailableAmenities();
} }
}, [roomFilters, roomCurrentPage, activeTab]); }, [activeTab, fetchRooms, fetchAvailableAmenities]);
useEffect(() => { useEffect(() => {
if (activeTab !== 'rooms') return;
const fetchAllRoomTypes = async () => { const fetchAllRoomTypes = async () => {
try { try {
const response = await roomService.getRooms({ limit: 100, page: 1 }); const response = await roomService.getRooms({ limit: 100, page: 1 });
@@ -474,60 +531,21 @@ const ReceptionDashboardPage: React.FC = () => {
if (allUniqueRoomTypes.size > 0) { if (allUniqueRoomTypes.size > 0) {
const roomTypesList = Array.from(allUniqueRoomTypes.values()); const roomTypesList = Array.from(allUniqueRoomTypes.values());
setRoomTypes(roomTypesList); setRoomTypes(roomTypesList);
if (!editingRoom && roomFormData.room_type_id === 1 && roomTypesList.length > 0) { setRoomFormData(prev => {
setRoomFormData(prev => ({ ...prev, room_type_id: roomTypesList[0].id })); if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
} return { ...prev, room_type_id: roomTypesList[0].id };
}
return prev;
});
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch room types:', err); console.error('Failed to fetch room types:', err);
} }
}; };
if (activeTab === 'rooms') {
fetchAllRoomTypes();
}
}, [activeTab]);
const fetchAvailableAmenities = async () => { fetchAllRoomTypes();
try { }, [activeTab, editingRoom]);
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<number, { id: number; name: string }>();
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) => { const handleRoomSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -866,17 +884,7 @@ const ReceptionDashboardPage: React.FC = () => {
}; };
// Services Management Functions // Services Management Functions
useEffect(() => { const fetchServices = useCallback(async () => {
setServiceCurrentPage(1);
}, [serviceFilters]);
useEffect(() => {
if (activeTab === 'services') {
fetchServices();
}
}, [serviceFilters, serviceCurrentPage, activeTab]);
const fetchServices = async () => {
try { try {
setServicesLoading(true); setServicesLoading(true);
const response = await serviceService.getServices({ const response = await serviceService.getServices({
@@ -894,7 +902,17 @@ const ReceptionDashboardPage: React.FC = () => {
} finally { } finally {
setServicesLoading(false); 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) => { const handleServiceSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1311,6 +1329,100 @@ const ReceptionDashboardPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Payment Information */}
<div className="mt-6 p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-200">
<h3 className="text-md font-semibold text-gray-900 mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
Payment Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-green-100">
<span className="text-gray-600 font-medium">Payment Method:</span>
<span className="font-semibold text-gray-900">
{checkInBooking.payment_method === 'cash'
? '💵 Pay at Hotel'
: checkInBooking.payment_method === 'stripe'
? '💳 Stripe (Card)'
: checkInBooking.payment_method === 'paypal'
? '💳 PayPal'
: checkInBooking.payment_method || 'N/A'}
</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-gray-600 font-medium">Payment Status:</span>
<span className={`font-semibold ${
checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
? 'text-green-600'
: checkInBooking.payment_status === 'pending'
? 'text-yellow-600'
: 'text-red-600'
}`}>
{checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
? '✅ Paid'
: checkInBooking.payment_status === 'pending'
? '⏳ Pending'
: checkInBooking.payment_status === 'failed'
? '❌ Failed'
: checkInBooking.payment_status || 'Unpaid'}
</span>
</div>
</div>
</div>
<div>
<div className="space-y-3">
{(() => {
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 (
<>
<div className="flex justify-between items-center py-2 border-b border-green-100">
<span className="text-gray-600 font-medium">Total Price:</span>
<span className="font-semibold text-gray-900">{formatCurrency(checkInBooking.total_price)}</span>
</div>
{hasPayments && (
<>
<div className="flex justify-between items-center py-2 border-b border-green-100">
<span className="text-gray-600 font-medium">Amount Paid:</span>
<span className="font-semibold text-green-600">{formatCurrency(amountPaid)}</span>
</div>
{remainingDue > 0 && (
<div className="flex justify-between items-center py-2">
<span className="text-gray-600 font-medium">Remaining Due:</span>
<span className="font-semibold text-amber-600">{formatCurrency(remainingDue)}</span>
</div>
)}
{completedPayments.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-200">
<p className="text-xs text-gray-500 mb-2 font-medium">Payment Details:</p>
{completedPayments.map((payment, idx) => (
<div key={payment.id || idx} className="text-xs text-gray-600 mb-1">
{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}`}
</div>
))}
</div>
)}
</>
)}
</>
);
})()}
</div>
</div>
</div>
</div>
{checkInBooking.status !== 'confirmed' && ( {checkInBooking.status !== 'confirmed' && (
<div className="mt-6 p-4 bg-gradient-to-br from-amber-50 to-yellow-50 border border-amber-200 rounded-xl flex items-start gap-3"> <div className="mt-6 p-4 bg-gradient-to-br from-amber-50 to-yellow-50 border border-amber-200 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" /> <AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
@@ -1458,7 +1570,7 @@ const ReceptionDashboardPage: React.FC = () => {
<div className="space-y-3"> <div className="space-y-3">
<label className="block text-sm font-semibold text-gray-900">Total Additional Fee</label> <label className="block text-sm font-semibold text-gray-900">Total Additional Fee</label>
<div className="px-4 py-3.5 bg-gradient-to-br from-emerald-50 to-green-50 border-2 border-emerald-200 rounded-xl text-lg font-bold text-emerald-600"> <div className="px-4 py-3.5 bg-gradient-to-br from-emerald-50 to-green-50 border-2 border-emerald-200 rounded-xl text-lg font-bold text-emerald-600">
{formatCurrency(calculateCheckInAdditionalFee())} {formatCurrency(additionalFee)}
</div> </div>
</div> </div>
</div> </div>
@@ -1693,10 +1805,12 @@ const ReceptionDashboardPage: React.FC = () => {
<span>Total:</span> <span>Total:</span>
<span>{formatCurrency(calculateTotal())}</span> <span>{formatCurrency(calculateTotal())}</span>
</div> </div>
<div className="flex justify-between items-center text-lg text-gray-600"> {calculateTotalPaid() > 0 && (
<span>Deposit paid:</span> <div className="flex justify-between items-center text-lg text-gray-600">
<span className="font-semibold">-{formatCurrency(calculateDeposit())}</span> <span>Total paid:</span>
</div> <span className="font-semibold">-{formatCurrency(calculateTotalPaid())}</span>
</div>
)}
<div className="flex justify-between items-center text-3xl font-extrabold text-emerald-600 pt-4 border-t-2 border-gray-300"> <div className="flex justify-between items-center text-3xl font-extrabold text-emerald-600 pt-4 border-t-2 border-gray-300">
<span>Remaining payment:</span> <span>Remaining payment:</span>
<span>{formatCurrency(calculateRemaining())}</span> <span>{formatCurrency(calculateRemaining())}</span>
@@ -2075,13 +2189,178 @@ const ReceptionDashboardPage: React.FC = () => {
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p> <p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
</div> </div>
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg"> {/* Payment Method & Status */}
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Total Price</label> <div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
<p className="text-4xl font-bold bg-gradient-to-r from-amber-600 via-amber-700 to-amber-600 bg-clip-text text-transparent"> <label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
{formatCurrency(selectedBooking.total_price)} <div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
</p> Payment Information
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
<p className="text-base font-semibold text-slate-900">
{selectedBooking.payment_method === 'cash'
? '💵 Pay at Hotel'
: selectedBooking.payment_method === 'stripe'
? '💳 Stripe (Card)'
: selectedBooking.payment_method === 'paypal'
? '💳 PayPal'
: selectedBooking.payment_method || 'N/A'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
<p className={`text-base font-semibold ${
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
? 'text-green-600'
: selectedBooking.payment_status === 'pending'
? 'text-yellow-600'
: 'text-red-600'
}`}>
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
? '✅ Paid'
: selectedBooking.payment_status === 'pending'
? '⏳ Pending'
: selectedBooking.payment_status === 'failed'
? '❌ Failed'
: selectedBooking.payment_status || 'Unpaid'}
</p>
</div>
</div>
</div> </div>
{/* Payment History */}
{selectedBooking.payments && selectedBooking.payments.length > 0 && (
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-teal-400 to-teal-600 rounded-full"></div>
Payment History
</label>
<div className="space-y-3">
{selectedBooking.payments.map((payment: any, idx: number) => (
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-sm font-semibold text-slate-900">
{formatCurrency(payment.amount || 0)}
</p>
<p className="text-xs text-slate-500 mt-1">
{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'}
</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
payment.payment_status === 'completed' || payment.payment_status === 'paid'
? 'bg-green-100 text-green-700'
: payment.payment_status === 'pending'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
</span>
</div>
{payment.transaction_id && (
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
)}
{payment.payment_date && (
<p className="text-xs text-slate-400 mt-1">
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</p>
)}
</div>
))}
</div>
</div>
)}
{/* 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 */}
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
{formatCurrency(amountPaid)}
</p>
{hasPayments && completedPayments.length > 0 && (
<p className="text-xs text-green-600 mt-2">
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
{amountPaid > 0 && selectedBooking.total_price > 0 && (
<span className="ml-2">
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
</span>
)}
</p>
)}
{amountPaid === 0 && !hasPayments && (
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
)}
</div>
{/* Remaining Due */}
{remainingDue > 0 && (
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
<p className="text-3xl font-bold text-amber-600">
{formatCurrency(remainingDue)}
</p>
{selectedBooking.total_price > 0 && (
<p className="text-xs text-amber-600 mt-2">
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
</p>
)}
</div>
)}
{/* Total Booking Price */}
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
{selectedBooking.original_price && selectedBooking.discount_amount && selectedBooking.discount_amount > 0 ? (
<>
<div className="mb-2">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-slate-600">Subtotal:</span>
<span className="text-lg font-semibold text-slate-700">{formatCurrency(selectedBooking.original_price)}</span>
</div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-green-600">
Discount{selectedBooking.promotion_code ? ` (${selectedBooking.promotion_code})` : ''}:
</span>
<span className="text-lg font-semibold text-green-600">-{formatCurrency(selectedBooking.discount_amount)}</span>
</div>
<div className="border-t border-slate-300 pt-2 mt-2">
<div className="flex justify-between items-center">
<span className="text-sm font-semibold text-slate-700">Total:</span>
<span className="text-2xl font-bold text-slate-700">{formatCurrency(selectedBooking.total_price)}</span>
</div>
</div>
</div>
</>
) : (
<p className="text-2xl font-bold text-slate-700">
{formatCurrency(selectedBooking.total_price)}
</p>
)}
<p className="text-xs text-slate-500 mt-2">
This is the total amount for the booking
</p>
</div>
</>
);
})()}
{selectedBooking.notes && ( {selectedBooking.notes && (
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200"> <div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label> <label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label>

View File

@@ -36,11 +36,12 @@ import systemSettingsService, {
CompanySettingsResponse, CompanySettingsResponse,
UpdateCompanySettingsRequest, UpdateCompanySettingsRequest,
} from '../../services/api/systemSettingsService'; } from '../../services/api/systemSettingsService';
import { recaptchaService, RecaptchaSettingsAdminResponse, UpdateRecaptchaSettingsRequest } from '../../services/api/systemSettingsService';
import { useCurrency } from '../../contexts/CurrencyContext'; import { useCurrency } from '../../contexts/CurrencyContext';
import { Loading } from '../../components/common'; import { Loading } from '../../components/common';
import { getCurrencySymbol } from '../../utils/format'; 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 SettingsPage: React.FC = () => {
const { currency, supportedCurrencies, refreshCurrency } = useCurrency(); const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
@@ -105,12 +106,22 @@ const SettingsPage: React.FC = () => {
company_phone: '', company_phone: '',
company_email: '', company_email: '',
company_address: '', company_address: '',
tax_rate: 0,
}); });
const [logoPreview, setLogoPreview] = useState<string | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [faviconPreview, setFaviconPreview] = useState<string | null>(null); const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
const [uploadingLogo, setUploadingLogo] = useState(false); const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingFavicon, setUploadingFavicon] = useState(false); const [uploadingFavicon, setUploadingFavicon] = useState(false);
// reCAPTCHA Settings State
const [recaptchaSettings, setRecaptchaSettings] = useState<RecaptchaSettingsAdminResponse['data'] | null>(null);
const [recaptchaFormData, setRecaptchaFormData] = useState<UpdateRecaptchaSettingsRequest>({
recaptcha_site_key: '',
recaptcha_secret_key: '',
recaptcha_enabled: false,
});
const [showRecaptchaSecret, setShowRecaptchaSecret] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -146,6 +157,9 @@ const SettingsPage: React.FC = () => {
if (activeTab === 'company') { if (activeTab === 'company') {
loadCompanySettings(); loadCompanySettings();
} }
if (activeTab === 'recaptcha') {
loadRecaptchaSettings();
}
}, [activeTab]); }, [activeTab]);
useEffect(() => { useEffect(() => {
@@ -219,6 +233,7 @@ const SettingsPage: React.FC = () => {
company_phone: companyRes.data.company_phone || '', company_phone: companyRes.data.company_phone || '',
company_email: companyRes.data.company_email || '', company_email: companyRes.data.company_email || '',
company_address: companyRes.data.company_address || '', company_address: companyRes.data.company_address || '',
tax_rate: companyRes.data.tax_rate || 0,
}); });
// Set previews if URLs exist // 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) { if (loading) {
return <Loading fullScreen={false} text="Loading settings..." />; return <Loading fullScreen={false} text="Loading settings..." />;
} }
@@ -590,6 +640,7 @@ const SettingsPage: React.FC = () => {
{ id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard }, { id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard },
{ id: 'smtp' as SettingsTab, label: 'Email Server', icon: Mail }, { id: 'smtp' as SettingsTab, label: 'Email Server', icon: Mail },
{ id: 'company' as SettingsTab, label: 'Company Info', icon: Building2 }, { id: 'company' as SettingsTab, label: 'Company Info', icon: Building2 },
{ id: 'recaptcha' as SettingsTab, label: 'reCAPTCHA', icon: Shield },
]; ];
return ( return (
@@ -2154,6 +2205,29 @@ const SettingsPage: React.FC = () => {
Physical address of your company or hotel Physical address of your company or hotel
</p> </p>
</div> </div>
{/* Tax Rate */}
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<DollarSign className="w-4 h-4 text-gray-600" />
Tax Rate (%)
</label>
<input
type="number"
step="0.01"
min="0"
max="100"
value={companyFormData.tax_rate || 0}
onChange={(e) =>
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"
/>
<p className="text-xs text-gray-500">
Default tax rate percentage to be applied to all invoices (e.g., 10 for 10%). This will be used for all bookings unless overridden.
</p>
</div>
</div> </div>
</div> </div>
@@ -2178,6 +2252,152 @@ const SettingsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{activeTab === 'recaptcha' && (
<div className="space-y-8">
{/* Section Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
<Shield className="w-6 h-6 text-amber-600" />
Google reCAPTCHA Settings
</h2>
<p className="text-gray-600 mt-2">
Configure Google reCAPTCHA to protect your forms from spam and abuse
</p>
</div>
</div>
{/* reCAPTCHA Settings Form */}
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-8">
<div className="space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div>
<label className="text-sm font-semibold text-gray-900">
Enable reCAPTCHA
</label>
<p className="text-xs text-gray-500 mt-1">
Enable or disable reCAPTCHA verification across all forms
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={recaptchaFormData.recaptcha_enabled || false}
onChange={(e) =>
setRecaptchaFormData({
...recaptchaFormData,
recaptcha_enabled: e.target.checked,
})
}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-amber-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-600"></div>
</label>
</div>
{/* Site Key */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<Key className="w-4 h-4 text-gray-600" />
reCAPTCHA Site Key
</label>
<input
type="text"
value={recaptchaFormData.recaptcha_site_key || ''}
onChange={(e) =>
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"
/>
<p className="text-xs text-gray-500">
Your reCAPTCHA site key from Google. Get it from{' '}
<a
href="https://www.google.com/recaptcha/admin"
target="_blank"
rel="noopener noreferrer"
className="text-amber-600 hover:underline"
>
Google reCAPTCHA Admin
</a>
</p>
</div>
{/* Secret Key */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<Lock className="w-4 h-4 text-gray-600" />
reCAPTCHA Secret Key
</label>
<div className="relative">
<input
type={showRecaptchaSecret ? 'text' : 'password'}
value={recaptchaFormData.recaptcha_secret_key || ''}
onChange={(e) =>
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"
/>
<button
type="button"
onClick={() => setShowRecaptchaSecret(!showRecaptchaSecret)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showRecaptchaSecret ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<p className="text-xs text-gray-500">
Your reCAPTCHA secret key (keep this secure). Leave empty to keep existing value.
</p>
{recaptchaSettings?.recaptcha_secret_key_masked && (
<p className="text-xs text-amber-600">
Current: {recaptchaSettings.recaptcha_secret_key_masked}
</p>
)}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-semibold mb-1">About reCAPTCHA</p>
<p className="text-xs">
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.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSaveRecaptcha}
disabled={saving}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-5 h-5" />
{saving ? 'Saving...' : 'Save reCAPTCHA Settings'}
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -21,6 +21,9 @@ import {
} from '../../utils/validationSchemas'; } from '../../utils/validationSchemas';
import { useCompanySettings } from '../../contexts/CompanySettingsContext'; import { useCompanySettings } from '../../contexts/CompanySettingsContext';
import * as yup from 'yup'; 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({ const mfaTokenSchema = yup.object().shape({
mfaToken: yup mfaToken: yup
@@ -41,6 +44,7 @@ const LoginPage: React.FC = () => {
const { settings } = useCompanySettings(); const { settings } = useCompanySettings();
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// MFA form setup // MFA form setup
const { const {
@@ -78,6 +82,23 @@ const LoginPage: React.FC = () => {
const onSubmit = async (data: LoginFormData) => { const onSubmit = async (data: LoginFormData) => {
try { try {
clearError(); 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({ await login({
email: data.email, email: data.email,
password: data.password, password: data.password,
@@ -91,9 +112,11 @@ const LoginPage: React.FC = () => {
'/dashboard'; '/dashboard';
navigate(from, { replace: true }); navigate(from, { replace: true });
} }
setRecaptchaToken(null);
} catch (error) { } catch (error) {
// Error has been handled in store // Error has been handled in store
console.error('Login error:', error); console.error('Login error:', error);
setRecaptchaToken(null);
} }
}; };
@@ -391,6 +414,19 @@ const LoginPage: React.FC = () => {
</Link> </Link>
</div> </div>
{/* reCAPTCHA */}
<div className="flex justify-center">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
console.error('reCAPTCHA error:', error);
setRecaptchaToken(null);
}}
theme="light"
size="normal"
/>
</div>
{/* Submit Button */} {/* Submit Button */}
<button <button
type="submit" type="submit"

View File

@@ -22,6 +22,9 @@ import {
RegisterFormData, RegisterFormData,
} from '../../utils/validationSchemas'; } from '../../utils/validationSchemas';
import { useCompanySettings } from '../../contexts/CompanySettingsContext'; import { useCompanySettings } from '../../contexts/CompanySettingsContext';
import { toast } from 'react-toastify';
import Recaptcha from '../../components/common/Recaptcha';
import { recaptchaService } from '../../services/api/systemSettingsService';
const RegisterPage: React.FC = () => { const RegisterPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -32,6 +35,7 @@ const RegisterPage: React.FC = () => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = const [showConfirmPassword, setShowConfirmPassword] =
useState(false); useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Update page title // Update page title
useEffect(() => { useEffect(() => {
@@ -87,6 +91,23 @@ const RegisterPage: React.FC = () => {
const onSubmit = async (data: RegisterFormData) => { const onSubmit = async (data: RegisterFormData) => {
try { try {
clearError(); 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({ await registerUser({
name: data.name, name: data.name,
email: data.email, email: data.email,
@@ -96,9 +117,11 @@ const RegisterPage: React.FC = () => {
// Redirect to login page // Redirect to login page
navigate('/login', { replace: true }); navigate('/login', { replace: true });
setRecaptchaToken(null);
} catch (error) { } catch (error) {
// Error has been handled in store // Error has been handled in store
console.error('Register error:', error); console.error('Register error:', error);
setRecaptchaToken(null);
} }
}; };
@@ -443,6 +466,19 @@ const RegisterPage: React.FC = () => {
)} )}
</div> </div>
{/* reCAPTCHA */}
<div className="flex justify-center">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
console.error('reCAPTCHA error:', error);
setRecaptchaToken(null);
}}
theme="light"
size="normal"
/>
</div>
{/* Submit Button */} {/* Submit Button */}
<button <button
type="submit" type="submit"

View File

@@ -503,7 +503,7 @@ const BookingDetailPage: React.FC = () => {
</div> </div>
)} )}
{/* Payment Method */} {/* Payment Method & Status */}
<div className="border-t pt-4"> <div className="border-t pt-4">
<p className="text-sm text-gray-600 mb-1"> <p className="text-sm text-gray-600 mb-1">
<CreditCard className="w-4 h-4 inline mr-1" /> <CreditCard className="w-4 h-4 inline mr-1" />
@@ -529,6 +529,70 @@ const BookingDetailPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Payment History */}
{booking.payments && booking.payments.length > 0 && (
<div className="border-t pt-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Payment History
</h3>
<div className="space-y-3">
{booking.payments.map((payment: any, index: number) => (
<div key={payment.id || index} className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg font-bold text-green-700">
{formatPrice(payment.amount || 0)}
</span>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
payment.payment_status === 'completed' || payment.payment_status === 'paid'
? 'bg-green-100 text-green-700'
: payment.payment_status === 'pending'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
</span>
</div>
<div className="space-y-1">
<p className="text-sm text-gray-700">
<span className="font-medium">Payment Method:</span>{' '}
{payment.payment_method === 'stripe' ? '💳 Stripe (Card)' :
payment.payment_method === 'paypal' ? '💳 PayPal' :
payment.payment_method === 'cash' ? '💵 Cash' :
payment.payment_method || 'N/A'}
</p>
<p className="text-sm text-gray-700">
<span className="font-medium">Payment Type:</span>{' '}
{payment.payment_type === 'deposit' ? 'Deposit (20%)' :
payment.payment_type === 'remaining' ? 'Remaining Payment' :
'Full Payment'}
</p>
{payment.transaction_id && (
<p className="text-xs text-gray-500 font-mono">
Transaction ID: {payment.transaction_id}
</p>
)}
{payment.payment_date && (
<p className="text-xs text-gray-500">
Paid on: {new Date(payment.payment_date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Price Breakdown */} {/* Price Breakdown */}
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">
@@ -579,17 +643,59 @@ const BookingDetailPage: React.FC = () => {
return null; return null;
})()} })()}
{/* Total */} {/* Payment Breakdown */}
<div className="border-t pt-3 mt-3"> {(() => {
<div className="flex justify-between items-center"> // Calculate amount paid from completed payments
<span className="text-lg font-semibold text-gray-900"> const completedPayments = booking.payments?.filter(
Total Payment (p) => p.payment_status === 'completed'
</span> ) || [];
<span className="text-2xl font-bold text-indigo-600"> const amountPaid = completedPayments.reduce(
{formatPrice(booking.total_price)} (sum, p) => sum + (p.amount || 0),
</span> 0
</div> );
</div> const remainingDue = booking.total_price - amountPaid;
const hasPayments = completedPayments.length > 0;
return (
<>
{hasPayments && (
<>
<div className="border-t pt-3 mt-3">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">
Amount Paid:
</span>
<span className="text-base font-semibold text-green-600">
{formatPrice(amountPaid)}
</span>
</div>
{remainingDue > 0 && (
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">
Remaining Due:
</span>
<span className="text-base font-semibold text-amber-600">
{formatPrice(remainingDue)}
</span>
</div>
)}
</div>
</>
)}
{/* Total */}
<div className="border-t pt-3 mt-3">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">
Total Booking Price
</span>
<span className="text-2xl font-bold text-indigo-600">
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</>
);
})()}
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -484,20 +484,33 @@ const BookingSuccessPage: React.FC = () => {
{/* Total Price */} {/* Total Price */}
<div className="border-t pt-4"> <div className="border-t pt-4">
<div className="flex justify-between {booking.original_price && booking.discount_amount && booking.discount_amount > 0 ? (
items-center" <>
> <div className="mb-2">
<span className="text-lg font-semibold <div className="flex justify-between items-center mb-1">
text-gray-900" <span className="text-sm text-gray-600">Subtotal:</span>
> <span className="text-base font-semibold text-gray-900">{formatPrice(booking.original_price)}</span>
Total Payment </div>
</span> <div className="flex justify-between items-center mb-2">
<span className="text-2xl font-bold <span className="text-sm text-green-600">
text-indigo-600" Discount{booking.promotion_code ? ` (${booking.promotion_code})` : ''}:
> </span>
{formatPrice(booking.total_price)} <span className="text-base font-semibold text-green-600">-{formatPrice(booking.discount_amount)}</span>
</span> </div>
</div> <div className="border-t border-gray-300 pt-2 mt-2">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">Total Payment</span>
<span className="text-2xl font-bold text-indigo-600">{formatPrice(booking.total_price)}</span>
</div>
</div>
</div>
</>
) : (
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">Total Payment</span>
<span className="text-2xl font-bold text-indigo-600">{formatPrice(booking.total_price)}</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,9 +5,10 @@ import {
AlertCircle, AlertCircle,
CreditCard, CreditCard,
ArrowLeft, ArrowLeft,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { getBookingById, type Booking } from import { getBookingById, cancelBooking, type Booking } from
'../../services/api/bookingService'; '../../services/api/bookingService';
import { import {
getPaymentsByBookingId, getPaymentsByBookingId,
@@ -21,13 +22,15 @@ import PayPalPaymentWrapper from '../../components/payments/PayPalPaymentWrapper
const DepositPaymentPage: React.FC = () => { const DepositPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>(); const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency(); const { formatCurrency, currency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(null); const [booking, setBooking] = useState<Booking | null>(null);
const [depositPayment, setDepositPayment] = useState<Payment | null>(null); const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [paymentSuccess, setPaymentSuccess] = useState(false); const [paymentSuccess, setPaymentSuccess] = useState(false);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<'stripe' | 'paypal' | null>(null);
const [cancelling, setCancelling] = useState(false);
useEffect(() => { useEffect(() => {
if (bookingId) { if (bookingId) {
@@ -86,30 +89,82 @@ const DepositPaymentPage: React.FC = () => {
const formatPrice = (price: number) => formatCurrency(price); 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!`
);
// Navigate to bookings list after cancellation
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) { if (loading) {
return <Loading fullScreen text="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) { if (error || !booking || !depositPayment) {
return ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div
<div className="max-w-4xl mx-auto px-4"> 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 <div
className="bg-red-50 border border-red-200 className="bg-gradient-to-br from-red-900/20 to-red-800/10
rounded-lg p-8 text-center" 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-12 h-12 text-red-500 mx-auto mb-3" /> <AlertCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400 mx-auto mb-4" />
<p className="text-red-700 font-medium 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'} {error || 'Payment information not found'}
</p> </p>
<Link <Link
to="/bookings" to="/bookings"
className="inline-flex items-center gap-2 px-6 py-2 className="inline-flex items-center gap-2 bg-gradient-to-r
bg-red-600 text-white rounded-lg hover:bg-red-700 from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
transition-colors" 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" /> <ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
Back to booking list Back to booking list
</Link> </Link>
</div> </div>
@@ -123,36 +178,71 @@ const DepositPaymentPage: React.FC = () => {
const isDepositPaid = depositPayment.payment_status === 'completed'; const isDepositPaid = depositPayment.payment_status === 'completed';
return ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div
<div className="max-w-4xl mx-auto px-4"> 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"
{/* Back Button */} style={{
<Link marginLeft: 'calc(50% - 50vw)',
to={`/bookings/${bookingId}`} marginRight: 'calc(50% - 50vw)',
className="inline-flex items-center gap-2 text-gray-600 width: '100vw',
hover:text-gray-900 mb-6 transition-colors" zIndex: 1
> }}
<ArrowLeft className="w-5 h-5" /> >
<span>Back to booking details</span> <div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
</Link> {/* Back Button and Cancel Button */}
<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>
{/* Cancel Booking Button - Only show if deposit not paid */}
{!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>
{/* Success Header (if paid) */} {/* Success Header (if paid) */}
{isDepositPaid && ( {isDepositPaid && (
<div <div
className="bg-green-50 border-2 border-green-200 className="bg-gradient-to-br from-green-900/20 to-green-800/10
rounded-lg p-6 mb-6" 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 items-center gap-4"> <div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
<div <div
className="w-16 h-16 bg-green-100 rounded-full className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-green-500/20 to-green-600/20
flex items-center justify-center" rounded-full flex items-center justify-center flex-shrink-0
border border-green-500/30 shadow-sm shadow-green-500/20"
> >
<CheckCircle className="w-10 h-10 text-green-600" /> <CheckCircle className="w-6 h-6 sm:w-7 sm:h-7 text-green-400" />
</div> </div>
<div className="flex-1"> <div className="flex-1 text-center sm:text-left">
<h1 className="text-2xl font-bold text-green-900 mb-1"> <h1 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-1 tracking-wide">
Deposit payment successful! Deposit Payment Successful!
</h1> </h1>
<p className="text-green-700"> <p className="text-green-200/80 font-light text-xs sm:text-sm tracking-wide">
Your booking has been confirmed. Your booking has been confirmed.
Remaining amount to be paid at check-in. Remaining amount to be paid at check-in.
</p> </p>
@@ -164,22 +254,24 @@ const DepositPaymentPage: React.FC = () => {
{/* Pending Header */} {/* Pending Header */}
{!isDepositPaid && ( {!isDepositPaid && (
<div <div
className="bg-orange-50 border-2 border-orange-200 className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
rounded-lg p-6 mb-6" 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 items-center gap-4"> <div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
<div <div
className="w-16 h-16 bg-orange-100 rounded-full className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
flex items-center justify-center" rounded-full flex items-center justify-center flex-shrink-0
border border-[#d4af37]/30 shadow-sm shadow-[#d4af37]/20"
> >
<CreditCard className="w-10 h-10 text-orange-600" /> <CreditCard className="w-6 h-6 sm:w-7 sm:h-7 text-[#d4af37]" />
</div> </div>
<div className="flex-1"> <div className="flex-1 text-center sm:text-left">
<h1 className="text-2xl font-bold text-orange-900 mb-1"> <h1 className="text-base sm:text-lg font-serif font-semibold text-[#d4af37] mb-1 tracking-wide">
Deposit Payment Deposit Payment Required
</h1> </h1>
<p className="text-orange-700"> <p className="text-gray-300/80 font-light text-xs sm:text-sm tracking-wide">
Please pay <strong>20% deposit</strong> to Please pay <strong className="text-[#d4af37] font-medium">20% deposit</strong> to
confirm your booking confirm your booking
</p> </p>
</div> </div>
@@ -187,132 +279,225 @@ const DepositPaymentPage: React.FC = () => {
</div> </div>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="w-full">
{/* Payment Info */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
<div className="lg:col-span-2 space-y-6"> {/* Payment Info */}
{/* Payment Summary */} <div className="lg:col-span-2 space-y-3">
<div className="bg-white rounded-lg shadow-md p-6"> {/* Payment Summary */}
<h2 className="text-xl font-bold text-gray-900 mb-4"> <div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
Payment Information border border-gray-700/50 rounded-lg p-3 sm:p-4
</h2> 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-3"> <div className="space-y-2">
<div className="flex justify-between"> <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-600">Total Room Price</span> <span className="text-gray-300 font-light tracking-wide text-xs sm:text-sm">Total Room Price</span>
<span className="font-medium"> <span className="font-medium text-gray-100 text-xs sm:text-sm">
{formatPrice(booking.total_price)} {formatPrice(booking.total_price)}
</span> </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> </div>
<div {isDepositPaid && (
className="flex justify-between border-t pt-3 <div className="mt-4 sm:mt-6 bg-gradient-to-br from-green-900/20 to-green-800/10
text-orange-600" 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">
<span className="font-medium"> Deposit paid on:{' '}
Deposit Amount to Pay (20%) {depositPayment.payment_date
</span> ? new Date(depositPayment.payment_date).toLocaleString('en-US')
<span className="text-xl font-bold"> : 'N/A'}
{formatPrice(depositAmount)} </p>
</span> {depositPayment.transaction_id && (
</div> <p className="text-xs text-green-400/70 mt-2 font-mono break-all">
Transaction ID: {depositPayment.transaction_id}
<div className="flex justify-between text-sm text-gray-500"> </p>
<span>Remaining amount to be paid at check-in</span> )}
<span>{formatPrice(remainingAmount)}</span> </div>
</div> )}
</div> </div>
{isDepositPaid && ( {/* Payment Method Selection */}
<div className="mt-4 bg-green-50 border border-green-200 rounded p-3"> {!isDepositPaid && !selectedPaymentMethod && (
<p className="text-sm text-green-800"> <div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
Deposit paid on:{' '} border border-gray-700/50 rounded-lg p-3 sm:p-4
{depositPayment.payment_date backdrop-blur-xl shadow-lg shadow-black/20">
? new Date(depositPayment.payment_date).toLocaleString('en-US') <h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-1.5 tracking-wide">
: 'N/A'} 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> </p>
{depositPayment.transaction_id && (
<p className="text-xs text-green-700 mt-1"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 sm:gap-3">
Transaction ID: {depositPayment.transaction_id} {/* Stripe Option */}
</p> <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>
{/* PayPal Option */}
<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>
)}
{/* Payment Method Selection Header (when method is selected) */}
{!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>
)}
{/* Stripe Payment Panel */}
{!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.');
// Navigate to booking details after successful payment
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
}}
onError={(error) => {
toast.error(error || 'Payment failed');
}}
/>
)} )}
</div> </div>
)} )}
</div>
{/* Payment Method Selection */} {/* PayPal Payment Panel */}
{!isDepositPaid && ( {!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'paypal' && (
<div className="bg-white rounded-lg shadow-md p-6"> <div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
<h2 className="text-xl font-bold text-gray-900 mb-6"> border border-gray-700/50 rounded-lg p-3 sm:p-4
Payment Method backdrop-blur-xl shadow-lg shadow-black/20">
</h2> <PayPalPaymentWrapper
<p className="text-sm text-gray-600 mb-4">
Pay with your credit or debit card
</p>
</div>
)}
{/* Stripe Payment Panel */}
{!isDepositPaid && booking && depositPayment && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<CreditCard className="w-5 h-5 inline mr-2" />
Card Payment
</h2>
{paymentSuccess ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-3" />
<h3 className="text-lg font-bold text-green-900 mb-2">
Payment Successful!
</h3>
<p className="text-green-700 mb-4">
Your deposit payment has been confirmed.
</p>
<button
onClick={() => navigate(`/bookings/${booking.id}`)}
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition-colors"
>
View Booking
</button>
</div>
) : (
<StripePaymentWrapper
bookingId={booking.id} bookingId={booking.id}
amount={depositAmount} amount={depositAmount}
onSuccess={() => { currency={currency || 'USD'}
setPaymentSuccess(true);
toast.success('✅ Payment successful! Your booking has been confirmed.');
// Navigate to booking details after successful payment
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
}}
onError={(error) => { onError={(error) => {
toast.error(error || 'Payment failed'); toast.error(error || 'Payment failed');
}} }}
/> />
)} </div>
</div> )}
)} </div>
{/* PayPal Payment Panel */}
{!paymentSuccess && booking && depositPayment && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<CreditCard className="w-5 h-5 inline mr-2" />
PayPal Payment
</h2>
<PayPalPaymentWrapper
bookingId={booking.id}
amount={depositAmount}
onError={(error) => {
toast.error(error || 'Payment failed');
}}
/>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -564,6 +564,33 @@ const MyBookingsPage: React.FC = () => {
> >
{formatPrice(booking.total_price)} {formatPrice(booking.total_price)}
</p> </p>
{(() => {
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;
if (hasPayments) {
return (
<div className="text-xs mt-1">
<div className="text-green-600 font-medium">
Paid: {formatPrice(amountPaid)}
</div>
{remainingDue > 0 && (
<div className="text-amber-600 font-medium">
Due: {formatPrice(remainingDue)}
</div>
)}
</div>
);
}
return null;
})()}
</div> </div>
</div> </div>

View File

@@ -1,35 +1,95 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { XCircle, ArrowLeft } from 'lucide-react'; import { XCircle, ArrowLeft, Loader2 } from 'lucide-react';
import { cancelPayPalPayment } from '../../services/api/paymentService';
import { toast } from 'react-toastify';
const PayPalCancelPage: React.FC = () => { const PayPalCancelPage: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const bookingId = searchParams.get('bookingId'); const bookingId = searchParams.get('bookingId');
const [cancelling, setCancelling] = useState(false);
useEffect(() => {
const handleCancel = async () => {
if (!bookingId) return;
try {
setCancelling(true);
const response = await cancelPayPalPayment(Number(bookingId));
if (response.success) {
toast.info('Payment canceled. Your booking has been automatically cancelled.');
}
} catch (err: any) {
console.error('Error canceling payment:', err);
// Don't show error toast - user already canceled, just log it
} finally {
setCancelling(false);
}
};
handleCancel();
}, [bookingId]);
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center"> flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<XCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" /> <div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
<h1 className="text-2xl font-bold text-gray-900 mb-2"> border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
Payment Cancelled backdrop-blur-xl shadow-2xl shadow-black/20">
</h1> {cancelling ? (
<p className="text-gray-600 mb-6"> <>
You cancelled the PayPal payment. No charges were made. <div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-orange-500/20 to-orange-600/20
</p> rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
<div className="flex gap-3"> border border-orange-500/30 shadow-lg shadow-orange-500/20">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 text-orange-400 animate-spin" />
</div>
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-orange-300 mb-3 tracking-wide">
Processing Cancellation
</h1>
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
Canceling your payment and booking...
</p>
</>
) : (
<>
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-orange-500/20 to-orange-600/20
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
border border-orange-500/30 shadow-lg shadow-orange-500/20">
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-orange-400" />
</div>
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-orange-300 mb-3 tracking-wide">
Payment Cancelled
</h1>
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide leading-relaxed px-2">
You cancelled the PayPal payment. No charges were made. Your booking has been automatically cancelled.
</p>
</>
)}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
{bookingId && ( {bookingId && (
<button <button
onClick={() => navigate(`/deposit-payment/${bookingId}`)} onClick={() => navigate(`/payment/deposit/${bookingId}`)}
className="flex-1 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center justify-center gap-2" 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 flex items-center justify-center gap-2
disabled:opacity-50 disabled:cursor-not-allowed text-sm sm:text-base"
disabled={cancelling}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
Try Again Try Again
</button> </button>
)} )}
<button <button
onClick={() => navigate('/bookings')} onClick={() => navigate('/bookings')}
className="flex-1 bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition-colors" className="flex-1 bg-gradient-to-br from-gray-800/40 to-gray-700/20
border border-gray-600/30 text-gray-300 px-4 py-2 sm:px-6 sm:py-3 rounded-sm
hover:border-[#d4af37]/50 hover:text-[#d4af37]
transition-all duration-300 font-light tracking-wide
backdrop-blur-sm text-sm sm:text-base"
> >
My Bookings My Bookings
</button> </button>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { capturePayPalPayment } from '../../services/api/paymentService'; import { capturePayPalPayment, cancelPayPalPayment } from '../../services/api/paymentService';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
@@ -37,6 +37,13 @@ const PayPalReturnPage: React.FC = () => {
} else { } else {
setError(response.message || 'Payment capture failed'); setError(response.message || 'Payment capture failed');
toast.error(response.message || 'Payment capture failed'); toast.error(response.message || 'Payment capture failed');
// If payment capture fails, cancel the payment and booking
try {
await cancelPayPalPayment(Number(bookingId));
} catch (cancelErr) {
console.error('Error canceling payment after capture failure:', cancelErr);
}
} }
} catch (err: any) { } catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment'; const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
@@ -52,10 +59,17 @@ const PayPalReturnPage: React.FC = () => {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
<div className="text-center"> flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<Loader2 className="w-12 h-12 animate-spin text-indigo-600 mx-auto mb-4" /> <div className="text-center w-full max-w-2xl mx-auto">
<p className="text-gray-600">Processing your payment...</p> <div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 text-[#d4af37] animate-spin" />
</div>
<p className="text-gray-300/80 font-light text-base sm:text-lg tracking-wide">
Processing your payment...
</p>
</div> </div>
</div> </div>
); );
@@ -63,18 +77,29 @@ const PayPalReturnPage: React.FC = () => {
if (success) { if (success) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center"> flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" /> <div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
<h1 className="text-2xl font-bold text-gray-900 mb-2"> border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
backdrop-blur-xl shadow-2xl shadow-black/20">
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-green-500/20 to-green-600/20
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
border border-green-500/30 shadow-lg shadow-green-500/20">
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400" />
</div>
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-green-300 mb-3 tracking-wide">
Payment Successful! Payment Successful!
</h1> </h1>
<p className="text-gray-600 mb-6"> <p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
Your payment has been confirmed. Redirecting to booking details... Your payment has been confirmed. Redirecting to booking details...
</p> </p>
<button <button
onClick={() => navigate(`/bookings/${bookingId}`)} onClick={() => navigate(`/bookings/${bookingId}`)}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors" className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-6 py-2 sm:px-8 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 w-full sm:w-auto"
> >
View Booking View Booking
</button> </button>
@@ -84,25 +109,40 @@ const PayPalReturnPage: React.FC = () => {
} }
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center"> flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" /> <div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
<h1 className="text-2xl font-bold text-gray-900 mb-2"> border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl text-center
backdrop-blur-xl shadow-2xl shadow-black/20">
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-red-500/20 to-red-600/20
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
border border-red-500/30 shadow-lg shadow-red-500/20">
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400" />
</div>
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-red-300 mb-3 tracking-wide">
Payment Failed Payment Failed
</h1> </h1>
<p className="text-gray-600 mb-6"> <p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide leading-relaxed px-2">
{error || 'Unable to process your payment. Please try again.'} {error || 'Unable to process your payment. Please try again.'}
</p> </p>
<div className="flex gap-3"> <div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<button <button
onClick={() => navigate(`/deposit-payment/${bookingId}`)} onClick={() => navigate(`/payment/deposit/${bookingId}`)}
className="flex-1 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors" 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 Retry Payment
</button> </button>
<button <button
onClick={() => navigate('/bookings')} onClick={() => navigate('/bookings')}
className="flex-1 bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition-colors" className="flex-1 bg-gradient-to-br from-gray-800/40 to-gray-700/20
border border-gray-600/30 text-gray-300 px-4 py-2 sm:px-6 sm:py-3 rounded-sm
hover:border-[#d4af37]/50 hover:text-[#d4af37]
transition-all duration-300 font-light tracking-wide
backdrop-blur-sm text-sm sm:text-base"
> >
My Bookings My Bookings
</button> </button>

View File

@@ -71,8 +71,18 @@ const RoomDetailPage: React.FC = () => {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]"> <div
<div className="max-w-7xl mx-auto px-4 py-8"> className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)',
width: '100vw',
paddingTop: '1.5rem',
paddingBottom: '1.5rem',
zIndex: 1
}}
>
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8 sm:py-12">
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
<div className="h-[600px] bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-xl border border-[#d4af37]/20" /> <div className="h-[600px] bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-xl border border-[#d4af37]/20" />
<div className="h-12 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg w-1/3 border border-[#d4af37]/10" /> <div className="h-12 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg w-1/3 border border-[#d4af37]/10" />
@@ -85,8 +95,18 @@ const RoomDetailPage: React.FC = () => {
if (error || !room) { if (error || !room) {
return ( return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]"> <div
<div className="max-w-7xl mx-auto px-4 py-8"> className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)',
width: '100vw',
paddingTop: '1.5rem',
paddingBottom: '1.5rem',
zIndex: 1
}}
>
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8 sm:py-12">
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10 <div className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-12 text-center border border-red-500/30 rounded-xl p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10" backdrop-blur-xl shadow-2xl shadow-red-500/10"
@@ -115,22 +135,32 @@ const RoomDetailPage: React.FC = () => {
const formattedPrice = formatCurrency(room?.price || roomType?.base_price || 0); const formattedPrice = formatCurrency(room?.price || roomType?.base_price || 0);
return ( return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]"> <div
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)',
width: '100vw',
paddingTop: '1.5rem',
paddingBottom: '1.5rem',
zIndex: 1
}}
>
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
{/* Back Button */} {/* Back Button */}
<Link <Link
to="/rooms" to="/rooms"
className="inline-flex items-center gap-2 className="inline-flex items-center gap-1
text-[#d4af37]/80 hover:text-[#d4af37] text-[#d4af37]/80 hover:text-[#d4af37]
mb-8 transition-all duration-300 mb-3 transition-all duration-300
group font-light tracking-wide" group font-light tracking-wide text-xs sm:text-sm"
> >
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" /> <ArrowLeft className="w-3.5 h-3.5 group-hover:-translate-x-1 transition-transform" />
<span>Back to room list</span> <span>Back to room list</span>
</Link> </Link>
{/* Image Gallery */} {/* Image Gallery */}
<div className="mb-12"> <div className="mb-4">
<RoomGallery <RoomGallery
images={(room.images && room.images.length > 0) images={(room.images && room.images.length > 0)
? room.images ? room.images
@@ -140,30 +170,30 @@ const RoomDetailPage: React.FC = () => {
</div> </div>
{/* Room Information */} {/* Room Information */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-16"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4 lg:gap-6 mb-4 sm:mb-5">
{/* Main Info */} {/* Main Info */}
<div className="lg:col-span-8 space-y-10"> <div className="lg:col-span-8 space-y-3 sm:space-y-4">
{/* Title & Basic Info */} {/* Title & Basic Info */}
<div className="space-y-6"> <div className="space-y-3">
{/* Room Name with Luxury Badge */} {/* Room Name with Luxury Badge */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-1.5 mb-2">
{room.featured && ( {room.featured && (
<div className="flex items-center gap-2 <div className="flex items-center gap-1
bg-gradient-to-r from-[#d4af37] to-[#c9a227] bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-4 py-1.5 rounded-sm text-[#0f0f0f] px-2 py-0.5 rounded-sm
text-xs font-medium tracking-wide text-[10px] sm:text-xs font-medium tracking-wide
shadow-lg shadow-[#d4af37]/30" shadow-sm shadow-[#d4af37]/30"
> >
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-2.5 h-2.5" />
Featured Featured
</div> </div>
)} )}
<div <div
className={`px-4 py-1.5 rounded-sm className={`px-2 py-0.5 rounded-sm
text-xs font-medium tracking-wide text-[10px] sm:text-xs font-medium tracking-wide
backdrop-blur-sm shadow-lg backdrop-blur-sm shadow-sm
${ ${
room.status === 'available' room.status === 'available'
? 'bg-green-500/90 text-white border border-green-400/50' ? 'bg-green-500/90 text-white border border-green-400/50'
@@ -180,8 +210,8 @@ const RoomDetailPage: React.FC = () => {
</div> </div>
</div> </div>
<h1 className="text-5xl font-serif font-semibold <h1 className="text-xl sm:text-2xl lg:text-3xl font-serif font-semibold
text-white mb-6 tracking-tight leading-tight text-white mb-2 tracking-tight leading-tight
bg-gradient-to-r from-white via-[#d4af37] to-white bg-gradient-to-r from-white via-[#d4af37] to-white
bg-clip-text text-transparent" bg-clip-text text-transparent"
> >
@@ -191,63 +221,63 @@ const RoomDetailPage: React.FC = () => {
</div> </div>
{/* Basic Info Grid */} {/* Basic Info Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-3 mb-3">
<div className="flex items-center gap-3 <div className="flex items-center gap-2
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-lg border border-[#d4af37]/20 rounded-lg border border-[#d4af37]/20
hover:border-[#d4af37]/40 transition-all duration-300" hover:border-[#d4af37]/40 transition-all duration-300"
> >
<div className="p-2 bg-[#d4af37]/10 rounded-lg <div className="p-1 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30"> border border-[#d4af37]/30">
<MapPin className="w-5 h-5 text-[#d4af37]" /> <MapPin className="w-3.5 h-3.5 text-[#d4af37]" />
</div> </div>
<div> <div>
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5"> <p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
Location Location
</p> </p>
<p className="text-white font-light tracking-wide"> <p className="text-xs sm:text-sm text-white font-light tracking-wide">
Room {room.room_number} - Floor {room.floor} Room {room.room_number} - Floor {room.floor}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 <div className="flex items-center gap-2
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-lg border border-[#d4af37]/20" rounded-lg border border-[#d4af37]/20"
> >
<div className="p-2 bg-[#d4af37]/10 rounded-lg <div className="p-1 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30"> border border-[#d4af37]/30">
<Users className="w-5 h-5 text-[#d4af37]" /> <Users className="w-3.5 h-3.5 text-[#d4af37]" />
</div> </div>
<div> <div>
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5"> <p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
Capacity Capacity
</p> </p>
<p className="text-white font-light tracking-wide"> <p className="text-xs sm:text-sm text-white font-light tracking-wide">
{room?.capacity || roomType?.capacity || 0} guests {room?.capacity || roomType?.capacity || 0} guests
</p> </p>
</div> </div>
</div> </div>
{room.average_rating != null && ( {room.average_rating != null && (
<div className="flex items-center gap-3 <div className="flex items-center gap-2
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-lg border border-[#d4af37]/20 rounded-lg border border-[#d4af37]/20
hover:border-[#d4af37]/40 transition-all duration-300" hover:border-[#d4af37]/40 transition-all duration-300"
> >
<div className="p-2 bg-[#d4af37]/10 rounded-lg <div className="p-1 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30"> border border-[#d4af37]/30">
<Star className="w-5 h-5 text-[#d4af37] fill-[#d4af37]" /> <Star className="w-3.5 h-3.5 text-[#d4af37] fill-[#d4af37]" />
</div> </div>
<div> <div>
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5"> <p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
Rating Rating
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<p className="text-white font-semibold"> <p className="text-xs sm:text-sm text-white font-semibold">
{Number(room.average_rating).toFixed(1)} {Number(room.average_rating).toFixed(1)}
</p> </p>
<span className="text-xs text-gray-500 font-light"> <span className="text-[10px] sm:text-xs text-gray-500 font-light">
({room.total_reviews || 0}) ({room.total_reviews || 0})
</span> </span>
</div> </div>
@@ -259,23 +289,23 @@ const RoomDetailPage: React.FC = () => {
{/* Description - Show room-specific description first, fallback to room type */} {/* Description - Show room-specific description first, fallback to room type */}
{(room?.description || roomType?.description) && ( {(room?.description || roomType?.description) && (
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] <div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20 rounded-lg border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5" backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
> >
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-2 mb-2">
<div className="p-2 bg-[#d4af37]/10 rounded-lg <div className="p-1 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30"> border border-[#d4af37]/30">
<Award className="w-5 h-5 text-[#d4af37]" /> <Award className="w-3.5 h-3.5 text-[#d4af37]" />
</div> </div>
<h2 className="text-2xl font-serif font-semibold <h2 className="text-sm sm:text-base font-serif font-semibold
text-white tracking-wide" text-white tracking-wide"
> >
{room?.description ? 'Room Description' : 'Room Type Description'} {room?.description ? 'Room Description' : 'Room Type Description'}
</h2> </h2>
</div> </div>
<p className="text-gray-300 leading-relaxed <p className="text-gray-300 leading-relaxed
font-light tracking-wide text-lg" font-light tracking-wide text-xs sm:text-sm"
> >
{room?.description || roomType?.description} {room?.description || roomType?.description}
</p> </p>
@@ -283,16 +313,16 @@ const RoomDetailPage: React.FC = () => {
)} )}
{/* Amenities */} {/* Amenities */}
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] <div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20 rounded-lg border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5" backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
> >
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-2 mb-2">
<div className="p-2 bg-[#d4af37]/10 rounded-lg <div className="p-1 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30"> border border-[#d4af37]/30">
<Sparkles className="w-5 h-5 text-[#d4af37]" /> <Sparkles className="w-3.5 h-3.5 text-[#d4af37]" />
</div> </div>
<h2 className="text-2xl font-serif font-semibold <h2 className="text-sm sm:text-base font-serif font-semibold
text-white tracking-wide" text-white tracking-wide"
> >
Amenities & Features Amenities & Features
@@ -311,25 +341,25 @@ const RoomDetailPage: React.FC = () => {
{/* Booking Card */} {/* Booking Card */}
<aside className="lg:col-span-4"> <aside className="lg:col-span-4">
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] <div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
rounded-xl border border-[#d4af37]/30 rounded-lg border border-[#d4af37]/30
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20 backdrop-blur-xl shadow-lg shadow-[#d4af37]/20
p-8 sticky top-6" p-3 sm:p-4 sticky top-4"
> >
{/* Price Section */} {/* Price Section */}
<div className="mb-8 pb-8 border-b border-[#d4af37]/20"> <div className="mb-4 pb-4 border-b border-[#d4af37]/20">
<p className="text-xs text-gray-400 font-light tracking-wide mb-2"> <p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-1">
Starting from Starting from
</p> </p>
<div className="flex items-baseline gap-3"> <div className="flex items-baseline gap-1.5">
<CurrencyIcon className="text-[#d4af37]" size={24} /> <CurrencyIcon className="text-[#d4af37]" size={16} />
<div> <div>
<div className="text-4xl font-serif font-semibold <div className="text-2xl sm:text-3xl font-serif font-semibold
bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-gradient-to-r from-[#d4af37] to-[#f5d76e]
bg-clip-text text-transparent tracking-tight" bg-clip-text text-transparent tracking-tight"
> >
{formattedPrice} {formattedPrice}
</div> </div>
<div className="text-sm text-gray-400 font-light tracking-wide mt-1"> <div className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mt-0.5">
/ night / night
</div> </div>
</div> </div>
@@ -337,23 +367,23 @@ const RoomDetailPage: React.FC = () => {
</div> </div>
{/* Booking Button */} {/* Booking Button */}
<div className="mb-6"> <div className="mb-3">
<Link <Link
to={`/booking/${room.id}`} to={`/booking/${room.id}`}
className={`block w-full py-4 text-center className={`block w-full py-2 text-center
font-medium rounded-sm transition-all duration-300 font-medium rounded-sm transition-all duration-300
tracking-wide relative overflow-hidden group tracking-wide relative overflow-hidden group text-xs sm:text-sm
${ ${
room.status === 'available' room.status === 'available'
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-lg shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50' ? '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' : 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
}`} }`}
onClick={(e) => { onClick={(e) => {
if (room.status !== 'available') e.preventDefault(); if (room.status !== 'available') e.preventDefault();
}} }}
> >
<span className="relative z-10 flex items-center justify-center gap-2"> <span className="relative z-10 flex items-center justify-center gap-1.5">
<Calendar className="w-5 h-5" /> <Calendar className="w-3.5 h-3.5" />
{room.status === 'available' ? 'Book Now' : 'Not Available'} {room.status === 'available' ? 'Book Now' : 'Not Available'}
</span> </span>
{room.status === 'available' && ( {room.status === 'available' && (
@@ -363,42 +393,42 @@ const RoomDetailPage: React.FC = () => {
</div> </div>
{room.status === 'available' && ( {room.status === 'available' && (
<div className="flex items-start gap-3 p-4 bg-[#d4af37]/5 <div className="flex items-start gap-2 p-2 bg-[#d4af37]/5
rounded-lg border border-[#d4af37]/20 mb-6" rounded-lg border border-[#d4af37]/20 mb-3"
> >
<Shield className="w-5 h-5 text-[#d4af37] mt-0.5 flex-shrink-0" /> <Shield className="w-3.5 h-3.5 text-[#d4af37] mt-0.5 flex-shrink-0" />
<p className="text-sm text-gray-300 font-light tracking-wide"> <p className="text-[10px] sm:text-xs text-gray-300 font-light tracking-wide leading-relaxed">
No immediate charge secure your booking now and pay at the hotel No immediate charge secure your booking now and pay at the hotel
</p> </p>
</div> </div>
)} )}
{/* Room Details */} {/* Room Details */}
<div className="space-y-4"> <div className="space-y-2">
<div className="flex items-center justify-between <div className="flex items-center justify-between
py-3 border-b border-[#d4af37]/10" py-1.5 border-b border-[#d4af37]/10"
> >
<span className="text-gray-400 font-light tracking-wide">Room Type</span> <span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Type</span>
<strong className="text-white font-light">{roomType?.name}</strong> <strong className="text-xs sm:text-sm text-white font-light">{roomType?.name}</strong>
</div> </div>
<div className="flex items-center justify-between <div className="flex items-center justify-between
py-3 border-b border-[#d4af37]/10" py-1.5 border-b border-[#d4af37]/10"
> >
<span className="text-gray-400 font-light tracking-wide">Max Guests</span> <span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Max Guests</span>
<span className="text-white font-light">{(room?.capacity || roomType?.capacity || 0)} guests</span> <span className="text-xs sm:text-sm text-white font-light">{(room?.capacity || roomType?.capacity || 0)} guests</span>
</div> </div>
{room?.room_size && ( {room?.room_size && (
<div className="flex items-center justify-between <div className="flex items-center justify-between
py-3 border-b border-[#d4af37]/10" py-1.5 border-b border-[#d4af37]/10"
> >
<span className="text-gray-400 font-light tracking-wide">Room Size</span> <span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Size</span>
<span className="text-white font-light">{room.room_size}</span> <span className="text-xs sm:text-sm text-white font-light">{room.room_size}</span>
</div> </div>
)} )}
{room?.view && ( {room?.view && (
<div className={`flex items-center justify-between ${room?.room_size ? 'py-3 border-b border-[#d4af37]/10' : 'py-3'}`}> <div className={`flex items-center justify-between ${room?.room_size ? 'py-1.5 border-b border-[#d4af37]/10' : 'py-1.5'}`}>
<span className="text-gray-400 font-light tracking-wide">View</span> <span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">View</span>
<span className="text-white font-light">{room.view}</span> <span className="text-xs sm:text-sm text-white font-light">{room.view}</span>
</div> </div>
)} )}
</div> </div>
@@ -407,9 +437,9 @@ const RoomDetailPage: React.FC = () => {
</div> </div>
{/* Reviews Section */} {/* Reviews Section */}
<div className="mb-12 p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] <div className="mb-4 p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20 rounded-lg border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5" backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
> >
<ReviewSection roomId={room.id} /> <ReviewSection roomId={room.id} />
</div> </div>

View File

@@ -29,6 +29,9 @@ export interface Booking {
check_out_date: string; check_out_date: string;
guest_count: number; guest_count: number;
total_price: number; total_price: number;
original_price?: number;
discount_amount?: number;
promotion_code?: string;
status: status:
| 'pending' | 'pending'
| 'confirmed' | 'confirmed'
@@ -70,6 +73,13 @@ export interface Booking {
phone_number?: string; phone_number?: string;
}; };
payments?: Payment[]; payments?: Payment[];
payment_balance?: {
total_paid: number;
total_price: number;
remaining_balance: number;
is_fully_paid: boolean;
payment_percentage: number;
};
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -365,6 +365,30 @@ export const capturePayPalPayment = async (
}; };
}; };
/**
* Cancel PayPal payment (when user cancels on PayPal page)
* POST /api/payments/paypal/cancel
*/
export const cancelPayPalPayment = async (
bookingId: number
): Promise<{
success: boolean;
message?: string;
}> => {
const response = await apiClient.post(
'/payments/paypal/cancel',
{
booking_id: bookingId,
}
);
// Map backend response format (status: "success") to frontend format (success: true)
const data = response.data;
return {
success: data.status === "success" || data.success === true,
message: data.message,
};
};
export default { export default {
createPayment, createPayment,
getPayments, getPayments,
@@ -378,4 +402,5 @@ export default {
confirmStripePayment, confirmStripePayment,
createPayPalOrder, createPayPalOrder,
capturePayPalPayment, capturePayPalPayment,
cancelPayPalPayment,
}; };

View File

@@ -107,6 +107,7 @@ export interface CompanySettingsResponse {
company_phone: string; company_phone: string;
company_email: string; company_email: string;
company_address: string; company_address: string;
tax_rate: number;
updated_at?: string | null; updated_at?: string | null;
updated_by?: string | null; updated_by?: string | null;
}; };
@@ -119,6 +120,7 @@ export interface UpdateCompanySettingsRequest {
company_phone?: string; company_phone?: string;
company_email?: string; company_email?: string;
company_address?: string; company_address?: string;
tax_rate?: number;
} }
export interface UploadLogoResponse { export interface UploadLogoResponse {
@@ -139,6 +141,49 @@ export interface UploadFaviconResponse {
}; };
} }
export interface RecaptchaSettingsResponse {
status: string;
data: {
recaptcha_site_key: string;
recaptcha_enabled: boolean;
};
}
export interface RecaptchaSettingsAdminResponse {
status: string;
data: {
recaptcha_site_key: string;
recaptcha_secret_key: string;
recaptcha_secret_key_masked: string;
recaptcha_enabled: boolean;
has_site_key: boolean;
has_secret_key: boolean;
updated_at?: string | null;
updated_by?: string | null;
};
}
export interface UpdateRecaptchaSettingsRequest {
recaptcha_site_key?: string;
recaptcha_secret_key?: string;
recaptcha_enabled?: boolean;
}
export interface VerifyRecaptchaRequest {
token: string;
}
export interface VerifyRecaptchaResponse {
status: string;
data: {
verified: boolean;
score?: number;
action?: string;
error_codes?: string[];
message?: string;
};
}
const systemSettingsService = { const systemSettingsService = {
/** /**
* Get platform currency (public endpoint) * Get platform currency (public endpoint)
@@ -311,7 +356,56 @@ const systemSettingsService = {
}, },
}; };
const recaptchaService = {
/**
* Get reCAPTCHA settings (public endpoint)
*/
getRecaptchaSettings: async (): Promise<RecaptchaSettingsResponse> => {
const response = await apiClient.get<RecaptchaSettingsResponse>(
'/api/admin/system-settings/recaptcha'
);
return response.data;
},
/**
* Get reCAPTCHA settings (admin only)
*/
getRecaptchaSettingsAdmin: async (): Promise<RecaptchaSettingsAdminResponse> => {
const response = await apiClient.get<RecaptchaSettingsAdminResponse>(
'/api/admin/system-settings/recaptcha/admin'
);
return response.data;
},
/**
* Update reCAPTCHA settings (admin only)
*/
updateRecaptchaSettings: async (
settings: UpdateRecaptchaSettingsRequest
): Promise<RecaptchaSettingsAdminResponse> => {
const response = await apiClient.put<RecaptchaSettingsAdminResponse>(
'/api/admin/system-settings/recaptcha',
settings
);
return response.data;
},
/**
* Verify reCAPTCHA token
*/
verifyRecaptcha: async (
token: string
): Promise<VerifyRecaptchaResponse> => {
const response = await apiClient.post<VerifyRecaptchaResponse>(
'/api/admin/system-settings/recaptcha/verify',
{ token }
);
return response.data;
},
};
export default systemSettingsService; export default systemSettingsService;
export { recaptchaService };
export type { export type {
PlatformCurrencyResponse, PlatformCurrencyResponse,
@@ -328,5 +422,10 @@ export type {
UpdateCompanySettingsRequest, UpdateCompanySettingsRequest,
UploadLogoResponse, UploadLogoResponse,
UploadFaviconResponse, UploadFaviconResponse,
RecaptchaSettingsResponse,
RecaptchaSettingsAdminResponse,
UpdateRecaptchaSettingsRequest,
VerifyRecaptchaRequest,
VerifyRecaptchaResponse,
}; };