updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -20,6 +20,7 @@ stripe>=13.2.0
|
||||
paypal-checkout-serversdk>=1.0.3
|
||||
pyotp==2.9.0
|
||||
qrcode[pil]==7.4.2
|
||||
httpx==0.25.2
|
||||
|
||||
# Enterprise features (optional but recommended)
|
||||
# redis==5.0.1 # Uncomment if using Redis caching
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -24,6 +24,9 @@ class Booking(Base):
|
||||
check_out_date = Column(DateTime, nullable=False)
|
||||
num_guests = Column(Integer, nullable=False, default=1)
|
||||
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)
|
||||
deposit_paid = Column(Boolean, nullable=False, default=False)
|
||||
requires_deposit = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -37,6 +37,7 @@ class Invoice(Base):
|
||||
|
||||
# Status
|
||||
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_name = Column(String(200), nullable=True)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -26,6 +26,134 @@ from ..utils.email_templates import (
|
||||
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:
|
||||
"""Generate unique booking number"""
|
||||
prefix = "BK"
|
||||
@@ -34,6 +162,29 @@ def generate_booking_number() -> str:
|
||||
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("/")
|
||||
async def get_all_bookings(
|
||||
search: Optional[str] = Query(None),
|
||||
@@ -47,7 +198,11 @@ async def get_all_bookings(
|
||||
):
|
||||
"""Get all bookings (Admin/Staff only)"""
|
||||
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)
|
||||
if search:
|
||||
@@ -79,6 +234,23 @@ async def get_all_bookings(
|
||||
# Include related data
|
||||
result = []
|
||||
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 = {
|
||||
"id": booking.id,
|
||||
"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_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, # Frontend expects guest_count
|
||||
"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,
|
||||
"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, # Frontend expects notes
|
||||
"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
|
||||
@@ -111,6 +295,37 @@ async def get_all_bookings(
|
||||
"room_number": booking.room.room_number,
|
||||
"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)
|
||||
|
||||
@@ -127,6 +342,11 @@ async def get_all_bookings(
|
||||
},
|
||||
}
|
||||
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))
|
||||
|
||||
|
||||
@@ -138,13 +358,33 @@ async def get_my_bookings(
|
||||
):
|
||||
"""Get current user's bookings"""
|
||||
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
|
||||
).order_by(Booking.created_at.desc()).all()
|
||||
|
||||
base_url = get_base_url(request)
|
||||
result = []
|
||||
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 = {
|
||||
"id": booking.id,
|
||||
"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_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,
|
||||
"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 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)
|
||||
|
||||
return {
|
||||
@@ -219,6 +487,10 @@ async def create_booking(
|
||||
guest_count = booking_data.get("guest_count", 1)
|
||||
notes = booking_data.get("notes")
|
||||
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
|
||||
missing_fields = []
|
||||
@@ -284,6 +556,34 @@ async def create_booking(
|
||||
# Will be confirmed after successful payment
|
||||
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
|
||||
booking = Booking(
|
||||
booking_number=booking_number,
|
||||
@@ -293,7 +593,10 @@ async def create_booking(
|
||||
check_out_date=check_out,
|
||||
num_guests=guest_count,
|
||||
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,
|
||||
requires_deposit=requires_deposit,
|
||||
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}")
|
||||
|
||||
# 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
|
||||
# The payment will be created when the customer pays at check-in
|
||||
# For cash payments, create a pending deposit payment record that can be paid via PayPal or Stripe
|
||||
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
|
||||
services = booking_data.get("services", [])
|
||||
@@ -368,9 +684,10 @@ async def create_booking(
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Automatically create invoice for the booking
|
||||
# Automatically create invoice(s) for the booking
|
||||
try:
|
||||
from ..services.invoice_service import InvoiceService
|
||||
from ..utils.mailer import send_email
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
# Reload booking with service_usages for invoice creation
|
||||
@@ -378,15 +695,113 @@ async def create_booking(
|
||||
selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
|
||||
).filter(Booking.id == booking.id).first()
|
||||
|
||||
# Create invoice automatically
|
||||
invoice = InvoiceService.create_invoice_from_booking(
|
||||
# Get company settings for invoice
|
||||
from ..models.system_settings import SystemSettings
|
||||
company_settings = {}
|
||||
for key in ["company_name", "company_address", "company_phone", "company_email", "company_tax_id", "company_logo_url"]:
|
||||
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||
if setting and setting.value:
|
||||
company_settings[key] = setting.value
|
||||
|
||||
# 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=0.0, # Default no tax, can be configured
|
||||
discount_amount=0.0,
|
||||
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:
|
||||
# Log error but don't fail booking creation if invoice creation fails
|
||||
import logging
|
||||
@@ -511,32 +926,7 @@ async def create_booking(
|
||||
"capacity": booking.room.room_type.capacity,
|
||||
}
|
||||
|
||||
# Send booking confirmation email (non-blocking)
|
||||
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}")
|
||||
# Don't send email here - emails will be sent when booking is confirmed or cancelled
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -678,7 +1068,11 @@ async def get_booking_by_id(
|
||||
"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
|
||||
]
|
||||
@@ -757,7 +1151,12 @@ async def cancel_booking(
|
||||
|
||||
# Send cancellation email (non-blocking)
|
||||
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(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
@@ -770,7 +1169,9 @@ async def cancel_booking(
|
||||
html=email_html
|
||||
)
|
||||
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 {
|
||||
"success": True,
|
||||
@@ -792,7 +1193,10 @@ async def update_booking(
|
||||
):
|
||||
"""Update booking status (Admin only)"""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
@@ -807,29 +1211,105 @@ async def update_booking(
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Send status change email if status changed (non-blocking)
|
||||
# Check payment balance if status changed to checked_in
|
||||
payment_warning = None
|
||||
if status_value and old_status != booking.status and booking.status == BookingStatus.checked_in:
|
||||
payment_balance = calculate_booking_payment_balance(booking)
|
||||
if payment_balance["remaining_balance"] > 0.01: # More than 1 cent remaining
|
||||
payment_warning = {
|
||||
"message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}",
|
||||
"total_paid": payment_balance["total_paid"],
|
||||
"total_price": payment_balance["total_price"],
|
||||
"remaining_balance": payment_balance["remaining_balance"],
|
||||
"payment_percentage": payment_balance["payment_percentage"]
|
||||
}
|
||||
|
||||
# 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:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
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=booking.status.value,
|
||||
status="cancelled",
|
||||
client_url=client_url
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email if booking.user else None,
|
||||
subject=f"Booking Status Updated - {booking.booking_number}",
|
||||
subject=f"Booking Cancelled - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send status change email: {e}")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send status change email: {e}")
|
||||
|
||||
return {
|
||||
response_data = {
|
||||
"status": "success",
|
||||
"message": "Booking updated successfully",
|
||||
"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:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -844,30 +1324,126 @@ async def check_booking_by_number(
|
||||
):
|
||||
"""Check booking by booking number"""
|
||||
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:
|
||||
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 = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_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_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,
|
||||
"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:
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"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",
|
||||
"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:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -25,7 +25,15 @@ class ContactForm(BaseModel):
|
||||
|
||||
def get_admin_email(db: Session) -> str:
|
||||
"""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(
|
||||
SystemSettings.key == "admin_email"
|
||||
).first()
|
||||
@@ -52,7 +60,7 @@ def get_admin_email(db: Session) -> str:
|
||||
# Last resort: raise error
|
||||
raise HTTPException(
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 datetime import datetime
|
||||
import os
|
||||
@@ -11,13 +11,51 @@ from ..models.user import User
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
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.paypal_service import PayPalService
|
||||
|
||||
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("/")
|
||||
async def get_payments(
|
||||
booking_id: Optional[int] = Query(None),
|
||||
@@ -29,11 +67,11 @@ async def get_payments(
|
||||
):
|
||||
"""Get all payments"""
|
||||
try:
|
||||
query = db.query(Payment)
|
||||
|
||||
# Filter by booking_id
|
||||
# Build base query
|
||||
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
|
||||
if status_filter:
|
||||
@@ -46,7 +84,14 @@ async def get_payments(
|
||||
if current_user.role_id != 1: # Not admin
|
||||
query = query.join(Booking).filter(Booking.user_id == current_user.id)
|
||||
|
||||
# Get total count before applying eager loading
|
||||
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
|
||||
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,
|
||||
"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)
|
||||
|
||||
@@ -87,8 +140,13 @@ async def get_payments(
|
||||
},
|
||||
},
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
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}")
|
||||
@@ -108,8 +166,10 @@ async def get_payments_by_booking_id(
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Get all payments for this booking
|
||||
payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
|
||||
# Get all payments for this booking with user relationship
|
||||
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 = []
|
||||
for payment in payments:
|
||||
@@ -133,6 +193,14 @@ async def get_payments_by_booking_id(
|
||||
"id": payment.booking.id,
|
||||
"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)
|
||||
|
||||
@@ -241,14 +309,34 @@ async def create_payment(
|
||||
# Send payment confirmation email if payment was marked as paid (non-blocking)
|
||||
if payment.payment_status == PaymentStatus.completed and booking.user:
|
||||
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(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name,
|
||||
amount=float(payment.amount),
|
||||
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
||||
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(
|
||||
to=booking.user.email,
|
||||
@@ -256,7 +344,9 @@ async def create_payment(
|
||||
html=email_html
|
||||
)
|
||||
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 {
|
||||
"status": "success",
|
||||
@@ -284,16 +374,28 @@ async def update_payment_status(
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
|
||||
status_value = status_data.get("status")
|
||||
old_status = payment.payment_status
|
||||
|
||||
if status_value:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Invalid payment status")
|
||||
|
||||
if status_data.get("transaction_id"):
|
||||
payment.transaction_id = status_data["transaction_id"]
|
||||
|
||||
old_status = payment.payment_status
|
||||
if status_data.get("mark_as_paid"):
|
||||
payment.payment_status = PaymentStatus.completed
|
||||
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)
|
||||
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
|
||||
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
|
||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||
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(
|
||||
booking_number=payment.booking.booking_number,
|
||||
guest_name=payment.booking.user.full_name,
|
||||
amount=float(payment.amount),
|
||||
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
||||
transaction_id=payment.transaction_id,
|
||||
client_url=client_url
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
await send_email(
|
||||
to=payment.booking.user.email,
|
||||
@@ -325,8 +460,20 @@ async def update_payment_status(
|
||||
# If this is a deposit payment, update booking deposit_paid status
|
||||
if payment.payment_type == PaymentType.deposit and payment.booking:
|
||||
payment.booking.deposit_paid = True
|
||||
# Optionally auto-confirm booking if deposit is paid
|
||||
if payment.booking.status == BookingStatus.pending:
|
||||
# Restore cancelled bookings or confirm pending bookings when deposit is paid
|
||||
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||
payment.booking.status = BookingStatus.confirmed
|
||||
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:
|
||||
@@ -395,6 +542,29 @@ async def create_stripe_payment_intent(
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
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
|
||||
intent = StripeService.create_payment_intent(
|
||||
amount=amount,
|
||||
@@ -472,7 +642,7 @@ async def confirm_stripe_payment(
|
||||
)
|
||||
|
||||
# Confirm payment (this commits the transaction internally)
|
||||
payment = StripeService.confirm_payment(
|
||||
payment = await StripeService.confirm_payment(
|
||||
payment_intent_id=payment_intent_id,
|
||||
db=db,
|
||||
booking_id=booking_id
|
||||
@@ -495,14 +665,34 @@ async def confirm_stripe_payment(
|
||||
# This won't affect the transaction since it's already committed
|
||||
if booking and booking.user:
|
||||
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(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name,
|
||||
amount=payment["amount"],
|
||||
payment_method="stripe",
|
||||
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(
|
||||
to=booking.user.email,
|
||||
@@ -574,7 +764,7 @@ async def stripe_webhook(
|
||||
detail="Missing stripe-signature header"
|
||||
)
|
||||
|
||||
result = StripeService.handle_webhook(
|
||||
result = await StripeService.handle_webhook(
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
db=db
|
||||
@@ -640,6 +830,31 @@ async def create_paypal_order(
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
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
|
||||
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")
|
||||
@@ -689,6 +904,63 @@ async def create_paypal_order(
|
||||
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")
|
||||
async def capture_paypal_payment(
|
||||
payment_data: dict,
|
||||
@@ -707,7 +979,7 @@ async def capture_paypal_payment(
|
||||
)
|
||||
|
||||
# Confirm payment (this commits the transaction internally)
|
||||
payment = PayPalService.confirm_payment(
|
||||
payment = await PayPalService.confirm_payment(
|
||||
order_id=order_id,
|
||||
db=db,
|
||||
booking_id=booking_id
|
||||
@@ -727,14 +999,34 @@ async def capture_paypal_payment(
|
||||
# Send payment confirmation email (non-blocking)
|
||||
if booking and booking.user:
|
||||
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(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name,
|
||||
amount=payment["amount"],
|
||||
payment_method="paypal",
|
||||
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(
|
||||
to=booking.user.email,
|
||||
|
||||
@@ -128,7 +128,8 @@ async def validate_promotion(
|
||||
"""Validate and apply promotion"""
|
||||
try:
|
||||
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()
|
||||
if not promotion:
|
||||
@@ -161,17 +162,30 @@ async def validate_promotion(
|
||||
final_amount = booking_amount - discount_amount
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"data": {
|
||||
"promotion": {
|
||||
"id": promotion.id,
|
||||
"code": promotion.code,
|
||||
"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,
|
||||
"discount_amount": discount_amount,
|
||||
"final_amount": final_amount,
|
||||
}
|
||||
},
|
||||
"message": "Promotion validated successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -895,6 +895,7 @@ class UpdateCompanySettingsRequest(BaseModel):
|
||||
company_phone: Optional[str] = None
|
||||
company_email: Optional[str] = None
|
||||
company_address: Optional[str] = None
|
||||
tax_rate: Optional[float] = None
|
||||
|
||||
|
||||
@router.get("/company")
|
||||
@@ -911,6 +912,7 @@ async def get_company_settings(
|
||||
"company_phone",
|
||||
"company_email",
|
||||
"company_address",
|
||||
"tax_rate",
|
||||
]
|
||||
|
||||
settings_dict = {}
|
||||
@@ -944,6 +946,7 @@ async def get_company_settings(
|
||||
"company_phone": settings_dict.get("company_phone", ""),
|
||||
"company_email": settings_dict.get("company_email", ""),
|
||||
"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_by": updated_by,
|
||||
}
|
||||
@@ -972,6 +975,8 @@ async def update_company_settings(
|
||||
db_settings["company_email"] = request_data.company_email
|
||||
if request_data.company_address is not None:
|
||||
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():
|
||||
# Find or create setting
|
||||
@@ -997,7 +1002,7 @@ async def update_company_settings(
|
||||
|
||||
# Get 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(
|
||||
SystemSettings.key == key
|
||||
).first()
|
||||
@@ -1032,6 +1037,7 @@ async def update_company_settings(
|
||||
"company_phone": updated_settings.get("company_phone", ""),
|
||||
"company_email": updated_settings.get("company_email", ""),
|
||||
"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_by": updated_by,
|
||||
}
|
||||
@@ -1243,3 +1249,272 @@ async def upload_company_favicon(
|
||||
logger.error(f"Error uploading favicon: {e}", exc_info=True)
|
||||
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))
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,14 +11,15 @@ from ..models.payment import Payment, PaymentStatus
|
||||
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"""
|
||||
# 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")
|
||||
|
||||
# Get the last invoice number for today
|
||||
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()
|
||||
|
||||
if last_invoice:
|
||||
@@ -31,7 +32,7 @@ def generate_invoice_number(db: Session) -> str:
|
||||
else:
|
||||
sequence = 1
|
||||
|
||||
return f"INV-{today}-{sequence:04d}"
|
||||
return f"{prefix}-{today}-{sequence:04d}"
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
@@ -45,6 +46,8 @@ class InvoiceService:
|
||||
tax_rate: float = 0.0,
|
||||
discount_amount: float = 0.0,
|
||||
due_days: int = 30,
|
||||
is_proforma: bool = False,
|
||||
invoice_amount: Optional[float] = None, # For partial invoices (e.g., deposit)
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -77,10 +80,16 @@ class InvoiceService:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# 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
|
||||
# Initial subtotal is booking total (room + services)
|
||||
# Initial subtotal is booking total (room + services) or invoice_amount if specified
|
||||
if invoice_amount is not None:
|
||||
subtotal = float(invoice_amount)
|
||||
else:
|
||||
subtotal = float(booking.total_price)
|
||||
|
||||
# Calculate tax and total amounts
|
||||
@@ -121,6 +130,7 @@ class InvoiceService:
|
||||
amount_paid=amount_paid,
|
||||
balance_due=balance_due,
|
||||
status=status,
|
||||
is_proforma=is_proforma,
|
||||
company_name=kwargs.get("company_name"),
|
||||
company_address=kwargs.get("company_address"),
|
||||
company_phone=kwargs.get("company_phone"),
|
||||
@@ -146,17 +156,28 @@ class InvoiceService:
|
||||
services_total = sum(
|
||||
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
|
||||
nights = (booking.check_out_date - booking.check_in_date).days
|
||||
if nights <= 0:
|
||||
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 = InvoiceItem(
|
||||
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,
|
||||
unit_price=room_price / nights if nights > 0 else room_price,
|
||||
tax_rate=tax_rate,
|
||||
@@ -168,14 +189,20 @@ class InvoiceService:
|
||||
|
||||
# Add service items if any
|
||||
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(
|
||||
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),
|
||||
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,
|
||||
discount_amount=0.0,
|
||||
line_total=float(service_usage.total_price),
|
||||
line_total=service_item_price,
|
||||
service_id=service_usage.service_id,
|
||||
)
|
||||
db.add(service_item)
|
||||
@@ -391,6 +418,7 @@ class InvoiceService:
|
||||
"notes": invoice.notes,
|
||||
"terms_and_conditions": invoice.terms_and_conditions,
|
||||
"payment_instructions": invoice.payment_instructions,
|
||||
"is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
PayPal payment service for processing PayPal payments
|
||||
"""
|
||||
import logging
|
||||
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
|
||||
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
|
||||
from paypalcheckoutsdk.payments import CapturesRefundRequest
|
||||
@@ -13,6 +14,8 @@ from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_paypal_client_id(db: Session) -> Optional[str]:
|
||||
"""Get PayPal client ID from database or environment variable"""
|
||||
@@ -282,7 +285,7 @@ class PayPalService:
|
||||
raise ValueError(f"PayPal error: {error_msg}")
|
||||
|
||||
@staticmethod
|
||||
def confirm_payment(
|
||||
async def confirm_payment(
|
||||
order_id: str,
|
||||
db: Session,
|
||||
booking_id: Optional[int] = None
|
||||
@@ -337,6 +340,15 @@ class PayPalService:
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).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"]
|
||||
capture_id = capture_data.get("capture_id")
|
||||
|
||||
@@ -347,6 +359,7 @@ class PayPalService:
|
||||
payment.payment_date = datetime.utcnow()
|
||||
# If pending, keep as pending
|
||||
payment.amount = amount
|
||||
payment.payment_method = PaymentMethod.paypal # Update payment method to PayPal
|
||||
if capture_id:
|
||||
payment.transaction_id = f"{order_id}|{capture_id}"
|
||||
else:
|
||||
@@ -380,18 +393,142 @@ class PayPalService:
|
||||
if payment.payment_status == PaymentStatus.completed:
|
||||
db.refresh(booking)
|
||||
|
||||
if payment.payment_type == PaymentType.deposit:
|
||||
booking.deposit_paid = True
|
||||
if booking.status == BookingStatus.pending:
|
||||
booking.status = BookingStatus.confirmed
|
||||
elif payment.payment_type == PaymentType.full:
|
||||
# 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
|
||||
)
|
||||
|
||||
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
||||
# 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:
|
||||
booking.deposit_paid = True
|
||||
# Restore cancelled bookings or confirm pending bookings
|
||||
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 deposit was just paid
|
||||
should_send_email = True
|
||||
elif payment.payment_type == PaymentType.full:
|
||||
# Confirm booking and restore cancelled bookings when payment succeeds
|
||||
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
||||
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.refresh(booking)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Stripe payment service for processing card payments
|
||||
"""
|
||||
import logging
|
||||
import stripe
|
||||
from typing import Optional, Dict, Any
|
||||
from ..config.settings import settings
|
||||
@@ -10,6 +11,8 @@ from ..models.system_settings import SystemSettings
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_stripe_secret_key(db: Session) -> Optional[str]:
|
||||
"""Get Stripe secret key from database or environment variable"""
|
||||
@@ -183,7 +186,7 @@ class StripeService:
|
||||
raise ValueError(f"Stripe error: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def confirm_payment(
|
||||
async def confirm_payment(
|
||||
payment_intent_id: str,
|
||||
db: Session,
|
||||
booking_id: Optional[int] = None
|
||||
@@ -230,6 +233,15 @@ class StripeService:
|
||||
Payment.payment_method == PaymentMethod.stripe
|
||||
).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"]
|
||||
|
||||
if payment:
|
||||
@@ -240,6 +252,7 @@ class StripeService:
|
||||
payment.payment_date = datetime.utcnow()
|
||||
# If processing, keep as pending (will be updated by webhook)
|
||||
payment.amount = amount
|
||||
payment.payment_method = PaymentMethod.stripe # Update payment method to Stripe
|
||||
else:
|
||||
# Create new payment record
|
||||
payment_type = PaymentType.full
|
||||
@@ -271,25 +284,148 @@ class StripeService:
|
||||
# Refresh booking to get updated payments relationship
|
||||
db.refresh(booking)
|
||||
|
||||
if payment.payment_type == PaymentType.deposit:
|
||||
# Mark deposit as paid and confirm booking
|
||||
booking.deposit_paid = True
|
||||
if booking.status == BookingStatus.pending:
|
||||
booking.status = BookingStatus.confirmed
|
||||
elif payment.payment_type == PaymentType.full:
|
||||
# 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:
|
||||
# Mark deposit as paid and confirm booking
|
||||
booking.deposit_paid = True
|
||||
# Restore cancelled bookings or confirm pending bookings
|
||||
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 deposit was just paid
|
||||
should_send_email = True
|
||||
elif payment.payment_type == PaymentType.full:
|
||||
# Confirm booking if:
|
||||
# 1. Total paid (all payments) covers the booking price, OR
|
||||
# 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 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.refresh(booking)
|
||||
|
||||
@@ -335,7 +471,7 @@ class StripeService:
|
||||
raise ValueError(f"Error confirming payment: {error_msg}")
|
||||
|
||||
@staticmethod
|
||||
def handle_webhook(
|
||||
async def handle_webhook(
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
db: Session
|
||||
@@ -376,13 +512,15 @@ class StripeService:
|
||||
|
||||
if booking_id:
|
||||
try:
|
||||
StripeService.confirm_payment(
|
||||
await StripeService.confirm_payment(
|
||||
payment_intent_id=payment_intent_id,
|
||||
db=db,
|
||||
booking_id=int(booking_id)
|
||||
)
|
||||
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":
|
||||
payment_intent = event["data"]["object"]
|
||||
@@ -401,6 +539,42 @@ class StripeService:
|
||||
payment.payment_status = PaymentStatus.failed
|
||||
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 {
|
||||
"status": "success",
|
||||
"event_type": event["type"],
|
||||
|
||||
Binary file not shown.
@@ -260,19 +260,66 @@ def booking_confirmation_email_template(
|
||||
total_price: float,
|
||||
requires_deposit: bool,
|
||||
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:
|
||||
"""Booking confirmation email template"""
|
||||
deposit_info = ""
|
||||
if requires_deposit and deposit_amount:
|
||||
if requires_deposit and deposit_amount and amount_paid is None:
|
||||
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);">
|
||||
<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>
|
||||
</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"""
|
||||
<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;">
|
||||
@@ -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: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
|
||||
</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;">
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{payment_breakdown}
|
||||
|
||||
{deposit_info}
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
@@ -334,7 +393,10 @@ def payment_confirmation_email_template(
|
||||
amount: float,
|
||||
payment_method: str,
|
||||
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:
|
||||
"""Payment confirmation email template"""
|
||||
transaction_info = ""
|
||||
@@ -346,6 +408,34 @@ def payment_confirmation_email_template(
|
||||
</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"""
|
||||
<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;">
|
||||
@@ -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>
|
||||
</tr>
|
||||
{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;">
|
||||
<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>
|
||||
{remaining_due_info}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
46
Frontend/package-lock.json
generated
46
Frontend/package-lock.json
generated
@@ -13,12 +13,14 @@
|
||||
"@stripe/react-stripe-js": "^2.9.0",
|
||||
"@stripe/stripe-js": "^2.4.0",
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^8.9.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
@@ -1610,6 +1612,15 @@
|
||||
"@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": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
@@ -3179,6 +3190,15 @@
|
||||
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4124,6 +4144,19 @@
|
||||
"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": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz",
|
||||
@@ -4187,6 +4220,19 @@
|
||||
"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": {
|
||||
"version": "7.65.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
|
||||
|
||||
@@ -11,19 +11,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"@stripe/react-stripe-js": "^2.9.0",
|
||||
"@stripe/stripe-js": "^2.4.0",
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^8.9.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"yup": "^1.3.3",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
|
||||
@@ -216,7 +216,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="deposit-payment/:bookingId"
|
||||
path="payment/deposit/:bookingId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DepositPaymentPage />
|
||||
|
||||
91
Frontend/src/components/common/Recaptcha.tsx
Normal file
91
Frontend/src/components/common/Recaptcha.tsx
Normal 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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPayPalOrder } from '../../services/api/paymentService';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
|
||||
interface PayPalPaymentWrapperProps {
|
||||
bookingId: number;
|
||||
@@ -12,9 +13,12 @@ interface PayPalPaymentWrapperProps {
|
||||
const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
bookingId,
|
||||
amount,
|
||||
currency = 'USD',
|
||||
currency: propCurrency,
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
|
||||
@@ -75,22 +79,29 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
|
||||
<span className="ml-2 text-gray-600">Initializing PayPal payment...</span>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||
border border-red-500/30 rounded-xl p-6 backdrop-blur-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="w-6 h-6 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<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
|
||||
</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.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -102,18 +113,23 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
if (!approvalUrl) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
|
||||
<span className="ml-2 text-gray-600">Loading PayPal...</span>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="mb-6">
|
||||
<svg
|
||||
className="mx-auto h-12 w-auto"
|
||||
className="mx-auto h-14 w-auto"
|
||||
viewBox="0 0 283 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -124,12 +140,12 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-serif font-semibold text-[#d4af37] mb-3 tracking-wide">
|
||||
Complete Payment with PayPal
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
<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">
|
||||
<span className="font-semibold text-[#d4af37]">
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
@@ -138,7 +154,11 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
</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"
|
||||
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"
|
||||
@@ -149,11 +169,10 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
</svg>
|
||||
Pay with PayPal
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
<p className="text-xs text-gray-400/70 mt-6 font-light tracking-wide">
|
||||
Secure payment powered by PayPal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -71,15 +71,15 @@ const RatingStars: React.FC<RatingStarsProps> = ({
|
||||
<Star
|
||||
className={`${sizeClasses[size]} ${
|
||||
isFilled
|
||||
? 'text-yellow-500 fill-yellow-500'
|
||||
: 'text-gray-300'
|
||||
? 'text-[#d4af37] fill-[#d4af37]'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{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)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type Review,
|
||||
} from '../../services/api/reviewService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import Recaptcha from '../common/Recaptcha';
|
||||
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||
|
||||
interface ReviewSectionProps {
|
||||
roomId: number;
|
||||
@@ -42,6 +44,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [averageRating, setAverageRating] = useState<number>(0);
|
||||
const [totalReviews, setTotalReviews] = useState<number>(0);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -87,6 +90,22 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const response = await createReview({
|
||||
@@ -101,12 +120,14 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
);
|
||||
reset();
|
||||
fetchReviews();
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ||
|
||||
'Unable to submit review';
|
||||
toast.error(message);
|
||||
setRecaptchaToken(null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -121,24 +142,26 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
{/* Rating Summary */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
<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-sm sm:text-base font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
Customer Reviews
|
||||
</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<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.toFixed(1)
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<RatingStars
|
||||
rating={averageRating}
|
||||
size="md"
|
||||
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' : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,29 +170,29 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
|
||||
{/* Review Form */}
|
||||
{isAuthenticated ? (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h4 className="text-xl font-semibold mb-4">
|
||||
<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-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
Write Your Review
|
||||
</h4>
|
||||
<form onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
<label className="block text-[10px] sm:text-xs font-light
|
||||
text-gray-300 mb-1.5 tracking-wide"
|
||||
>
|
||||
Your Rating
|
||||
</label>
|
||||
<RatingStars
|
||||
rating={rating}
|
||||
size="lg"
|
||||
size="sm"
|
||||
interactive
|
||||
onRatingChange={(value) =>
|
||||
setValue('rating', value)
|
||||
}
|
||||
/>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -178,51 +201,66 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
<div>
|
||||
<label
|
||||
htmlFor="comment"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
className="block text-[10px] sm:text-xs font-light
|
||||
text-gray-300 mb-1.5 tracking-wide"
|
||||
>
|
||||
Comment
|
||||
</label>
|
||||
<textarea
|
||||
{...register('comment')}
|
||||
id="comment"
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border
|
||||
border-gray-300 rounded-lg
|
||||
focus:ring-2 focus:ring-blue-500
|
||||
focus:border-transparent"
|
||||
rows={3}
|
||||
className="w-full px-2.5 py-1.5 bg-[#0a0a0a] border
|
||||
border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 text-xs sm:text-sm
|
||||
focus:ring-2 focus:ring-[#d4af37]/50
|
||||
focus:border-[#d4af37] transition-all duration-300
|
||||
font-light tracking-wide resize-none"
|
||||
placeholder="Share your experience..."
|
||||
/>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
</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
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-6 py-3 bg-blue-600 text-white
|
||||
rounded-lg hover:bg-blue-700
|
||||
disabled:bg-gray-400
|
||||
className="px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] rounded-sm hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
disabled:bg-gray-800 disabled:text-gray-500
|
||||
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'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 text-center"
|
||||
<div className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 border border-[#d4af37]/30
|
||||
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{' '}
|
||||
<a
|
||||
href="/login"
|
||||
className="font-semibold underline
|
||||
hover:text-blue-900"
|
||||
hover:text-[#f5d76e] transition-colors"
|
||||
>
|
||||
login
|
||||
</a>{' '}
|
||||
@@ -233,71 +271,70 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
|
||||
{/* Reviews List */}
|
||||
<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})
|
||||
</h4>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2.5">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div className="h-4 bg-gray-300
|
||||
<div className="h-3 bg-gray-700
|
||||
rounded w-1/4 mb-2"
|
||||
/>
|
||||
<div className="h-4 bg-gray-300
|
||||
<div className="h-3 bg-gray-700
|
||||
rounded w-3/4"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50
|
||||
rounded-lg"
|
||||
<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"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
<p className="text-gray-300 text-sm sm:text-base font-light">
|
||||
No reviews yet
|
||||
</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!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2.5 sm:space-y-3">
|
||||
{reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20
|
||||
p-3 sm:p-4 backdrop-blur-xl shadow-sm shadow-[#d4af37]/5"
|
||||
>
|
||||
<div className="flex items-start
|
||||
justify-between mb-3"
|
||||
justify-between mb-2"
|
||||
>
|
||||
<div>
|
||||
<h5 className="font-semibold
|
||||
text-gray-900"
|
||||
text-white text-xs sm:text-sm"
|
||||
>
|
||||
{review.user?.full_name || 'Guest'}
|
||||
</h5>
|
||||
<div className="flex items-center
|
||||
gap-2 mt-1"
|
||||
gap-1.5 mt-1"
|
||||
>
|
||||
<RatingStars
|
||||
rating={review.rating}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm
|
||||
text-gray-500"
|
||||
<span className="text-[10px] sm:text-xs
|
||||
text-gray-400 font-light"
|
||||
>
|
||||
{formatDate(review.created_at)}
|
||||
</span>
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { pageContentService } from '../services/api';
|
||||
import type { PageContent } from '../services/api/pageContentService';
|
||||
import { useCompanySettings } from '../contexts/CompanySettingsContext';
|
||||
import { toast } from 'react-toastify';
|
||||
import Recaptcha from '../components/common/Recaptcha';
|
||||
import { recaptchaService } from '../services/api/systemSettingsService';
|
||||
|
||||
const ContactPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -18,6 +20,7 @@ const ContactPage: React.FC = () => {
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
@@ -53,6 +56,22 @@ const ContactPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await submitContactForm(formData);
|
||||
@@ -67,9 +86,11 @@ const ContactPage: React.FC = () => {
|
||||
message: '',
|
||||
});
|
||||
setErrors({});
|
||||
setRecaptchaToken(null);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
|
||||
toast.error(errorMessage);
|
||||
setRecaptchaToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -400,6 +421,20 @@ const ContactPage: React.FC = () => {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="pt-2 sm:pt-3 md:pt-4">
|
||||
<button
|
||||
|
||||
@@ -214,9 +214,37 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{(() => {
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = booking.total_price - amountPaid;
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
return (
|
||||
<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 className="px-8 py-5 whitespace-nowrap">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Total Price - Highlighted */}
|
||||
<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">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Total Price</label>
|
||||
<p className="text-4xl font-bold bg-gradient-to-r from-amber-600 via-amber-700 to-amber-600 bg-clip-text text-transparent">
|
||||
{/* Payment Method & Status */}
|
||||
<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-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Notes */}
|
||||
|
||||
@@ -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">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">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">Payment Date</th>
|
||||
</tr>
|
||||
@@ -756,11 +757,28 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
<div className="text-sm font-semibold text-emerald-600">{payment.booking?.booking_number}</div>
|
||||
</td>
|
||||
<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 className="px-8 py-5 whitespace-nowrap">
|
||||
{getPaymentMethodBadge(payment.payment_method)}
|
||||
</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">
|
||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(payment.amount)}
|
||||
|
||||
@@ -35,7 +35,17 @@ const CheckInPage: React.FC = () => {
|
||||
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
||||
setBooking(response.data.booking);
|
||||
setActualRoomNumber(response.data.booking.room?.room_number || '');
|
||||
|
||||
// Show warning if there's remaining balance
|
||||
if ((response as any).warning) {
|
||||
const warning = (response as any).warning;
|
||||
toast.warning(
|
||||
`⚠️ Payment Reminder: Guest has remaining balance of ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
|
||||
{ autoClose: 8000 }
|
||||
);
|
||||
} else {
|
||||
toast.success('Booking found');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Booking not found');
|
||||
setBooking(null);
|
||||
@@ -89,12 +99,21 @@ const CheckInPage: React.FC = () => {
|
||||
// Calculate additional fee
|
||||
calculateAdditionalFee();
|
||||
|
||||
await bookingService.updateBooking(booking.id, {
|
||||
const response = await bookingService.updateBooking(booking.id, {
|
||||
status: 'checked_in',
|
||||
// Can send additional data about guests, room_number, additional_fee
|
||||
} as any);
|
||||
|
||||
// Show warning if there's remaining balance
|
||||
if ((response as any).warning) {
|
||||
const warning = (response as any).warning;
|
||||
toast.warning(
|
||||
`⚠️ Check-in successful, but guest has remaining balance: ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
|
||||
{ autoClose: 10000 }
|
||||
);
|
||||
} else {
|
||||
toast.success('Check-in successful');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setBooking(null);
|
||||
@@ -201,6 +220,150 @@ const CheckInPage: React.FC = () => {
|
||||
</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' && (
|
||||
<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" />
|
||||
|
||||
@@ -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">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">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">Payment Date</th>
|
||||
</tr>
|
||||
@@ -181,6 +182,21 @@ const PaymentManagementPage: React.FC = () => {
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getMethodBadge(payment.payment_method)}
|
||||
</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">
|
||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(payment.amount)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -189,6 +189,14 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate additional fee when extraPersons or children change
|
||||
useEffect(() => {
|
||||
const extraPersonFee = extraPersons * 200000;
|
||||
const childrenFee = children * 100000;
|
||||
const total = extraPersonFee + childrenFee;
|
||||
setAdditionalFee(total);
|
||||
}, [extraPersons, children]);
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (!checkInBooking) return;
|
||||
|
||||
@@ -272,8 +280,11 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const calculateDeposit = () => {
|
||||
return checkOutBooking?.total_price ? checkOutBooking.total_price * 0.3 : 0;
|
||||
const calculateTotalPaid = () => {
|
||||
if (!checkOutBooking?.payments) return 0;
|
||||
return checkOutBooking.payments
|
||||
.filter(payment => payment.payment_status === 'completed')
|
||||
.reduce((sum, payment) => sum + (payment.amount || 0), 0);
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
@@ -285,7 +296,9 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const calculateRemaining = () => {
|
||||
return calculateTotal() - calculateDeposit();
|
||||
const total = calculateTotal();
|
||||
const totalPaid = calculateTotalPaid();
|
||||
return total - totalPaid;
|
||||
};
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
@@ -326,17 +339,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Bookings Management Functions
|
||||
useEffect(() => {
|
||||
setBookingCurrentPage(1);
|
||||
}, [bookingFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'bookings') {
|
||||
fetchBookings();
|
||||
}
|
||||
}, [bookingFilters, bookingCurrentPage, activeTab]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
const fetchBookings = useCallback(async () => {
|
||||
try {
|
||||
setBookingsLoading(true);
|
||||
const response = await bookingService.getAllBookings({
|
||||
@@ -354,7 +357,17 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
} finally {
|
||||
setBookingsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [bookingFilters.search, bookingFilters.status, bookingCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setBookingCurrentPage(1);
|
||||
}, [bookingFilters.search, bookingFilters.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'bookings') {
|
||||
fetchBookings();
|
||||
}
|
||||
}, [activeTab, fetchBookings]);
|
||||
|
||||
const handleUpdateBookingStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
@@ -426,19 +439,63 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Rooms Management Functions
|
||||
const fetchAvailableAmenities = useCallback(async () => {
|
||||
try {
|
||||
const response = await roomService.getAmenities();
|
||||
if (response.data?.amenities) {
|
||||
setAvailableAmenities(response.data.amenities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRooms = useCallback(async () => {
|
||||
try {
|
||||
setRoomsLoading(true);
|
||||
const response = await roomService.getRooms({
|
||||
...roomFilters,
|
||||
page: roomCurrentPage,
|
||||
limit: roomItemsPerPage,
|
||||
});
|
||||
setRooms(response.data.rooms);
|
||||
if (response.data.pagination) {
|
||||
setRoomTotalPages(response.data.pagination.totalPages);
|
||||
setRoomTotalItems(response.data.pagination.total);
|
||||
}
|
||||
|
||||
const uniqueRoomTypes = new Map<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(() => {
|
||||
setRoomCurrentPage(1);
|
||||
setSelectedRooms([]);
|
||||
}, [roomFilters]);
|
||||
}, [roomFilters.search, roomFilters.status, roomFilters.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'rooms') {
|
||||
fetchRooms();
|
||||
fetchAvailableAmenities();
|
||||
}
|
||||
}, [roomFilters, roomCurrentPage, activeTab]);
|
||||
}, [activeTab, fetchRooms, fetchAvailableAmenities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'rooms') return;
|
||||
|
||||
const fetchAllRoomTypes = async () => {
|
||||
try {
|
||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
@@ -474,60 +531,21 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
const roomTypesList = Array.from(allUniqueRoomTypes.values());
|
||||
setRoomTypes(roomTypesList);
|
||||
if (!editingRoom && roomFormData.room_type_id === 1 && roomTypesList.length > 0) {
|
||||
setRoomFormData(prev => ({ ...prev, room_type_id: roomTypesList[0].id }));
|
||||
setRoomFormData(prev => {
|
||||
if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
|
||||
return { ...prev, room_type_id: roomTypesList[0].id };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
}
|
||||
};
|
||||
if (activeTab === 'rooms') {
|
||||
|
||||
fetchAllRoomTypes();
|
||||
}
|
||||
}, [activeTab]);
|
||||
}, [activeTab, editingRoom]);
|
||||
|
||||
const fetchAvailableAmenities = async () => {
|
||||
try {
|
||||
const response = await roomService.getAmenities();
|
||||
if (response.data?.amenities) {
|
||||
setAvailableAmenities(response.data.amenities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
setRoomsLoading(true);
|
||||
const response = await roomService.getRooms({
|
||||
...roomFilters,
|
||||
page: roomCurrentPage,
|
||||
limit: roomItemsPerPage,
|
||||
});
|
||||
setRooms(response.data.rooms);
|
||||
if (response.data.pagination) {
|
||||
setRoomTotalPages(response.data.pagination.totalPages);
|
||||
setRoomTotalItems(response.data.pagination.total);
|
||||
}
|
||||
|
||||
const uniqueRoomTypes = new Map<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) => {
|
||||
e.preventDefault();
|
||||
@@ -866,17 +884,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Services Management Functions
|
||||
useEffect(() => {
|
||||
setServiceCurrentPage(1);
|
||||
}, [serviceFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'services') {
|
||||
fetchServices();
|
||||
}
|
||||
}, [serviceFilters, serviceCurrentPage, activeTab]);
|
||||
|
||||
const fetchServices = async () => {
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
setServicesLoading(true);
|
||||
const response = await serviceService.getServices({
|
||||
@@ -894,7 +902,17 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
} finally {
|
||||
setServicesLoading(false);
|
||||
}
|
||||
};
|
||||
}, [serviceFilters.search, serviceFilters.status, serviceCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setServiceCurrentPage(1);
|
||||
}, [serviceFilters.search, serviceFilters.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'services') {
|
||||
fetchServices();
|
||||
}
|
||||
}, [activeTab, fetchServices]);
|
||||
|
||||
const handleServiceSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1311,6 +1329,100 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</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' && (
|
||||
<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" />
|
||||
@@ -1458,7 +1570,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
{formatCurrency(calculateCheckInAdditionalFee())}
|
||||
{formatCurrency(additionalFee)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1693,10 +1805,12 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<span>Total:</span>
|
||||
<span>{formatCurrency(calculateTotal())}</span>
|
||||
</div>
|
||||
{calculateTotalPaid() > 0 && (
|
||||
<div className="flex justify-between items-center text-lg text-gray-600">
|
||||
<span>Deposit paid:</span>
|
||||
<span className="font-semibold">-{formatCurrency(calculateDeposit())}</span>
|
||||
<span>Total paid:</span>
|
||||
<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">
|
||||
<span>Remaining payment:</span>
|
||||
<span>{formatCurrency(calculateRemaining())}</span>
|
||||
@@ -2075,12 +2189,177 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||
</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">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Total Price</label>
|
||||
<p className="text-4xl font-bold bg-gradient-to-r from-amber-600 via-amber-700 to-amber-600 bg-clip-text text-transparent">
|
||||
{formatCurrency(selectedBooking.total_price)}
|
||||
{/* Payment Method & Status */}
|
||||
<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-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
|
||||
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>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
||||
|
||||
@@ -36,11 +36,12 @@ import systemSettingsService, {
|
||||
CompanySettingsResponse,
|
||||
UpdateCompanySettingsRequest,
|
||||
} from '../../services/api/systemSettingsService';
|
||||
import { recaptchaService, RecaptchaSettingsAdminResponse, UpdateRecaptchaSettingsRequest } from '../../services/api/systemSettingsService';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import { Loading } from '../../components/common';
|
||||
import { getCurrencySymbol } from '../../utils/format';
|
||||
|
||||
type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company';
|
||||
type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company' | 'recaptcha';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
|
||||
@@ -105,12 +106,22 @@ const SettingsPage: React.FC = () => {
|
||||
company_phone: '',
|
||||
company_email: '',
|
||||
company_address: '',
|
||||
tax_rate: 0,
|
||||
});
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
||||
const [uploadingLogo, setUploadingLogo] = 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 [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -146,6 +157,9 @@ const SettingsPage: React.FC = () => {
|
||||
if (activeTab === 'company') {
|
||||
loadCompanySettings();
|
||||
}
|
||||
if (activeTab === 'recaptcha') {
|
||||
loadRecaptchaSettings();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -219,6 +233,7 @@ const SettingsPage: React.FC = () => {
|
||||
company_phone: companyRes.data.company_phone || '',
|
||||
company_email: companyRes.data.company_email || '',
|
||||
company_address: companyRes.data.company_address || '',
|
||||
tax_rate: companyRes.data.tax_rate || 0,
|
||||
});
|
||||
|
||||
// Set previews if URLs exist
|
||||
@@ -579,6 +594,41 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecaptchaSettings = async () => {
|
||||
try {
|
||||
const recaptchaRes = await recaptchaService.getRecaptchaSettingsAdmin();
|
||||
setRecaptchaSettings(recaptchaRes.data);
|
||||
setRecaptchaFormData({
|
||||
recaptcha_site_key: recaptchaRes.data.recaptcha_site_key || '',
|
||||
recaptcha_secret_key: '',
|
||||
recaptcha_enabled: recaptchaRes.data.recaptcha_enabled || false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
'Failed to load reCAPTCHA settings'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRecaptcha = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await recaptchaService.updateRecaptchaSettings(recaptchaFormData);
|
||||
toast.success('reCAPTCHA settings saved successfully');
|
||||
await loadRecaptchaSettings();
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
'Failed to save reCAPTCHA settings'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen={false} text="Loading settings..." />;
|
||||
}
|
||||
@@ -590,6 +640,7 @@ const SettingsPage: React.FC = () => {
|
||||
{ id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard },
|
||||
{ id: 'smtp' as SettingsTab, label: 'Email Server', icon: Mail },
|
||||
{ id: 'company' as SettingsTab, label: 'Company Info', icon: Building2 },
|
||||
{ id: 'recaptcha' as SettingsTab, label: 'reCAPTCHA', icon: Shield },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -2154,6 +2205,29 @@ const SettingsPage: React.FC = () => {
|
||||
Physical address of your company or hotel
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -2178,6 +2252,152 @@ const SettingsPage: React.FC = () => {
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
} from '../../utils/validationSchemas';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
import * as yup from 'yup';
|
||||
import { toast } from 'react-toastify';
|
||||
import Recaptcha from '../../components/common/Recaptcha';
|
||||
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||
|
||||
const mfaTokenSchema = yup.object().shape({
|
||||
mfaToken: yup
|
||||
@@ -41,6 +44,7 @@ const LoginPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// MFA form setup
|
||||
const {
|
||||
@@ -78,6 +82,23 @@ const LoginPage: React.FC = () => {
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
@@ -91,9 +112,11 @@ const LoginPage: React.FC = () => {
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
setRecaptchaToken(null);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Login error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -391,6 +414,19 @@ const LoginPage: React.FC = () => {
|
||||
</Link>
|
||||
</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 */}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
RegisterFormData,
|
||||
} from '../../utils/validationSchemas';
|
||||
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 navigate = useNavigate();
|
||||
@@ -32,6 +35,7 @@ const RegisterPage: React.FC = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
@@ -87,6 +91,23 @@ const RegisterPage: React.FC = () => {
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await registerUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
@@ -96,9 +117,11 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
// Redirect to login page
|
||||
navigate('/login', { replace: true });
|
||||
setRecaptchaToken(null);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Register error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -443,6 +466,19 @@ const RegisterPage: React.FC = () => {
|
||||
)}
|
||||
</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 */}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -503,7 +503,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
{/* Payment Method & Status */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<CreditCard className="w-4 h-4 inline mr-1" />
|
||||
@@ -529,6 +529,70 @@ const BookingDetailPage: React.FC = () => {
|
||||
</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 */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
@@ -579,17 +643,59 @@ const BookingDetailPage: React.FC = () => {
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
{(() => {
|
||||
// Calculate amount paid from completed payments
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = booking.total_price - amountPaid;
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPayments && (
|
||||
<>
|
||||
<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 Payment
|
||||
Total Booking Price
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-indigo-600">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -484,20 +484,33 @@ const BookingSuccessPage: React.FC = () => {
|
||||
|
||||
{/* Total Price */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between
|
||||
items-center"
|
||||
>
|
||||
<span className="text-lg font-semibold
|
||||
text-gray-900"
|
||||
>
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold
|
||||
text-indigo-600"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
{booking.original_price && booking.discount_amount && booking.discount_amount > 0 ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600">Subtotal:</span>
|
||||
<span className="text-base font-semibold text-gray-900">{formatPrice(booking.original_price)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-green-600">
|
||||
Discount{booking.promotion_code ? ` (${booking.promotion_code})` : ''}:
|
||||
</span>
|
||||
<span className="text-base font-semibold text-green-600">-{formatPrice(booking.discount_amount)}</span>
|
||||
</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>
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
AlertCircle,
|
||||
CreditCard,
|
||||
ArrowLeft,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getBookingById, type Booking } from
|
||||
import { getBookingById, cancelBooking, type Booking } from
|
||||
'../../services/api/bookingService';
|
||||
import {
|
||||
getPaymentsByBookingId,
|
||||
@@ -21,13 +22,15 @@ import PayPalPaymentWrapper from '../../components/payments/PayPalPaymentWrapper
|
||||
const DepositPaymentPage: React.FC = () => {
|
||||
const { bookingId } = useParams<{ bookingId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const { formatCurrency, currency } = useFormatCurrency();
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paymentSuccess, setPaymentSuccess] = useState(false);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<'stripe' | 'paypal' | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId) {
|
||||
@@ -86,30 +89,82 @@ const DepositPaymentPage: React.FC = () => {
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
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
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8">
|
||||
<div
|
||||
className="bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||
border border-red-500/30 rounded-xl p-6 sm:p-12 text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-red-500/10"
|
||||
>
|
||||
<AlertCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-red-300 font-light text-base sm:text-lg mb-6 tracking-wide px-2">
|
||||
{error || 'Payment information not found'}
|
||||
</p>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="inline-flex items-center gap-2 px-6 py-2
|
||||
bg-red-600 text-white rounded-lg hover:bg-red-700
|
||||
transition-colors"
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r
|
||||
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
|
||||
px-4 py-2 sm:px-6 sm:py-3 rounded-sm hover:from-[#f5d76e]
|
||||
hover:to-[#d4af37] transition-all duration-300
|
||||
font-medium tracking-wide shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Back to booking list
|
||||
</Link>
|
||||
</div>
|
||||
@@ -123,36 +178,71 @@ const DepositPaymentPage: React.FC = () => {
|
||||
const isDepositPaid = depositPayment.payment_status === 'completed';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8 sm:py-12 w-screen relative -mt-6 -mb-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||
{/* 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-2 text-gray-600
|
||||
hover:text-gray-900 mb-6 transition-colors"
|
||||
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-5 h-5" />
|
||||
<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) */}
|
||||
{isDepositPaid && (
|
||||
<div
|
||||
className="bg-green-50 border-2 border-green-200
|
||||
rounded-lg p-6 mb-6"
|
||||
className="bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||
border border-green-500/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||
backdrop-blur-xl shadow-lg shadow-green-500/10"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100 rounded-full
|
||||
flex items-center justify-center"
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-green-500/20 to-green-600/20
|
||||
rounded-full flex items-center justify-center flex-shrink-0
|
||||
border border-green-500/30 shadow-sm shadow-green-500/20"
|
||||
>
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
<CheckCircle className="w-6 h-6 sm:w-7 sm:h-7 text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-green-900 mb-1">
|
||||
Deposit payment successful!
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h1 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-1 tracking-wide">
|
||||
Deposit Payment Successful!
|
||||
</h1>
|
||||
<p className="text-green-700">
|
||||
<p className="text-green-200/80 font-light text-xs sm:text-sm tracking-wide">
|
||||
Your booking has been confirmed.
|
||||
Remaining amount to be paid at check-in.
|
||||
</p>
|
||||
@@ -164,22 +254,24 @@ const DepositPaymentPage: React.FC = () => {
|
||||
{/* Pending Header */}
|
||||
{!isDepositPaid && (
|
||||
<div
|
||||
className="bg-orange-50 border-2 border-orange-200
|
||||
rounded-lg p-6 mb-6"
|
||||
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||
border border-[#d4af37]/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/10"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
|
||||
<div
|
||||
className="w-16 h-16 bg-orange-100 rounded-full
|
||||
flex items-center justify-center"
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-full flex items-center justify-center flex-shrink-0
|
||||
border border-[#d4af37]/30 shadow-sm shadow-[#d4af37]/20"
|
||||
>
|
||||
<CreditCard className="w-10 h-10 text-orange-600" />
|
||||
<CreditCard className="w-6 h-6 sm:w-7 sm:h-7 text-[#d4af37]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-orange-900 mb-1">
|
||||
Deposit Payment
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h1 className="text-base sm:text-lg font-serif font-semibold text-[#d4af37] mb-1 tracking-wide">
|
||||
Deposit Payment Required
|
||||
</h1>
|
||||
<p className="text-orange-700">
|
||||
Please pay <strong>20% deposit</strong> to
|
||||
<p className="text-gray-300/80 font-light text-xs sm:text-sm tracking-wide">
|
||||
Please pay <strong className="text-[#d4af37] font-medium">20% deposit</strong> to
|
||||
confirm your booking
|
||||
</p>
|
||||
</div>
|
||||
@@ -187,51 +279,55 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{/* Payment Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
{/* Payment Summary */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<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
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-2.5 sm:mb-3 tracking-wide">
|
||||
Payment Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Total Room Price</span>
|
||||
<span className="font-medium">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-0 py-1.5 border-b border-gray-700/30">
|
||||
<span className="text-gray-300 font-light tracking-wide text-xs sm:text-sm">Total Room Price</span>
|
||||
<span className="font-medium text-gray-100 text-xs sm:text-sm">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between border-t pt-3
|
||||
text-orange-600"
|
||||
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">
|
||||
<span className="font-medium text-[#d4af37] text-xs sm:text-sm tracking-wide">
|
||||
Deposit Amount to Pay (20%)
|
||||
</span>
|
||||
<span className="text-xl font-bold">
|
||||
<span className="text-base sm:text-lg font-bold text-[#d4af37]">
|
||||
{formatPrice(depositAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<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>{formatPrice(remainingAmount)}</span>
|
||||
<span className="text-gray-300">{formatPrice(remainingAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDepositPaid && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded p-3">
|
||||
<p className="text-sm text-green-800">
|
||||
<div className="mt-4 sm:mt-6 bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||
border border-green-500/30 rounded-lg p-3 sm:p-4 backdrop-blur-sm">
|
||||
<p className="text-xs sm:text-sm text-green-300 font-light break-words">
|
||||
✓ Deposit paid on:{' '}
|
||||
{depositPayment.payment_date
|
||||
? new Date(depositPayment.payment_date).toLocaleString('en-US')
|
||||
: 'N/A'}
|
||||
</p>
|
||||
{depositPayment.transaction_id && (
|
||||
<p className="text-xs text-green-700 mt-1">
|
||||
<p className="text-xs text-green-400/70 mt-2 font-mono break-all">
|
||||
Transaction ID: {depositPayment.transaction_id}
|
||||
</p>
|
||||
)}
|
||||
@@ -240,37 +336,127 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
{!isDepositPaid && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
||||
Payment Method
|
||||
{!isDepositPaid && !selectedPaymentMethod && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-1.5 tracking-wide">
|
||||
Choose Payment Method
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Pay with your credit or debit card
|
||||
<p className="text-gray-300/80 font-light mb-3 sm:mb-4 tracking-wide text-xs sm:text-sm">
|
||||
Please select how you would like to pay the deposit:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 sm:gap-3">
|
||||
{/* Stripe Option */}
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
{!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-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">
|
||||
<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-700 mb-4">
|
||||
<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-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition-colors"
|
||||
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>
|
||||
@@ -296,15 +482,14 @@ const DepositPaymentPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'paypal' && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
<PayPalPaymentWrapper
|
||||
bookingId={booking.id}
|
||||
amount={depositAmount}
|
||||
currency={currency || 'USD'}
|
||||
onError={(error) => {
|
||||
toast.error(error || 'Payment failed');
|
||||
}}
|
||||
@@ -312,7 +497,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -564,6 +564,33 @@ const MyBookingsPage: React.FC = () => {
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,35 +1,95 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<XCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
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">
|
||||
{cancelling ? (
|
||||
<>
|
||||
<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">
|
||||
<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-600 mb-6">
|
||||
You cancelled the PayPal payment. No charges were made.
|
||||
<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 gap-3">
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
{bookingId && (
|
||||
<button
|
||||
onClick={() => navigate(`/deposit-payment/${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"
|
||||
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
|
||||
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 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
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import Loading from '../../components/common/Loading';
|
||||
@@ -37,6 +37,13 @@ const PayPalReturnPage: React.FC = () => {
|
||||
} else {
|
||||
setError(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) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
|
||||
@@ -52,10 +59,17 @@ const PayPalReturnPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-indigo-600 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Processing your payment...</p>
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center w-full max-w-2xl mx-auto">
|
||||
<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>
|
||||
);
|
||||
@@ -63,18 +77,29 @@ const PayPalReturnPage: React.FC = () => {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
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!
|
||||
</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...
|
||||
</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -84,25 +109,40 @@ const PayPalReturnPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
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
|
||||
</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.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => navigate(`/deposit-payment/${bookingId}`)}
|
||||
className="flex-1 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
|
||||
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
|
||||
>
|
||||
Retry Payment
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -71,8 +71,18 @@ const RoomDetailPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div
|
||||
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="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" />
|
||||
@@ -85,8 +95,18 @@ const RoomDetailPage: React.FC = () => {
|
||||
|
||||
if (error || !room) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div
|
||||
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
|
||||
border border-red-500/30 rounded-xl p-12 text-center
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div
|
||||
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 */}
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-2
|
||||
className="inline-flex items-center gap-1
|
||||
text-[#d4af37]/80 hover:text-[#d4af37]
|
||||
mb-8 transition-all duration-300
|
||||
group font-light tracking-wide"
|
||||
mb-3 transition-all duration-300
|
||||
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>
|
||||
</Link>
|
||||
|
||||
{/* Image Gallery */}
|
||||
<div className="mb-12">
|
||||
<div className="mb-4">
|
||||
<RoomGallery
|
||||
images={(room.images && room.images.length > 0)
|
||||
? room.images
|
||||
@@ -140,30 +170,30 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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 */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{/* Room Name with Luxury Badge */}
|
||||
<div className="flex items-start justify-between">
|
||||
<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 && (
|
||||
<div className="flex items-center gap-2
|
||||
<div className="flex items-center gap-1
|
||||
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-4 py-1.5 rounded-sm
|
||||
text-xs font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30"
|
||||
text-[#0f0f0f] px-2 py-0.5 rounded-sm
|
||||
text-[10px] sm:text-xs font-medium tracking-wide
|
||||
shadow-sm shadow-[#d4af37]/30"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<Sparkles className="w-2.5 h-2.5" />
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`px-4 py-1.5 rounded-sm
|
||||
text-xs font-medium tracking-wide
|
||||
backdrop-blur-sm shadow-lg
|
||||
className={`px-2 py-0.5 rounded-sm
|
||||
text-[10px] sm:text-xs font-medium tracking-wide
|
||||
backdrop-blur-sm shadow-sm
|
||||
${
|
||||
room.status === 'available'
|
||||
? 'bg-green-500/90 text-white border border-green-400/50'
|
||||
@@ -180,8 +210,8 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-serif font-semibold
|
||||
text-white mb-6 tracking-tight leading-tight
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-serif font-semibold
|
||||
text-white mb-2 tracking-tight leading-tight
|
||||
bg-gradient-to-r from-white via-[#d4af37] to-white
|
||||
bg-clip-text text-transparent"
|
||||
>
|
||||
@@ -191,63 +221,63 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Basic Info Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="flex items-center gap-3
|
||||
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-3 mb-3">
|
||||
<div className="flex items-center gap-2
|
||||
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
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">
|
||||
<MapPin className="w-5 h-5 text-[#d4af37]" />
|
||||
<MapPin className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</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
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3
|
||||
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
<div className="flex items-center gap-2
|
||||
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
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">
|
||||
<Users className="w-5 h-5 text-[#d4af37]" />
|
||||
<Users className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{room.average_rating != null && (
|
||||
<div className="flex items-center gap-3
|
||||
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
<div className="flex items-center gap-2
|
||||
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
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">
|
||||
<Star className="w-5 h-5 text-[#d4af37] fill-[#d4af37]" />
|
||||
<Star className="w-3.5 h-3.5 text-[#d4af37] fill-[#d4af37]" />
|
||||
</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
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-white font-semibold">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-xs sm:text-sm text-white font-semibold">
|
||||
{Number(room.average_rating).toFixed(1)}
|
||||
</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})
|
||||
</span>
|
||||
</div>
|
||||
@@ -259,23 +289,23 @@ const RoomDetailPage: React.FC = () => {
|
||||
|
||||
{/* Description - Show room-specific description first, fallback to room type */}
|
||||
{(room?.description || roomType?.description) && (
|
||||
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-xl border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
|
||||
<div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<Award className="w-5 h-5 text-[#d4af37]" />
|
||||
<Award className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-serif font-semibold
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold
|
||||
text-white tracking-wide"
|
||||
>
|
||||
{room?.description ? 'Room Description' : 'Room Type Description'}
|
||||
</h2>
|
||||
</div>
|
||||
<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}
|
||||
</p>
|
||||
@@ -283,16 +313,16 @@ const RoomDetailPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Amenities */}
|
||||
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-xl border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
|
||||
<div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<Sparkles className="w-5 h-5 text-[#d4af37]" />
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-serif font-semibold
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold
|
||||
text-white tracking-wide"
|
||||
>
|
||||
Amenities & Features
|
||||
@@ -311,25 +341,25 @@ const RoomDetailPage: React.FC = () => {
|
||||
{/* Booking Card */}
|
||||
<aside className="lg:col-span-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
|
||||
rounded-xl border border-[#d4af37]/30
|
||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20
|
||||
p-8 sticky top-6"
|
||||
rounded-lg border border-[#d4af37]/30
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/20
|
||||
p-3 sm:p-4 sticky top-4"
|
||||
>
|
||||
{/* Price Section */}
|
||||
<div className="mb-8 pb-8 border-b border-[#d4af37]/20">
|
||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-2">
|
||||
<div className="mb-4 pb-4 border-b border-[#d4af37]/20">
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-1">
|
||||
Starting from
|
||||
</p>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<CurrencyIcon className="text-[#d4af37]" size={24} />
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<CurrencyIcon className="text-[#d4af37]" size={16} />
|
||||
<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-clip-text text-transparent tracking-tight"
|
||||
>
|
||||
{formattedPrice}
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,23 +367,23 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Booking Button */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<Link
|
||||
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
|
||||
tracking-wide relative overflow-hidden group
|
||||
tracking-wide relative overflow-hidden group text-xs sm:text-sm
|
||||
${
|
||||
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'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (room.status !== 'available') e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="relative z-10 flex items-center justify-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
||||
</span>
|
||||
{room.status === 'available' && (
|
||||
@@ -363,42 +393,42 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{room.status === 'available' && (
|
||||
<div className="flex items-start gap-3 p-4 bg-[#d4af37]/5
|
||||
rounded-lg border border-[#d4af37]/20 mb-6"
|
||||
<div className="flex items-start gap-2 p-2 bg-[#d4af37]/5
|
||||
rounded-lg border border-[#d4af37]/20 mb-3"
|
||||
>
|
||||
<Shield className="w-5 h-5 text-[#d4af37] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-gray-300 font-light tracking-wide">
|
||||
<Shield className="w-3.5 h-3.5 text-[#d4af37] mt-0.5 flex-shrink-0" />
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<strong className="text-white font-light">{roomType?.name}</strong>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Type</span>
|
||||
<strong className="text-xs sm:text-sm text-white font-light">{roomType?.name}</strong>
|
||||
</div>
|
||||
<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-white font-light">{(room?.capacity || roomType?.capacity || 0)} guests</span>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Max Guests</span>
|
||||
<span className="text-xs sm:text-sm text-white font-light">{(room?.capacity || roomType?.capacity || 0)} guests</span>
|
||||
</div>
|
||||
{room?.room_size && (
|
||||
<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-white font-light">{room.room_size}</span>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Size</span>
|
||||
<span className="text-xs sm:text-sm text-white font-light">{room.room_size}</span>
|
||||
</div>
|
||||
)}
|
||||
{room?.view && (
|
||||
<div className={`flex items-center justify-between ${room?.room_size ? 'py-3 border-b border-[#d4af37]/10' : 'py-3'}`}>
|
||||
<span className="text-gray-400 font-light tracking-wide">View</span>
|
||||
<span className="text-white font-light">{room.view}</span>
|
||||
<div className={`flex items-center justify-between ${room?.room_size ? 'py-1.5 border-b border-[#d4af37]/10' : 'py-1.5'}`}>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">View</span>
|
||||
<span className="text-xs sm:text-sm text-white font-light">{room.view}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -407,9 +437,9 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Reviews Section */}
|
||||
<div className="mb-12 p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-xl border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
|
||||
<div className="mb-4 p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
<ReviewSection roomId={room.id} />
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface Booking {
|
||||
check_out_date: string;
|
||||
guest_count: number;
|
||||
total_price: number;
|
||||
original_price?: number;
|
||||
discount_amount?: number;
|
||||
promotion_code?: string;
|
||||
status:
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
@@ -70,6 +73,13 @@ export interface Booking {
|
||||
phone_number?: string;
|
||||
};
|
||||
payments?: Payment[];
|
||||
payment_balance?: {
|
||||
total_paid: number;
|
||||
total_price: number;
|
||||
remaining_balance: number;
|
||||
is_fully_paid: boolean;
|
||||
payment_percentage: number;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
createPayment,
|
||||
getPayments,
|
||||
@@ -378,4 +402,5 @@ export default {
|
||||
confirmStripePayment,
|
||||
createPayPalOrder,
|
||||
capturePayPalPayment,
|
||||
cancelPayPalPayment,
|
||||
};
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface CompanySettingsResponse {
|
||||
company_phone: string;
|
||||
company_email: string;
|
||||
company_address: string;
|
||||
tax_rate: number;
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
@@ -119,6 +120,7 @@ export interface UpdateCompanySettingsRequest {
|
||||
company_phone?: string;
|
||||
company_email?: string;
|
||||
company_address?: string;
|
||||
tax_rate?: number;
|
||||
}
|
||||
|
||||
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 = {
|
||||
/**
|
||||
* 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 { recaptchaService };
|
||||
|
||||
export type {
|
||||
PlatformCurrencyResponse,
|
||||
@@ -328,5 +422,10 @@ export type {
|
||||
UpdateCompanySettingsRequest,
|
||||
UploadLogoResponse,
|
||||
UploadFaviconResponse,
|
||||
RecaptchaSettingsResponse,
|
||||
RecaptchaSettingsAdminResponse,
|
||||
UpdateRecaptchaSettingsRequest,
|
||||
VerifyRecaptchaRequest,
|
||||
VerifyRecaptchaResponse,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user