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
|
paypal-checkout-serversdk>=1.0.3
|
||||||
pyotp==2.9.0
|
pyotp==2.9.0
|
||||||
qrcode[pil]==7.4.2
|
qrcode[pil]==7.4.2
|
||||||
|
httpx==0.25.2
|
||||||
|
|
||||||
# Enterprise features (optional but recommended)
|
# Enterprise features (optional but recommended)
|
||||||
# redis==5.0.1 # Uncomment if using Redis caching
|
# redis==5.0.1 # Uncomment if using Redis caching
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -24,6 +24,9 @@ class Booking(Base):
|
|||||||
check_out_date = Column(DateTime, nullable=False)
|
check_out_date = Column(DateTime, nullable=False)
|
||||||
num_guests = Column(Integer, nullable=False, default=1)
|
num_guests = Column(Integer, nullable=False, default=1)
|
||||||
total_price = Column(Numeric(10, 2), nullable=False)
|
total_price = Column(Numeric(10, 2), nullable=False)
|
||||||
|
original_price = Column(Numeric(10, 2), nullable=True) # Price before discount
|
||||||
|
discount_amount = Column(Numeric(10, 2), nullable=True, default=0) # Discount amount applied
|
||||||
|
promotion_code = Column(String(50), nullable=True) # Promotion code used
|
||||||
status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending)
|
status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending)
|
||||||
deposit_paid = Column(Boolean, nullable=False, default=False)
|
deposit_paid = Column(Boolean, nullable=False, default=False)
|
||||||
requires_deposit = Column(Boolean, nullable=False, default=False)
|
requires_deposit = Column(Boolean, nullable=False, default=False)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Invoice(Base):
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft)
|
status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft)
|
||||||
|
is_proforma = Column(Boolean, nullable=False, default=False) # True for proforma invoices
|
||||||
|
|
||||||
# Company/Organization information (for admin to manage)
|
# Company/Organization information (for admin to manage)
|
||||||
company_name = Column(String(200), nullable=True)
|
company_name = Column(String(200), nullable=True)
|
||||||
|
|||||||
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"])
|
router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> str:
|
||||||
|
"""Generate HTML email content for invoice"""
|
||||||
|
invoice_type = "Proforma Invoice" if is_proforma else "Invoice"
|
||||||
|
items_html = ''.join([f'''
|
||||||
|
<tr>
|
||||||
|
<td>{item.get('description', 'N/A')}</td>
|
||||||
|
<td>{item.get('quantity', 0)}</td>
|
||||||
|
<td>{item.get('unit_price', 0):.2f}</td>
|
||||||
|
<td>{item.get('line_total', 0):.2f}</td>
|
||||||
|
</tr>
|
||||||
|
''' for item in invoice.get('items', [])])
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
|
||||||
|
color: #0f0f0f;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}}
|
||||||
|
.content {{
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}}
|
||||||
|
.invoice-info {{
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}}
|
||||||
|
.invoice-info h2 {{
|
||||||
|
color: #d4af37;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}}
|
||||||
|
.details {{
|
||||||
|
margin: 20px 0;
|
||||||
|
}}
|
||||||
|
.details table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
}}
|
||||||
|
.details th, .details td {{
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}}
|
||||||
|
.details th {{
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
.total {{
|
||||||
|
text-align: right;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 20px;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{invoice_type}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="invoice-info">
|
||||||
|
<h2>{invoice_type} #{invoice.get('invoice_number', 'N/A')}</h2>
|
||||||
|
<p><strong>Issue Date:</strong> {invoice.get('issue_date', 'N/A')}</p>
|
||||||
|
<p><strong>Due Date:</strong> {invoice.get('due_date', 'N/A')}</p>
|
||||||
|
<p><strong>Status:</strong> {invoice.get('status', 'N/A')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<h3>Items</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Unit Price</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items_html}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total">
|
||||||
|
<p>Subtotal: {invoice.get('subtotal', 0):.2f}</p>
|
||||||
|
{f'<p style="color: #059669;">Discount: -{invoice.get("discount_amount", 0):.2f}</p>' if invoice.get('discount_amount', 0) > 0 else ''}
|
||||||
|
<p>Tax: {invoice.get('tax_amount', 0):.2f}</p>
|
||||||
|
<p><strong>Total Amount: {invoice.get('total_amount', 0):.2f}</strong></p>
|
||||||
|
<p>Amount Paid: {invoice.get('amount_paid', 0):.2f}</p>
|
||||||
|
<p>Balance Due: {invoice.get('balance_due', 0):.2f}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Thank you for your booking!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def generate_booking_number() -> str:
|
def generate_booking_number() -> str:
|
||||||
"""Generate unique booking number"""
|
"""Generate unique booking number"""
|
||||||
prefix = "BK"
|
prefix = "BK"
|
||||||
@@ -34,6 +162,29 @@ def generate_booking_number() -> str:
|
|||||||
return f"{prefix}-{ts}-{rand}"
|
return f"{prefix}-{ts}-{rand}"
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_booking_payment_balance(booking: Booking) -> dict:
|
||||||
|
"""Calculate total paid amount and remaining balance for a booking"""
|
||||||
|
total_paid = 0.0
|
||||||
|
if booking.payments:
|
||||||
|
# Sum all completed payments
|
||||||
|
total_paid = sum(
|
||||||
|
float(payment.amount) if payment.amount else 0.0
|
||||||
|
for payment in booking.payments
|
||||||
|
if payment.payment_status == PaymentStatus.completed
|
||||||
|
)
|
||||||
|
|
||||||
|
total_price = float(booking.total_price) if booking.total_price else 0.0
|
||||||
|
remaining_balance = total_price - total_paid
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_paid": total_paid,
|
||||||
|
"total_price": total_price,
|
||||||
|
"remaining_balance": remaining_balance,
|
||||||
|
"is_fully_paid": remaining_balance <= 0.01, # Allow small floating point differences
|
||||||
|
"payment_percentage": (total_paid / total_price * 100) if total_price > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_all_bookings(
|
async def get_all_bookings(
|
||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
@@ -47,7 +198,11 @@ async def get_all_bookings(
|
|||||||
):
|
):
|
||||||
"""Get all bookings (Admin/Staff only)"""
|
"""Get all bookings (Admin/Staff only)"""
|
||||||
try:
|
try:
|
||||||
query = db.query(Booking)
|
query = db.query(Booking).options(
|
||||||
|
selectinload(Booking.payments),
|
||||||
|
joinedload(Booking.user),
|
||||||
|
joinedload(Booking.room).joinedload(Room.room_type)
|
||||||
|
)
|
||||||
|
|
||||||
# Filter by search (booking_number)
|
# Filter by search (booking_number)
|
||||||
if search:
|
if search:
|
||||||
@@ -79,6 +234,23 @@ async def get_all_bookings(
|
|||||||
# Include related data
|
# Include related data
|
||||||
result = []
|
result = []
|
||||||
for booking in bookings:
|
for booking in bookings:
|
||||||
|
# Determine payment_method and payment_status from payments
|
||||||
|
payment_method_from_payments = None
|
||||||
|
payment_status_from_payments = "unpaid"
|
||||||
|
if booking.payments:
|
||||||
|
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||||
|
if isinstance(latest_payment.payment_method, PaymentMethod):
|
||||||
|
payment_method_from_payments = latest_payment.payment_method.value
|
||||||
|
elif hasattr(latest_payment.payment_method, 'value'):
|
||||||
|
payment_method_from_payments = latest_payment.payment_method.value
|
||||||
|
else:
|
||||||
|
payment_method_from_payments = str(latest_payment.payment_method)
|
||||||
|
|
||||||
|
if latest_payment.payment_status == PaymentStatus.completed:
|
||||||
|
payment_status_from_payments = "paid"
|
||||||
|
elif latest_payment.payment_status == PaymentStatus.refunded:
|
||||||
|
payment_status_from_payments = "refunded"
|
||||||
|
|
||||||
booking_dict = {
|
booking_dict = {
|
||||||
"id": booking.id,
|
"id": booking.id,
|
||||||
"booking_number": booking.booking_number,
|
"booking_number": booking.booking_number,
|
||||||
@@ -87,21 +259,33 @@ async def get_all_bookings(
|
|||||||
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
||||||
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
||||||
"num_guests": booking.num_guests,
|
"num_guests": booking.num_guests,
|
||||||
|
"guest_count": booking.num_guests, # Frontend expects guest_count
|
||||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||||
|
"original_price": float(booking.original_price) if booking.original_price else None,
|
||||||
|
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
|
||||||
|
"promotion_code": booking.promotion_code,
|
||||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||||
|
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
|
||||||
|
"payment_status": payment_status_from_payments,
|
||||||
"deposit_paid": booking.deposit_paid,
|
"deposit_paid": booking.deposit_paid,
|
||||||
"requires_deposit": booking.requires_deposit,
|
"requires_deposit": booking.requires_deposit,
|
||||||
"special_requests": booking.special_requests,
|
"special_requests": booking.special_requests,
|
||||||
|
"notes": booking.special_requests, # Frontend expects notes
|
||||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||||
|
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||||
|
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||||
|
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add user info
|
# Add user info
|
||||||
if booking.user:
|
if booking.user:
|
||||||
booking_dict["user"] = {
|
booking_dict["user"] = {
|
||||||
"id": booking.user.id,
|
"id": booking.user.id,
|
||||||
|
"name": booking.user.full_name,
|
||||||
"full_name": booking.user.full_name,
|
"full_name": booking.user.full_name,
|
||||||
"email": booking.user.email,
|
"email": booking.user.email,
|
||||||
"phone": booking.user.phone,
|
"phone": booking.user.phone,
|
||||||
|
"phone_number": booking.user.phone,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add room info
|
# Add room info
|
||||||
@@ -111,6 +295,37 @@ async def get_all_bookings(
|
|||||||
"room_number": booking.room.room_number,
|
"room_number": booking.room.room_number,
|
||||||
"floor": booking.room.floor,
|
"floor": booking.room.floor,
|
||||||
}
|
}
|
||||||
|
# Safely access room_type - it should be loaded via joinedload
|
||||||
|
try:
|
||||||
|
if hasattr(booking.room, 'room_type') and booking.room.room_type:
|
||||||
|
booking_dict["room"]["room_type"] = {
|
||||||
|
"id": booking.room.room_type.id,
|
||||||
|
"name": booking.room.room_type.name,
|
||||||
|
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
|
||||||
|
"capacity": booking.room.room_type.capacity,
|
||||||
|
}
|
||||||
|
except Exception as room_type_error:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning(f"Could not load room_type for booking {booking.id}: {room_type_error}")
|
||||||
|
|
||||||
|
# Add payments
|
||||||
|
if booking.payments:
|
||||||
|
booking_dict["payments"] = [
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"amount": float(p.amount) if p.amount else 0.0,
|
||||||
|
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||||
|
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||||
|
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
||||||
|
"transaction_id": p.transaction_id,
|
||||||
|
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
|
||||||
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
|
}
|
||||||
|
for p in booking.payments
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
booking_dict["payments"] = []
|
||||||
|
|
||||||
result.append(booking_dict)
|
result.append(booking_dict)
|
||||||
|
|
||||||
@@ -127,6 +342,11 @@ async def get_all_bookings(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error in get_all_bookings: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -138,13 +358,33 @@ async def get_my_bookings(
|
|||||||
):
|
):
|
||||||
"""Get current user's bookings"""
|
"""Get current user's bookings"""
|
||||||
try:
|
try:
|
||||||
bookings = db.query(Booking).filter(
|
bookings = db.query(Booking).options(
|
||||||
|
selectinload(Booking.payments),
|
||||||
|
joinedload(Booking.room).joinedload(Room.room_type)
|
||||||
|
).filter(
|
||||||
Booking.user_id == current_user.id
|
Booking.user_id == current_user.id
|
||||||
).order_by(Booking.created_at.desc()).all()
|
).order_by(Booking.created_at.desc()).all()
|
||||||
|
|
||||||
base_url = get_base_url(request)
|
base_url = get_base_url(request)
|
||||||
result = []
|
result = []
|
||||||
for booking in bookings:
|
for booking in bookings:
|
||||||
|
# Determine payment_method and payment_status from payments
|
||||||
|
payment_method_from_payments = None
|
||||||
|
payment_status_from_payments = "unpaid"
|
||||||
|
if booking.payments:
|
||||||
|
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||||
|
if isinstance(latest_payment.payment_method, PaymentMethod):
|
||||||
|
payment_method_from_payments = latest_payment.payment_method.value
|
||||||
|
elif hasattr(latest_payment.payment_method, 'value'):
|
||||||
|
payment_method_from_payments = latest_payment.payment_method.value
|
||||||
|
else:
|
||||||
|
payment_method_from_payments = str(latest_payment.payment_method)
|
||||||
|
|
||||||
|
if latest_payment.payment_status == PaymentStatus.completed:
|
||||||
|
payment_status_from_payments = "paid"
|
||||||
|
elif latest_payment.payment_status == PaymentStatus.refunded:
|
||||||
|
payment_status_from_payments = "refunded"
|
||||||
|
|
||||||
booking_dict = {
|
booking_dict = {
|
||||||
"id": booking.id,
|
"id": booking.id,
|
||||||
"booking_number": booking.booking_number,
|
"booking_number": booking.booking_number,
|
||||||
@@ -152,12 +392,22 @@ async def get_my_bookings(
|
|||||||
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
||||||
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
||||||
"num_guests": booking.num_guests,
|
"num_guests": booking.num_guests,
|
||||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
"guest_count": booking.num_guests,
|
||||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||||
"deposit_paid": booking.deposit_paid,
|
"original_price": float(booking.original_price) if booking.original_price else None,
|
||||||
"requires_deposit": booking.requires_deposit,
|
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
|
||||||
"special_requests": booking.special_requests,
|
"promotion_code": booking.promotion_code,
|
||||||
|
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||||
|
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
|
||||||
|
"payment_status": payment_status_from_payments,
|
||||||
|
"deposit_paid": booking.deposit_paid,
|
||||||
|
"requires_deposit": booking.requires_deposit,
|
||||||
|
"special_requests": booking.special_requests,
|
||||||
|
"notes": booking.special_requests,
|
||||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||||
|
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||||
|
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||||
|
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add room info
|
# Add room info
|
||||||
@@ -184,6 +434,24 @@ async def get_my_bookings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add payments
|
||||||
|
if booking.payments:
|
||||||
|
booking_dict["payments"] = [
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"amount": float(p.amount) if p.amount else 0.0,
|
||||||
|
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||||
|
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||||
|
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
||||||
|
"transaction_id": p.transaction_id,
|
||||||
|
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
|
||||||
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
|
}
|
||||||
|
for p in booking.payments
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
booking_dict["payments"] = []
|
||||||
|
|
||||||
result.append(booking_dict)
|
result.append(booking_dict)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -219,6 +487,10 @@ async def create_booking(
|
|||||||
guest_count = booking_data.get("guest_count", 1)
|
guest_count = booking_data.get("guest_count", 1)
|
||||||
notes = booking_data.get("notes")
|
notes = booking_data.get("notes")
|
||||||
payment_method = booking_data.get("payment_method", "cash")
|
payment_method = booking_data.get("payment_method", "cash")
|
||||||
|
promotion_code = booking_data.get("promotion_code")
|
||||||
|
|
||||||
|
# Invoice information (optional)
|
||||||
|
invoice_info = booking_data.get("invoice_info", {})
|
||||||
|
|
||||||
# Detailed validation with specific error messages
|
# Detailed validation with specific error messages
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
@@ -284,6 +556,34 @@ async def create_booking(
|
|||||||
# Will be confirmed after successful payment
|
# Will be confirmed after successful payment
|
||||||
initial_status = BookingStatus.pending
|
initial_status = BookingStatus.pending
|
||||||
|
|
||||||
|
# Calculate original price (before discount) and discount amount
|
||||||
|
# Calculate room price
|
||||||
|
room_price = float(room.price) if room.price and room.price > 0 else float(room.room_type.base_price) if room.room_type else 0.0
|
||||||
|
number_of_nights = (check_out - check_in).days
|
||||||
|
room_total = room_price * number_of_nights
|
||||||
|
|
||||||
|
# Calculate services total (will be recalculated when adding services, but estimate here)
|
||||||
|
services = booking_data.get("services", [])
|
||||||
|
services_total = 0.0
|
||||||
|
if services:
|
||||||
|
from ..models.service import Service
|
||||||
|
for service_item in services:
|
||||||
|
service_id = service_item.get("service_id")
|
||||||
|
quantity = service_item.get("quantity", 1)
|
||||||
|
if service_id:
|
||||||
|
service = db.query(Service).filter(Service.id == service_id).first()
|
||||||
|
if service and service.is_active:
|
||||||
|
services_total += float(service.price) * quantity
|
||||||
|
|
||||||
|
original_price = room_total + services_total
|
||||||
|
discount_amount = max(0.0, original_price - float(total_price)) if promotion_code else 0.0
|
||||||
|
|
||||||
|
# Add promotion code to notes if provided
|
||||||
|
final_notes = notes or ""
|
||||||
|
if promotion_code:
|
||||||
|
promotion_note = f"Promotion Code: {promotion_code}"
|
||||||
|
final_notes = f"{promotion_note}\n{final_notes}".strip() if final_notes else promotion_note
|
||||||
|
|
||||||
# Create booking
|
# Create booking
|
||||||
booking = Booking(
|
booking = Booking(
|
||||||
booking_number=booking_number,
|
booking_number=booking_number,
|
||||||
@@ -293,7 +593,10 @@ async def create_booking(
|
|||||||
check_out_date=check_out,
|
check_out_date=check_out,
|
||||||
num_guests=guest_count,
|
num_guests=guest_count,
|
||||||
total_price=total_price,
|
total_price=total_price,
|
||||||
special_requests=notes,
|
original_price=original_price if promotion_code else None,
|
||||||
|
discount_amount=discount_amount if promotion_code and discount_amount > 0 else None,
|
||||||
|
promotion_code=promotion_code,
|
||||||
|
special_requests=final_notes,
|
||||||
status=initial_status,
|
status=initial_status,
|
||||||
requires_deposit=requires_deposit,
|
requires_deposit=requires_deposit,
|
||||||
deposit_paid=False,
|
deposit_paid=False,
|
||||||
@@ -330,8 +633,21 @@ async def create_booking(
|
|||||||
logger.info(f"Payment created: ID={payment.id}, method={payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method}")
|
logger.info(f"Payment created: ID={payment.id}, method={payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method}")
|
||||||
|
|
||||||
# Create deposit payment if required (for cash method)
|
# Create deposit payment if required (for cash method)
|
||||||
# Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record
|
# For cash payments, create a pending deposit payment record that can be paid via PayPal or Stripe
|
||||||
# The payment will be created when the customer pays at check-in
|
if requires_deposit and deposit_amount > 0:
|
||||||
|
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
|
||||||
|
deposit_payment = Payment(
|
||||||
|
booking_id=booking.id,
|
||||||
|
amount=deposit_amount,
|
||||||
|
payment_method=PaymentMethod.stripe, # Default, will be updated when user chooses payment method
|
||||||
|
payment_type=PaymentType.deposit,
|
||||||
|
deposit_percentage=deposit_percentage,
|
||||||
|
payment_status=PaymentStatus.pending,
|
||||||
|
payment_date=None,
|
||||||
|
)
|
||||||
|
db.add(deposit_payment)
|
||||||
|
db.flush()
|
||||||
|
logger.info(f"Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%")
|
||||||
|
|
||||||
# Add services to booking if provided
|
# Add services to booking if provided
|
||||||
services = booking_data.get("services", [])
|
services = booking_data.get("services", [])
|
||||||
@@ -368,9 +684,10 @@ async def create_booking(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
# Automatically create invoice for the booking
|
# Automatically create invoice(s) for the booking
|
||||||
try:
|
try:
|
||||||
from ..services.invoice_service import InvoiceService
|
from ..services.invoice_service import InvoiceService
|
||||||
|
from ..utils.mailer import send_email
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
# Reload booking with service_usages for invoice creation
|
# Reload booking with service_usages for invoice creation
|
||||||
@@ -378,15 +695,113 @@ async def create_booking(
|
|||||||
selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
|
selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
|
||||||
).filter(Booking.id == booking.id).first()
|
).filter(Booking.id == booking.id).first()
|
||||||
|
|
||||||
# Create invoice automatically
|
# Get company settings for invoice
|
||||||
invoice = InvoiceService.create_invoice_from_booking(
|
from ..models.system_settings import SystemSettings
|
||||||
booking_id=booking.id,
|
company_settings = {}
|
||||||
db=db,
|
for key in ["company_name", "company_address", "company_phone", "company_email", "company_tax_id", "company_logo_url"]:
|
||||||
created_by_id=current_user.id,
|
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||||
tax_rate=0.0, # Default no tax, can be configured
|
if setting and setting.value:
|
||||||
discount_amount=0.0,
|
company_settings[key] = setting.value
|
||||||
due_days=30,
|
|
||||||
)
|
# Get tax rate from settings (default to 0 if not set)
|
||||||
|
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == "tax_rate").first()
|
||||||
|
tax_rate = float(tax_rate_setting.value) if tax_rate_setting and tax_rate_setting.value else 0.0
|
||||||
|
|
||||||
|
# Merge invoice info from form with company settings (form takes precedence)
|
||||||
|
# Only include non-empty values from invoice_info
|
||||||
|
invoice_kwargs = {**company_settings}
|
||||||
|
if invoice_info:
|
||||||
|
if invoice_info.get("company_name"):
|
||||||
|
invoice_kwargs["company_name"] = invoice_info.get("company_name")
|
||||||
|
if invoice_info.get("company_address"):
|
||||||
|
invoice_kwargs["company_address"] = invoice_info.get("company_address")
|
||||||
|
if invoice_info.get("company_tax_id"):
|
||||||
|
invoice_kwargs["company_tax_id"] = invoice_info.get("company_tax_id")
|
||||||
|
if invoice_info.get("customer_tax_id"):
|
||||||
|
invoice_kwargs["customer_tax_id"] = invoice_info.get("customer_tax_id")
|
||||||
|
if invoice_info.get("notes"):
|
||||||
|
invoice_kwargs["notes"] = invoice_info.get("notes")
|
||||||
|
if invoice_info.get("terms_and_conditions"):
|
||||||
|
invoice_kwargs["terms_and_conditions"] = invoice_info.get("terms_and_conditions")
|
||||||
|
if invoice_info.get("payment_instructions"):
|
||||||
|
invoice_kwargs["payment_instructions"] = invoice_info.get("payment_instructions")
|
||||||
|
|
||||||
|
# Get discount from booking
|
||||||
|
booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0
|
||||||
|
|
||||||
|
# Create invoices based on payment method
|
||||||
|
if payment_method == "cash":
|
||||||
|
# For cash bookings: create invoice for 20% deposit + proforma for 80% remaining
|
||||||
|
deposit_amount = float(total_price) * 0.2
|
||||||
|
remaining_amount = float(total_price) * 0.8
|
||||||
|
|
||||||
|
# Create invoice for deposit (20%)
|
||||||
|
deposit_invoice = InvoiceService.create_invoice_from_booking(
|
||||||
|
booking_id=booking.id,
|
||||||
|
db=db,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
tax_rate=tax_rate,
|
||||||
|
discount_amount=booking_discount,
|
||||||
|
due_days=30,
|
||||||
|
is_proforma=False,
|
||||||
|
invoice_amount=deposit_amount,
|
||||||
|
**invoice_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create proforma invoice for remaining amount (80%)
|
||||||
|
proforma_invoice = InvoiceService.create_invoice_from_booking(
|
||||||
|
booking_id=booking.id,
|
||||||
|
db=db,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
tax_rate=tax_rate,
|
||||||
|
discount_amount=booking_discount,
|
||||||
|
due_days=30,
|
||||||
|
is_proforma=True,
|
||||||
|
invoice_amount=remaining_amount,
|
||||||
|
**invoice_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send deposit invoice via email
|
||||||
|
try:
|
||||||
|
invoice_html = _generate_invoice_email_html(deposit_invoice, is_proforma=False)
|
||||||
|
await send_email(
|
||||||
|
to=current_user.email,
|
||||||
|
subject=f"Invoice {deposit_invoice['invoice_number']} - Deposit Payment",
|
||||||
|
html=invoice_html
|
||||||
|
)
|
||||||
|
logger.info(f"Deposit invoice sent to {current_user.email}")
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.error(f"Failed to send deposit invoice email: {str(email_error)}")
|
||||||
|
|
||||||
|
# Send proforma invoice via email
|
||||||
|
try:
|
||||||
|
proforma_html = _generate_invoice_email_html(proforma_invoice, is_proforma=True)
|
||||||
|
await send_email(
|
||||||
|
to=current_user.email,
|
||||||
|
subject=f"Proforma Invoice {proforma_invoice['invoice_number']} - Remaining Balance",
|
||||||
|
html=proforma_html
|
||||||
|
)
|
||||||
|
logger.info(f"Proforma invoice sent to {current_user.email}")
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.error(f"Failed to send proforma invoice email: {str(email_error)}")
|
||||||
|
else:
|
||||||
|
# For full payment (Stripe/PayPal): create full invoice
|
||||||
|
# Invoice will be created and sent after payment is confirmed
|
||||||
|
# We create it now as draft, and it will be updated when payment is confirmed
|
||||||
|
full_invoice = InvoiceService.create_invoice_from_booking(
|
||||||
|
booking_id=booking.id,
|
||||||
|
db=db,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
tax_rate=tax_rate,
|
||||||
|
discount_amount=booking_discount,
|
||||||
|
due_days=30,
|
||||||
|
is_proforma=False,
|
||||||
|
**invoice_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
# Don't send invoice email yet - will be sent after payment is confirmed
|
||||||
|
# The invoice will be updated and sent when payment is completed
|
||||||
|
logger.info(f"Invoice {full_invoice['invoice_number']} created for booking {booking.id} (will be sent after payment confirmation)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error but don't fail booking creation if invoice creation fails
|
# Log error but don't fail booking creation if invoice creation fails
|
||||||
import logging
|
import logging
|
||||||
@@ -511,32 +926,7 @@ async def create_booking(
|
|||||||
"capacity": booking.room.room_type.capacity,
|
"capacity": booking.room.room_type.capacity,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send booking confirmation email (non-blocking)
|
# Don't send email here - emails will be sent when booking is confirmed or cancelled
|
||||||
try:
|
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
|
||||||
room = db.query(Room).filter(Room.id == room_id).first()
|
|
||||||
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
|
||||||
|
|
||||||
email_html = booking_confirmation_email_template(
|
|
||||||
booking_number=booking.booking_number,
|
|
||||||
guest_name=current_user.full_name,
|
|
||||||
room_number=room.room_number if room else "N/A",
|
|
||||||
room_type=room_type_name,
|
|
||||||
check_in=check_in.strftime("%B %d, %Y"),
|
|
||||||
check_out=check_out.strftime("%B %d, %Y"),
|
|
||||||
num_guests=guest_count,
|
|
||||||
total_price=float(total_price),
|
|
||||||
requires_deposit=requires_deposit,
|
|
||||||
deposit_amount=deposit_amount if requires_deposit else None,
|
|
||||||
client_url=client_url
|
|
||||||
)
|
|
||||||
await send_email(
|
|
||||||
to=current_user.email,
|
|
||||||
subject=f"Booking Confirmation - {booking.booking_number}",
|
|
||||||
html=email_html
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to send booking confirmation email: {e}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -678,7 +1068,11 @@ async def get_booking_by_id(
|
|||||||
"id": p.id,
|
"id": p.id,
|
||||||
"amount": float(p.amount) if p.amount else 0.0,
|
"amount": float(p.amount) if p.amount else 0.0,
|
||||||
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||||
|
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||||
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
||||||
|
"transaction_id": p.transaction_id,
|
||||||
|
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
|
||||||
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
}
|
}
|
||||||
for p in booking.payments
|
for p in booking.payments
|
||||||
]
|
]
|
||||||
@@ -757,7 +1151,12 @@ async def cancel_booking(
|
|||||||
|
|
||||||
# Send cancellation email (non-blocking)
|
# Send cancellation email (non-blocking)
|
||||||
try:
|
try:
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
from ..models.system_settings import SystemSettings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
email_html = booking_status_changed_email_template(
|
email_html = booking_status_changed_email_template(
|
||||||
booking_number=booking.booking_number,
|
booking_number=booking.booking_number,
|
||||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||||
@@ -770,7 +1169,9 @@ async def cancel_booking(
|
|||||||
html=email_html
|
html=email_html
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send cancellation email: {e}")
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Failed to send cancellation email: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -792,7 +1193,10 @@ async def update_booking(
|
|||||||
):
|
):
|
||||||
"""Update booking status (Admin only)"""
|
"""Update booking status (Admin only)"""
|
||||||
try:
|
try:
|
||||||
booking = db.query(Booking).filter(Booking.id == id).first()
|
# Load booking with payments to check balance
|
||||||
|
booking = db.query(Booking).options(
|
||||||
|
selectinload(Booking.payments)
|
||||||
|
).filter(Booking.id == id).first()
|
||||||
if not booking:
|
if not booking:
|
||||||
raise HTTPException(status_code=404, detail="Booking not found")
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
|
||||||
@@ -807,29 +1211,105 @@ async def update_booking(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
# Send status change email if status changed (non-blocking)
|
# Check payment balance if status changed to checked_in
|
||||||
if status_value and old_status != booking.status:
|
payment_warning = None
|
||||||
try:
|
if status_value and old_status != booking.status and booking.status == BookingStatus.checked_in:
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
payment_balance = calculate_booking_payment_balance(booking)
|
||||||
email_html = booking_status_changed_email_template(
|
if payment_balance["remaining_balance"] > 0.01: # More than 1 cent remaining
|
||||||
booking_number=booking.booking_number,
|
payment_warning = {
|
||||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
"message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}",
|
||||||
status=booking.status.value,
|
"total_paid": payment_balance["total_paid"],
|
||||||
client_url=client_url
|
"total_price": payment_balance["total_price"],
|
||||||
)
|
"remaining_balance": payment_balance["remaining_balance"],
|
||||||
await send_email(
|
"payment_percentage": payment_balance["payment_percentage"]
|
||||||
to=booking.user.email if booking.user else None,
|
}
|
||||||
subject=f"Booking Status Updated - {booking.booking_number}",
|
|
||||||
html=email_html
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to send status change email: {e}")
|
|
||||||
|
|
||||||
return {
|
# Send status change email only if status changed to confirmed or cancelled (non-blocking)
|
||||||
|
if status_value and old_status != booking.status:
|
||||||
|
if booking.status in [BookingStatus.confirmed, BookingStatus.cancelled]:
|
||||||
|
try:
|
||||||
|
from ..models.system_settings import SystemSettings
|
||||||
|
from ..services.room_service import get_base_url
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
if booking.status == BookingStatus.confirmed:
|
||||||
|
# Send booking confirmation email with full details
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
booking_with_room = db.query(Booking).options(
|
||||||
|
selectinload(Booking.room).selectinload(Room.room_type)
|
||||||
|
).filter(Booking.id == booking.id).first()
|
||||||
|
|
||||||
|
room = booking_with_room.room if booking_with_room else None
|
||||||
|
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
|
|
||||||
|
email_html = booking_confirmation_email_template(
|
||||||
|
booking_number=booking.booking_number,
|
||||||
|
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||||
|
room_number=room.room_number if room else "N/A",
|
||||||
|
room_type=room_type_name,
|
||||||
|
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
|
||||||
|
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
|
||||||
|
num_guests=booking.num_guests,
|
||||||
|
total_price=float(booking.total_price),
|
||||||
|
requires_deposit=booking.requires_deposit,
|
||||||
|
deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None,
|
||||||
|
original_price=float(booking.original_price) if booking.original_price else None,
|
||||||
|
discount_amount=float(booking.discount_amount) if booking.discount_amount else None,
|
||||||
|
promotion_code=booking.promotion_code,
|
||||||
|
client_url=client_url,
|
||||||
|
currency_symbol=currency_symbol
|
||||||
|
)
|
||||||
|
await send_email(
|
||||||
|
to=booking.user.email if booking.user else None,
|
||||||
|
subject=f"Booking Confirmed - {booking.booking_number}",
|
||||||
|
html=email_html
|
||||||
|
)
|
||||||
|
elif booking.status == BookingStatus.cancelled:
|
||||||
|
# Send cancellation email
|
||||||
|
email_html = booking_status_changed_email_template(
|
||||||
|
booking_number=booking.booking_number,
|
||||||
|
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||||
|
status="cancelled",
|
||||||
|
client_url=client_url
|
||||||
|
)
|
||||||
|
await send_email(
|
||||||
|
to=booking.user.email if booking.user else None,
|
||||||
|
subject=f"Booking Cancelled - {booking.booking_number}",
|
||||||
|
html=email_html
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Failed to send status change email: {e}")
|
||||||
|
|
||||||
|
response_data = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Booking updated successfully",
|
"message": "Booking updated successfully",
|
||||||
"data": {"booking": booking}
|
"data": {"booking": booking}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add payment warning if there's remaining balance during check-in
|
||||||
|
if payment_warning:
|
||||||
|
response_data["warning"] = payment_warning
|
||||||
|
response_data["message"] = "Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance."
|
||||||
|
|
||||||
|
return response_data
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -844,30 +1324,126 @@ async def check_booking_by_number(
|
|||||||
):
|
):
|
||||||
"""Check booking by booking number"""
|
"""Check booking by booking number"""
|
||||||
try:
|
try:
|
||||||
booking = db.query(Booking).filter(Booking.booking_number == booking_number).first()
|
booking = db.query(Booking).options(
|
||||||
|
selectinload(Booking.payments),
|
||||||
|
joinedload(Booking.user),
|
||||||
|
joinedload(Booking.room).joinedload(Room.room_type)
|
||||||
|
).filter(Booking.booking_number == booking_number).first()
|
||||||
|
|
||||||
if not booking:
|
if not booking:
|
||||||
raise HTTPException(status_code=404, detail="Booking not found")
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
|
||||||
|
# Determine payment_method and payment_status from payments
|
||||||
|
payment_method_from_payments = None
|
||||||
|
payment_status_from_payments = "unpaid"
|
||||||
|
if booking.payments:
|
||||||
|
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||||
|
if isinstance(latest_payment.payment_method, PaymentMethod):
|
||||||
|
payment_method_from_payments = latest_payment.payment_method.value
|
||||||
|
elif hasattr(latest_payment.payment_method, 'value'):
|
||||||
|
payment_method_from_payments = latest_payment.payment_method.value
|
||||||
|
else:
|
||||||
|
payment_method_from_payments = str(latest_payment.payment_method)
|
||||||
|
|
||||||
|
if latest_payment.payment_status == PaymentStatus.completed:
|
||||||
|
payment_status_from_payments = "paid"
|
||||||
|
elif latest_payment.payment_status == PaymentStatus.refunded:
|
||||||
|
payment_status_from_payments = "refunded"
|
||||||
|
|
||||||
booking_dict = {
|
booking_dict = {
|
||||||
"id": booking.id,
|
"id": booking.id,
|
||||||
"booking_number": booking.booking_number,
|
"booking_number": booking.booking_number,
|
||||||
|
"user_id": booking.user_id,
|
||||||
"room_id": booking.room_id,
|
"room_id": booking.room_id,
|
||||||
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
||||||
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
||||||
|
"num_guests": booking.num_guests,
|
||||||
|
"guest_count": booking.num_guests,
|
||||||
|
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||||
|
"original_price": float(booking.original_price) if booking.original_price else None,
|
||||||
|
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
|
||||||
|
"promotion_code": booking.promotion_code,
|
||||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||||
|
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
|
||||||
|
"payment_status": payment_status_from_payments,
|
||||||
|
"deposit_paid": booking.deposit_paid,
|
||||||
|
"requires_deposit": booking.requires_deposit,
|
||||||
|
"special_requests": booking.special_requests,
|
||||||
|
"notes": booking.special_requests,
|
||||||
|
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||||
|
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||||
|
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||||
|
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add user info
|
||||||
|
if booking.user:
|
||||||
|
booking_dict["user"] = {
|
||||||
|
"id": booking.user.id,
|
||||||
|
"name": booking.user.full_name,
|
||||||
|
"full_name": booking.user.full_name,
|
||||||
|
"email": booking.user.email,
|
||||||
|
"phone": booking.user.phone,
|
||||||
|
"phone_number": booking.user.phone,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add room info
|
||||||
if booking.room:
|
if booking.room:
|
||||||
booking_dict["room"] = {
|
booking_dict["room"] = {
|
||||||
"id": booking.room.id,
|
"id": booking.room.id,
|
||||||
"room_number": booking.room.room_number,
|
"room_number": booking.room.room_number,
|
||||||
|
"floor": booking.room.floor,
|
||||||
}
|
}
|
||||||
|
if booking.room.room_type:
|
||||||
|
booking_dict["room"]["room_type"] = {
|
||||||
|
"id": booking.room.room_type.id,
|
||||||
|
"name": booking.room.room_type.name,
|
||||||
|
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
|
||||||
|
"capacity": booking.room.room_type.capacity,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
# Add payments
|
||||||
|
if booking.payments:
|
||||||
|
booking_dict["payments"] = [
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"amount": float(p.amount) if p.amount else 0.0,
|
||||||
|
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||||
|
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||||
|
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
||||||
|
"transaction_id": p.transaction_id,
|
||||||
|
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
|
||||||
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
|
}
|
||||||
|
for p in booking.payments
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
booking_dict["payments"] = []
|
||||||
|
|
||||||
|
# Calculate and add payment balance information
|
||||||
|
payment_balance = calculate_booking_payment_balance(booking)
|
||||||
|
booking_dict["payment_balance"] = {
|
||||||
|
"total_paid": payment_balance["total_paid"],
|
||||||
|
"total_price": payment_balance["total_price"],
|
||||||
|
"remaining_balance": payment_balance["remaining_balance"],
|
||||||
|
"is_fully_paid": payment_balance["is_fully_paid"],
|
||||||
|
"payment_percentage": payment_balance["payment_percentage"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add warning if there's remaining balance (useful for check-in)
|
||||||
|
response_data = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {"booking": booking_dict}
|
"data": {"booking": booking_dict}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if payment_balance["remaining_balance"] > 0.01:
|
||||||
|
response_data["warning"] = {
|
||||||
|
"message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}",
|
||||||
|
"remaining_balance": payment_balance["remaining_balance"],
|
||||||
|
"payment_percentage": payment_balance["payment_percentage"]
|
||||||
|
}
|
||||||
|
|
||||||
|
return response_data
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ class ContactForm(BaseModel):
|
|||||||
|
|
||||||
def get_admin_email(db: Session) -> str:
|
def get_admin_email(db: Session) -> str:
|
||||||
"""Get admin email from system settings or find admin user"""
|
"""Get admin email from system settings or find admin user"""
|
||||||
# First, try to get from system settings
|
# First, try to get from company_email (company settings)
|
||||||
|
company_email_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "company_email"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if company_email_setting and company_email_setting.value:
|
||||||
|
return company_email_setting.value
|
||||||
|
|
||||||
|
# Second, try to get from admin_email (legacy setting)
|
||||||
admin_email_setting = db.query(SystemSettings).filter(
|
admin_email_setting = db.query(SystemSettings).filter(
|
||||||
SystemSettings.key == "admin_email"
|
SystemSettings.key == "admin_email"
|
||||||
).first()
|
).first()
|
||||||
@@ -52,7 +60,7 @@ def get_admin_email(db: Session) -> str:
|
|||||||
# Last resort: raise error
|
# Last resort: raise error
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="Admin email not configured. Please set admin_email in system settings or ensure an admin user exists."
|
detail="Admin email not configured. Please set company_email in system settings or ensure an admin user exists."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
@@ -11,13 +11,51 @@ from ..models.user import User
|
|||||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||||
from ..models.booking import Booking, BookingStatus
|
from ..models.booking import Booking, BookingStatus
|
||||||
from ..utils.mailer import send_email
|
from ..utils.mailer import send_email
|
||||||
from ..utils.email_templates import payment_confirmation_email_template
|
from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
|
||||||
from ..services.stripe_service import StripeService
|
from ..services.stripe_service import StripeService
|
||||||
from ..services.paypal_service import PayPalService
|
from ..services.paypal_service import PayPalService
|
||||||
|
|
||||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str = "Payment failed or canceled"):
|
||||||
|
"""
|
||||||
|
Helper function to cancel a booking when payment fails or is canceled.
|
||||||
|
This bypasses the normal cancellation restrictions and sends cancellation email.
|
||||||
|
"""
|
||||||
|
if booking.status == BookingStatus.cancelled:
|
||||||
|
return # Already cancelled
|
||||||
|
|
||||||
|
booking.status = BookingStatus.cancelled
|
||||||
|
db.commit()
|
||||||
|
db.refresh(booking)
|
||||||
|
|
||||||
|
# Send cancellation email (non-blocking)
|
||||||
|
try:
|
||||||
|
from ..models.system_settings import SystemSettings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
if booking.user:
|
||||||
|
email_html = booking_status_changed_email_template(
|
||||||
|
booking_number=booking.booking_number,
|
||||||
|
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||||
|
status="cancelled",
|
||||||
|
client_url=client_url
|
||||||
|
)
|
||||||
|
await send_email(
|
||||||
|
to=booking.user.email,
|
||||||
|
subject=f"Booking Cancelled - {booking.booking_number}",
|
||||||
|
html=email_html
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Failed to send cancellation email: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_payments(
|
async def get_payments(
|
||||||
booking_id: Optional[int] = Query(None),
|
booking_id: Optional[int] = Query(None),
|
||||||
@@ -29,11 +67,11 @@ async def get_payments(
|
|||||||
):
|
):
|
||||||
"""Get all payments"""
|
"""Get all payments"""
|
||||||
try:
|
try:
|
||||||
query = db.query(Payment)
|
# Build base query
|
||||||
|
|
||||||
# Filter by booking_id
|
|
||||||
if booking_id:
|
if booking_id:
|
||||||
query = query.filter(Payment.booking_id == booking_id)
|
query = db.query(Payment).filter(Payment.booking_id == booking_id)
|
||||||
|
else:
|
||||||
|
query = db.query(Payment)
|
||||||
|
|
||||||
# Filter by status
|
# Filter by status
|
||||||
if status_filter:
|
if status_filter:
|
||||||
@@ -46,7 +84,14 @@ async def get_payments(
|
|||||||
if current_user.role_id != 1: # Not admin
|
if current_user.role_id != 1: # Not admin
|
||||||
query = query.join(Booking).filter(Booking.user_id == current_user.id)
|
query = query.join(Booking).filter(Booking.user_id == current_user.id)
|
||||||
|
|
||||||
|
# Get total count before applying eager loading
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|
||||||
|
# Load payments with booking and user relationships using selectinload to avoid join conflicts
|
||||||
|
query = query.options(
|
||||||
|
selectinload(Payment.booking).selectinload(Booking.user)
|
||||||
|
)
|
||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
|
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
@@ -72,6 +117,14 @@ async def get_payments(
|
|||||||
"id": payment.booking.id,
|
"id": payment.booking.id,
|
||||||
"booking_number": payment.booking.booking_number,
|
"booking_number": payment.booking.booking_number,
|
||||||
}
|
}
|
||||||
|
# Include user information if available
|
||||||
|
if payment.booking.user:
|
||||||
|
payment_dict["booking"]["user"] = {
|
||||||
|
"id": payment.booking.user.id,
|
||||||
|
"name": payment.booking.user.full_name,
|
||||||
|
"full_name": payment.booking.user.full_name,
|
||||||
|
"email": payment.booking.user.email,
|
||||||
|
}
|
||||||
|
|
||||||
result.append(payment_dict)
|
result.append(payment_dict)
|
||||||
|
|
||||||
@@ -87,8 +140,13 @@ async def get_payments(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error fetching payments: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching payments: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/booking/{booking_id}")
|
@router.get("/booking/{booking_id}")
|
||||||
@@ -108,8 +166,10 @@ async def get_payments_by_booking_id(
|
|||||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
# Get all payments for this booking
|
# Get all payments for this booking with user relationship
|
||||||
payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
|
payments = db.query(Payment).options(
|
||||||
|
joinedload(Payment.booking).joinedload(Booking.user)
|
||||||
|
).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for payment in payments:
|
for payment in payments:
|
||||||
@@ -133,6 +193,14 @@ async def get_payments_by_booking_id(
|
|||||||
"id": payment.booking.id,
|
"id": payment.booking.id,
|
||||||
"booking_number": payment.booking.booking_number,
|
"booking_number": payment.booking.booking_number,
|
||||||
}
|
}
|
||||||
|
# Include user information if available
|
||||||
|
if payment.booking.user:
|
||||||
|
payment_dict["booking"]["user"] = {
|
||||||
|
"id": payment.booking.user.id,
|
||||||
|
"name": payment.booking.user.full_name,
|
||||||
|
"full_name": payment.booking.user.full_name,
|
||||||
|
"email": payment.booking.user.email,
|
||||||
|
}
|
||||||
|
|
||||||
result.append(payment_dict)
|
result.append(payment_dict)
|
||||||
|
|
||||||
@@ -241,14 +309,34 @@ async def create_payment(
|
|||||||
# Send payment confirmation email if payment was marked as paid (non-blocking)
|
# Send payment confirmation email if payment was marked as paid (non-blocking)
|
||||||
if payment.payment_status == PaymentStatus.completed and booking.user:
|
if payment.payment_status == PaymentStatus.completed and booking.user:
|
||||||
try:
|
try:
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
from ..models.system_settings import SystemSettings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
|
|
||||||
email_html = payment_confirmation_email_template(
|
email_html = payment_confirmation_email_template(
|
||||||
booking_number=booking.booking_number,
|
booking_number=booking.booking_number,
|
||||||
guest_name=booking.user.full_name,
|
guest_name=booking.user.full_name,
|
||||||
amount=float(payment.amount),
|
amount=float(payment.amount),
|
||||||
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
||||||
transaction_id=payment.transaction_id,
|
transaction_id=payment.transaction_id,
|
||||||
client_url=client_url
|
payment_type=payment.payment_type.value if payment.payment_type else None,
|
||||||
|
total_price=float(booking.total_price),
|
||||||
|
client_url=client_url,
|
||||||
|
currency_symbol=currency_symbol
|
||||||
)
|
)
|
||||||
await send_email(
|
await send_email(
|
||||||
to=booking.user.email,
|
to=booking.user.email,
|
||||||
@@ -256,7 +344,9 @@ async def create_payment(
|
|||||||
html=email_html
|
html=email_html
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send payment confirmation email: {e}")
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Failed to send payment confirmation email: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -284,16 +374,28 @@ async def update_payment_status(
|
|||||||
raise HTTPException(status_code=404, detail="Payment not found")
|
raise HTTPException(status_code=404, detail="Payment not found")
|
||||||
|
|
||||||
status_value = status_data.get("status")
|
status_value = status_data.get("status")
|
||||||
|
old_status = payment.payment_status
|
||||||
|
|
||||||
if status_value:
|
if status_value:
|
||||||
try:
|
try:
|
||||||
payment.payment_status = PaymentStatus(status_value)
|
new_status = PaymentStatus(status_value)
|
||||||
|
payment.payment_status = new_status
|
||||||
|
|
||||||
|
# Auto-cancel booking if payment is marked as failed or refunded
|
||||||
|
if new_status in [PaymentStatus.failed, PaymentStatus.refunded]:
|
||||||
|
booking = db.query(Booking).filter(Booking.id == payment.booking_id).first()
|
||||||
|
if booking and booking.status != BookingStatus.cancelled:
|
||||||
|
await cancel_booking_on_payment_failure(
|
||||||
|
booking,
|
||||||
|
db,
|
||||||
|
reason=f"Payment {new_status.value}"
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Invalid payment status")
|
raise HTTPException(status_code=400, detail="Invalid payment status")
|
||||||
|
|
||||||
if status_data.get("transaction_id"):
|
if status_data.get("transaction_id"):
|
||||||
payment.transaction_id = status_data["transaction_id"]
|
payment.transaction_id = status_data["transaction_id"]
|
||||||
|
|
||||||
old_status = payment.payment_status
|
|
||||||
if status_data.get("mark_as_paid"):
|
if status_data.get("mark_as_paid"):
|
||||||
payment.payment_status = PaymentStatus.completed
|
payment.payment_status = PaymentStatus.completed
|
||||||
payment.payment_date = datetime.utcnow()
|
payment.payment_date = datetime.utcnow()
|
||||||
@@ -304,17 +406,50 @@ async def update_payment_status(
|
|||||||
# Send payment confirmation email if payment was just completed (non-blocking)
|
# Send payment confirmation email if payment was just completed (non-blocking)
|
||||||
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
|
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
|
||||||
try:
|
try:
|
||||||
|
from ..models.system_settings import SystemSettings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
# Refresh booking relationship
|
# Refresh booking relationship
|
||||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||||
if payment.booking and payment.booking.user:
|
if payment.booking and payment.booking.user:
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
|
|
||||||
email_html = payment_confirmation_email_template(
|
email_html = payment_confirmation_email_template(
|
||||||
booking_number=payment.booking.booking_number,
|
booking_number=payment.booking.booking_number,
|
||||||
guest_name=payment.booking.user.full_name,
|
guest_name=payment.booking.user.full_name,
|
||||||
amount=float(payment.amount),
|
amount=float(payment.amount),
|
||||||
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
||||||
transaction_id=payment.transaction_id,
|
transaction_id=payment.transaction_id,
|
||||||
client_url=client_url
|
client_url=client_url,
|
||||||
|
currency_symbol=currency_symbol
|
||||||
)
|
)
|
||||||
await send_email(
|
await send_email(
|
||||||
to=payment.booking.user.email,
|
to=payment.booking.user.email,
|
||||||
@@ -325,10 +460,22 @@ async def update_payment_status(
|
|||||||
# If this is a deposit payment, update booking deposit_paid status
|
# If this is a deposit payment, update booking deposit_paid status
|
||||||
if payment.payment_type == PaymentType.deposit and payment.booking:
|
if payment.payment_type == PaymentType.deposit and payment.booking:
|
||||||
payment.booking.deposit_paid = True
|
payment.booking.deposit_paid = True
|
||||||
# Optionally auto-confirm booking if deposit is paid
|
# Restore cancelled bookings or confirm pending bookings when deposit is paid
|
||||||
if payment.booking.status == BookingStatus.pending:
|
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||||
payment.booking.status = BookingStatus.confirmed
|
payment.booking.status = BookingStatus.confirmed
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# If this is a full payment, also restore cancelled bookings
|
||||||
|
elif payment.payment_type == PaymentType.full and payment.booking:
|
||||||
|
# Calculate total paid from all completed payments
|
||||||
|
total_paid = sum(
|
||||||
|
float(p.amount) for p in payment.booking.payments
|
||||||
|
if p.payment_status == PaymentStatus.completed
|
||||||
|
)
|
||||||
|
# Confirm booking if fully paid, and restore cancelled bookings
|
||||||
|
if total_paid >= float(payment.booking.total_price):
|
||||||
|
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||||
|
payment.booking.status = BookingStatus.confirmed
|
||||||
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send payment confirmation email: {e}")
|
print(f"Failed to send payment confirmation email: {e}")
|
||||||
|
|
||||||
@@ -395,6 +542,29 @@ async def create_stripe_payment_intent(
|
|||||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
# For deposit payments, verify the amount matches the deposit payment record
|
||||||
|
# This ensures users are only charged the deposit (20%) and not the full amount
|
||||||
|
if booking.requires_deposit and not booking.deposit_paid:
|
||||||
|
deposit_payment = db.query(Payment).filter(
|
||||||
|
Payment.booking_id == booking_id,
|
||||||
|
Payment.payment_type == PaymentType.deposit,
|
||||||
|
Payment.payment_status == PaymentStatus.pending
|
||||||
|
).order_by(Payment.created_at.desc()).first()
|
||||||
|
|
||||||
|
if deposit_payment:
|
||||||
|
expected_deposit_amount = float(deposit_payment.amount)
|
||||||
|
# Allow small floating point differences (0.01)
|
||||||
|
if abs(amount - expected_deposit_amount) > 0.01:
|
||||||
|
logger.warning(
|
||||||
|
f"Amount mismatch for deposit payment: "
|
||||||
|
f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, "
|
||||||
|
f"Booking total ${float(booking.total_price):,.2f}"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})."
|
||||||
|
)
|
||||||
|
|
||||||
# Create payment intent
|
# Create payment intent
|
||||||
intent = StripeService.create_payment_intent(
|
intent = StripeService.create_payment_intent(
|
||||||
amount=amount,
|
amount=amount,
|
||||||
@@ -472,7 +642,7 @@ async def confirm_stripe_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Confirm payment (this commits the transaction internally)
|
# Confirm payment (this commits the transaction internally)
|
||||||
payment = StripeService.confirm_payment(
|
payment = await StripeService.confirm_payment(
|
||||||
payment_intent_id=payment_intent_id,
|
payment_intent_id=payment_intent_id,
|
||||||
db=db,
|
db=db,
|
||||||
booking_id=booking_id
|
booking_id=booking_id
|
||||||
@@ -495,14 +665,34 @@ async def confirm_stripe_payment(
|
|||||||
# This won't affect the transaction since it's already committed
|
# This won't affect the transaction since it's already committed
|
||||||
if booking and booking.user:
|
if booking and booking.user:
|
||||||
try:
|
try:
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
from ..models.system_settings import SystemSettings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
|
|
||||||
email_html = payment_confirmation_email_template(
|
email_html = payment_confirmation_email_template(
|
||||||
booking_number=booking.booking_number,
|
booking_number=booking.booking_number,
|
||||||
guest_name=booking.user.full_name,
|
guest_name=booking.user.full_name,
|
||||||
amount=payment["amount"],
|
amount=payment["amount"],
|
||||||
payment_method="stripe",
|
payment_method="stripe",
|
||||||
transaction_id=payment["transaction_id"],
|
transaction_id=payment["transaction_id"],
|
||||||
client_url=client_url
|
payment_type=payment.get("payment_type"),
|
||||||
|
total_price=float(booking.total_price),
|
||||||
|
client_url=client_url,
|
||||||
|
currency_symbol=currency_symbol
|
||||||
)
|
)
|
||||||
await send_email(
|
await send_email(
|
||||||
to=booking.user.email,
|
to=booking.user.email,
|
||||||
@@ -574,7 +764,7 @@ async def stripe_webhook(
|
|||||||
detail="Missing stripe-signature header"
|
detail="Missing stripe-signature header"
|
||||||
)
|
)
|
||||||
|
|
||||||
result = StripeService.handle_webhook(
|
result = await StripeService.handle_webhook(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
db=db
|
db=db
|
||||||
@@ -640,6 +830,31 @@ async def create_paypal_order(
|
|||||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
# For deposit payments, verify the amount matches the deposit payment record
|
||||||
|
# This ensures users are only charged the deposit (20%) and not the full amount
|
||||||
|
if booking.requires_deposit and not booking.deposit_paid:
|
||||||
|
deposit_payment = db.query(Payment).filter(
|
||||||
|
Payment.booking_id == booking_id,
|
||||||
|
Payment.payment_type == PaymentType.deposit,
|
||||||
|
Payment.payment_status == PaymentStatus.pending
|
||||||
|
).order_by(Payment.created_at.desc()).first()
|
||||||
|
|
||||||
|
if deposit_payment:
|
||||||
|
expected_deposit_amount = float(deposit_payment.amount)
|
||||||
|
# Allow small floating point differences (0.01)
|
||||||
|
if abs(amount - expected_deposit_amount) > 0.01:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning(
|
||||||
|
f"Amount mismatch for deposit payment: "
|
||||||
|
f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, "
|
||||||
|
f"Booking total ${float(booking.total_price):,.2f}"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})."
|
||||||
|
)
|
||||||
|
|
||||||
# Get return URLs from request or use defaults
|
# Get return URLs from request or use defaults
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||||
return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return")
|
return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return")
|
||||||
@@ -689,6 +904,63 @@ async def create_paypal_order(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/paypal/cancel")
|
||||||
|
async def cancel_paypal_payment(
|
||||||
|
payment_data: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Mark PayPal payment as failed and cancel booking when user cancels on PayPal"""
|
||||||
|
try:
|
||||||
|
booking_id = payment_data.get("booking_id")
|
||||||
|
|
||||||
|
if not booking_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="booking_id is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find pending PayPal payment for this booking
|
||||||
|
payment = db.query(Payment).filter(
|
||||||
|
Payment.booking_id == booking_id,
|
||||||
|
Payment.payment_method == PaymentMethod.paypal,
|
||||||
|
Payment.payment_status == PaymentStatus.pending
|
||||||
|
).order_by(Payment.created_at.desc()).first()
|
||||||
|
|
||||||
|
# Also check for deposit payments
|
||||||
|
if not payment:
|
||||||
|
payment = db.query(Payment).filter(
|
||||||
|
Payment.booking_id == booking_id,
|
||||||
|
Payment.payment_type == PaymentType.deposit,
|
||||||
|
Payment.payment_status == PaymentStatus.pending
|
||||||
|
).order_by(Payment.created_at.desc()).first()
|
||||||
|
|
||||||
|
if payment:
|
||||||
|
payment.payment_status = PaymentStatus.failed
|
||||||
|
db.commit()
|
||||||
|
db.refresh(payment)
|
||||||
|
|
||||||
|
# Auto-cancel booking
|
||||||
|
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||||
|
if booking and booking.status != BookingStatus.cancelled:
|
||||||
|
await cancel_booking_on_payment_failure(
|
||||||
|
booking,
|
||||||
|
db,
|
||||||
|
reason="PayPal payment canceled by user"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Payment canceled and booking cancelled"
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/paypal/capture")
|
@router.post("/paypal/capture")
|
||||||
async def capture_paypal_payment(
|
async def capture_paypal_payment(
|
||||||
payment_data: dict,
|
payment_data: dict,
|
||||||
@@ -707,7 +979,7 @@ async def capture_paypal_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Confirm payment (this commits the transaction internally)
|
# Confirm payment (this commits the transaction internally)
|
||||||
payment = PayPalService.confirm_payment(
|
payment = await PayPalService.confirm_payment(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
db=db,
|
db=db,
|
||||||
booking_id=booking_id
|
booking_id=booking_id
|
||||||
@@ -727,14 +999,34 @@ async def capture_paypal_payment(
|
|||||||
# Send payment confirmation email (non-blocking)
|
# Send payment confirmation email (non-blocking)
|
||||||
if booking and booking.user:
|
if booking and booking.user:
|
||||||
try:
|
try:
|
||||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
from ..models.system_settings import SystemSettings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
|
|
||||||
email_html = payment_confirmation_email_template(
|
email_html = payment_confirmation_email_template(
|
||||||
booking_number=booking.booking_number,
|
booking_number=booking.booking_number,
|
||||||
guest_name=booking.user.full_name,
|
guest_name=booking.user.full_name,
|
||||||
amount=payment["amount"],
|
amount=payment["amount"],
|
||||||
payment_method="paypal",
|
payment_method="paypal",
|
||||||
transaction_id=payment["transaction_id"],
|
transaction_id=payment["transaction_id"],
|
||||||
client_url=client_url
|
payment_type=payment.get("payment_type"),
|
||||||
|
total_price=float(booking.total_price),
|
||||||
|
client_url=client_url,
|
||||||
|
currency_symbol=currency_symbol
|
||||||
)
|
)
|
||||||
await send_email(
|
await send_email(
|
||||||
to=booking.user.email,
|
to=booking.user.email,
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ async def validate_promotion(
|
|||||||
"""Validate and apply promotion"""
|
"""Validate and apply promotion"""
|
||||||
try:
|
try:
|
||||||
code = validation_data.get("code")
|
code = validation_data.get("code")
|
||||||
booking_amount = float(validation_data.get("booking_amount", 0))
|
# Accept both booking_value (from frontend) and booking_amount (for backward compatibility)
|
||||||
|
booking_amount = float(validation_data.get("booking_value") or validation_data.get("booking_amount", 0))
|
||||||
|
|
||||||
promotion = db.query(Promotion).filter(Promotion.code == code).first()
|
promotion = db.query(Promotion).filter(Promotion.code == code).first()
|
||||||
if not promotion:
|
if not promotion:
|
||||||
@@ -161,17 +162,30 @@ async def validate_promotion(
|
|||||||
final_amount = booking_amount - discount_amount
|
final_amount = booking_amount - discount_amount
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"success": True,
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"promotion": {
|
"promotion": {
|
||||||
"id": promotion.id,
|
"id": promotion.id,
|
||||||
"code": promotion.code,
|
"code": promotion.code,
|
||||||
"name": promotion.name,
|
"name": promotion.name,
|
||||||
|
"description": promotion.description,
|
||||||
|
"discount_type": promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type),
|
||||||
|
"discount_value": float(promotion.discount_value) if promotion.discount_value else 0,
|
||||||
|
"min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None,
|
||||||
|
"max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None,
|
||||||
|
"start_date": promotion.start_date.isoformat() if promotion.start_date else None,
|
||||||
|
"end_date": promotion.end_date.isoformat() if promotion.end_date else None,
|
||||||
|
"usage_limit": promotion.usage_limit,
|
||||||
|
"used_count": promotion.used_count,
|
||||||
|
"status": "active" if promotion.is_active else "inactive",
|
||||||
},
|
},
|
||||||
|
"discount": discount_amount,
|
||||||
"original_amount": booking_amount,
|
"original_amount": booking_amount,
|
||||||
"discount_amount": discount_amount,
|
"discount_amount": discount_amount,
|
||||||
"final_amount": final_amount,
|
"final_amount": final_amount,
|
||||||
}
|
},
|
||||||
|
"message": "Promotion validated successfully"
|
||||||
}
|
}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -895,6 +895,7 @@ class UpdateCompanySettingsRequest(BaseModel):
|
|||||||
company_phone: Optional[str] = None
|
company_phone: Optional[str] = None
|
||||||
company_email: Optional[str] = None
|
company_email: Optional[str] = None
|
||||||
company_address: Optional[str] = None
|
company_address: Optional[str] = None
|
||||||
|
tax_rate: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/company")
|
@router.get("/company")
|
||||||
@@ -911,6 +912,7 @@ async def get_company_settings(
|
|||||||
"company_phone",
|
"company_phone",
|
||||||
"company_email",
|
"company_email",
|
||||||
"company_address",
|
"company_address",
|
||||||
|
"tax_rate",
|
||||||
]
|
]
|
||||||
|
|
||||||
settings_dict = {}
|
settings_dict = {}
|
||||||
@@ -944,6 +946,7 @@ async def get_company_settings(
|
|||||||
"company_phone": settings_dict.get("company_phone", ""),
|
"company_phone": settings_dict.get("company_phone", ""),
|
||||||
"company_email": settings_dict.get("company_email", ""),
|
"company_email": settings_dict.get("company_email", ""),
|
||||||
"company_address": settings_dict.get("company_address", ""),
|
"company_address": settings_dict.get("company_address", ""),
|
||||||
|
"tax_rate": float(settings_dict.get("tax_rate", 0)) if settings_dict.get("tax_rate") else 0.0,
|
||||||
"updated_at": updated_at,
|
"updated_at": updated_at,
|
||||||
"updated_by": updated_by,
|
"updated_by": updated_by,
|
||||||
}
|
}
|
||||||
@@ -972,6 +975,8 @@ async def update_company_settings(
|
|||||||
db_settings["company_email"] = request_data.company_email
|
db_settings["company_email"] = request_data.company_email
|
||||||
if request_data.company_address is not None:
|
if request_data.company_address is not None:
|
||||||
db_settings["company_address"] = request_data.company_address
|
db_settings["company_address"] = request_data.company_address
|
||||||
|
if request_data.tax_rate is not None:
|
||||||
|
db_settings["tax_rate"] = str(request_data.tax_rate)
|
||||||
|
|
||||||
for key, value in db_settings.items():
|
for key, value in db_settings.items():
|
||||||
# Find or create setting
|
# Find or create setting
|
||||||
@@ -997,7 +1002,7 @@ async def update_company_settings(
|
|||||||
|
|
||||||
# Get updated settings
|
# Get updated settings
|
||||||
updated_settings = {}
|
updated_settings = {}
|
||||||
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address"]:
|
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate"]:
|
||||||
setting = db.query(SystemSettings).filter(
|
setting = db.query(SystemSettings).filter(
|
||||||
SystemSettings.key == key
|
SystemSettings.key == key
|
||||||
).first()
|
).first()
|
||||||
@@ -1032,6 +1037,7 @@ async def update_company_settings(
|
|||||||
"company_phone": updated_settings.get("company_phone", ""),
|
"company_phone": updated_settings.get("company_phone", ""),
|
||||||
"company_email": updated_settings.get("company_email", ""),
|
"company_email": updated_settings.get("company_email", ""),
|
||||||
"company_address": updated_settings.get("company_address", ""),
|
"company_address": updated_settings.get("company_address", ""),
|
||||||
|
"tax_rate": float(updated_settings.get("tax_rate", 0)) if updated_settings.get("tax_rate") else 0.0,
|
||||||
"updated_at": updated_at,
|
"updated_at": updated_at,
|
||||||
"updated_by": updated_by,
|
"updated_by": updated_by,
|
||||||
}
|
}
|
||||||
@@ -1243,3 +1249,272 @@ async def upload_company_favicon(
|
|||||||
logger.error(f"Error uploading favicon: {e}", exc_info=True)
|
logger.error(f"Error uploading favicon: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recaptcha")
|
||||||
|
async def get_recaptcha_settings(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get reCAPTCHA settings (Public endpoint for frontend)"""
|
||||||
|
try:
|
||||||
|
site_key_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_site_key"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
enabled_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_enabled"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"recaptcha_site_key": "",
|
||||||
|
"recaptcha_enabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if site_key_setting:
|
||||||
|
result["recaptcha_site_key"] = site_key_setting.value or ""
|
||||||
|
|
||||||
|
if enabled_setting:
|
||||||
|
result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": result
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recaptcha/admin")
|
||||||
|
async def get_recaptcha_settings_admin(
|
||||||
|
current_user: User = Depends(authorize_roles("admin")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get reCAPTCHA settings (Admin only - includes secret key)"""
|
||||||
|
try:
|
||||||
|
site_key_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_site_key"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
secret_key_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_secret_key"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
enabled_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_enabled"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Mask secret for security (only show last 4 characters)
|
||||||
|
def mask_key(key_value: str) -> str:
|
||||||
|
if not key_value or len(key_value) < 4:
|
||||||
|
return ""
|
||||||
|
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"recaptcha_site_key": "",
|
||||||
|
"recaptcha_secret_key": "",
|
||||||
|
"recaptcha_secret_key_masked": "",
|
||||||
|
"recaptcha_enabled": False,
|
||||||
|
"has_site_key": False,
|
||||||
|
"has_secret_key": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if site_key_setting:
|
||||||
|
result["recaptcha_site_key"] = site_key_setting.value or ""
|
||||||
|
result["has_site_key"] = bool(site_key_setting.value)
|
||||||
|
result["updated_at"] = site_key_setting.updated_at.isoformat() if site_key_setting.updated_at else None
|
||||||
|
result["updated_by"] = site_key_setting.updated_by.full_name if site_key_setting.updated_by else None
|
||||||
|
|
||||||
|
if secret_key_setting:
|
||||||
|
result["recaptcha_secret_key"] = secret_key_setting.value or ""
|
||||||
|
result["recaptcha_secret_key_masked"] = mask_key(secret_key_setting.value or "")
|
||||||
|
result["has_secret_key"] = bool(secret_key_setting.value)
|
||||||
|
|
||||||
|
if enabled_setting:
|
||||||
|
result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": result
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/recaptcha")
|
||||||
|
async def update_recaptcha_settings(
|
||||||
|
recaptcha_data: dict,
|
||||||
|
current_user: User = Depends(authorize_roles("admin")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update reCAPTCHA settings (Admin only)"""
|
||||||
|
try:
|
||||||
|
site_key = recaptcha_data.get("recaptcha_site_key", "").strip()
|
||||||
|
secret_key = recaptcha_data.get("recaptcha_secret_key", "").strip()
|
||||||
|
enabled = recaptcha_data.get("recaptcha_enabled", False)
|
||||||
|
|
||||||
|
# Update or create site key setting
|
||||||
|
if site_key:
|
||||||
|
setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_site_key"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if setting:
|
||||||
|
setting.value = site_key
|
||||||
|
setting.updated_by_id = current_user.id
|
||||||
|
else:
|
||||||
|
setting = SystemSettings(
|
||||||
|
key="recaptcha_site_key",
|
||||||
|
value=site_key,
|
||||||
|
description="Google reCAPTCHA site key for frontend",
|
||||||
|
updated_by_id=current_user.id
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
# Update or create secret key setting
|
||||||
|
if secret_key:
|
||||||
|
setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_secret_key"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if setting:
|
||||||
|
setting.value = secret_key
|
||||||
|
setting.updated_by_id = current_user.id
|
||||||
|
else:
|
||||||
|
setting = SystemSettings(
|
||||||
|
key="recaptcha_secret_key",
|
||||||
|
value=secret_key,
|
||||||
|
description="Google reCAPTCHA secret key for backend verification",
|
||||||
|
updated_by_id=current_user.id
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
# Update or create enabled setting
|
||||||
|
setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_enabled"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if setting:
|
||||||
|
setting.value = str(enabled).lower()
|
||||||
|
setting.updated_by_id = current_user.id
|
||||||
|
else:
|
||||||
|
setting = SystemSettings(
|
||||||
|
key="recaptcha_enabled",
|
||||||
|
value=str(enabled).lower(),
|
||||||
|
description="Enable or disable reCAPTCHA verification",
|
||||||
|
updated_by_id=current_user.id
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Return masked values
|
||||||
|
def mask_key(key_value: str) -> str:
|
||||||
|
if not key_value or len(key_value) < 4:
|
||||||
|
return ""
|
||||||
|
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "reCAPTCHA settings updated successfully",
|
||||||
|
"data": {
|
||||||
|
"recaptcha_site_key": site_key if site_key else "",
|
||||||
|
"recaptcha_secret_key": secret_key if secret_key else "",
|
||||||
|
"recaptcha_secret_key_masked": mask_key(secret_key) if secret_key else "",
|
||||||
|
"recaptcha_enabled": enabled,
|
||||||
|
"has_site_key": bool(site_key),
|
||||||
|
"has_secret_key": bool(secret_key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recaptcha/verify")
|
||||||
|
async def verify_recaptcha(
|
||||||
|
verification_data: dict,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Verify reCAPTCHA token (Public endpoint)"""
|
||||||
|
try:
|
||||||
|
token = verification_data.get("token", "").strip()
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="reCAPTCHA token is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get reCAPTCHA settings
|
||||||
|
enabled_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_enabled"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
secret_key_setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.key == "recaptcha_secret_key"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Check if reCAPTCHA is enabled
|
||||||
|
is_enabled = False
|
||||||
|
if enabled_setting:
|
||||||
|
is_enabled = enabled_setting.value.lower() == "true" if enabled_setting.value else False
|
||||||
|
|
||||||
|
if not is_enabled:
|
||||||
|
# If disabled, always return success
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"verified": True,
|
||||||
|
"message": "reCAPTCHA is disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if not secret_key_setting or not secret_key_setting.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="reCAPTCHA secret key is not configured"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify with Google reCAPTCHA API
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
"https://www.google.com/recaptcha/api/siteverify",
|
||||||
|
data={
|
||||||
|
"secret": secret_key_setting.value,
|
||||||
|
"response": token
|
||||||
|
},
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"verified": True,
|
||||||
|
"score": result.get("score"), # For v3
|
||||||
|
"action": result.get("action") # For v3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_codes = result.get("error-codes", [])
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"data": {
|
||||||
|
"verified": False,
|
||||||
|
"error_codes": error_codes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=408,
|
||||||
|
detail="reCAPTCHA verification timeout"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying reCAPTCHA: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
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
|
from ..models.user import User
|
||||||
|
|
||||||
|
|
||||||
def generate_invoice_number(db: Session) -> str:
|
def generate_invoice_number(db: Session, is_proforma: bool = False) -> str:
|
||||||
"""Generate a unique invoice number"""
|
"""Generate a unique invoice number"""
|
||||||
# Format: INV-YYYYMMDD-XXXX
|
# Format: INV-YYYYMMDD-XXXX or PRO-YYYYMMDD-XXXX for proforma
|
||||||
|
prefix = "PRO" if is_proforma else "INV"
|
||||||
today = datetime.utcnow().strftime("%Y%m%d")
|
today = datetime.utcnow().strftime("%Y%m%d")
|
||||||
|
|
||||||
# Get the last invoice number for today
|
# Get the last invoice number for today
|
||||||
last_invoice = db.query(Invoice).filter(
|
last_invoice = db.query(Invoice).filter(
|
||||||
Invoice.invoice_number.like(f"INV-{today}-%")
|
Invoice.invoice_number.like(f"{prefix}-{today}-%")
|
||||||
).order_by(Invoice.invoice_number.desc()).first()
|
).order_by(Invoice.invoice_number.desc()).first()
|
||||||
|
|
||||||
if last_invoice:
|
if last_invoice:
|
||||||
@@ -31,7 +32,7 @@ def generate_invoice_number(db: Session) -> str:
|
|||||||
else:
|
else:
|
||||||
sequence = 1
|
sequence = 1
|
||||||
|
|
||||||
return f"INV-{today}-{sequence:04d}"
|
return f"{prefix}-{today}-{sequence:04d}"
|
||||||
|
|
||||||
|
|
||||||
class InvoiceService:
|
class InvoiceService:
|
||||||
@@ -45,6 +46,8 @@ class InvoiceService:
|
|||||||
tax_rate: float = 0.0,
|
tax_rate: float = 0.0,
|
||||||
discount_amount: float = 0.0,
|
discount_amount: float = 0.0,
|
||||||
due_days: int = 30,
|
due_days: int = 30,
|
||||||
|
is_proforma: bool = False,
|
||||||
|
invoice_amount: Optional[float] = None, # For partial invoices (e.g., deposit)
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -77,11 +80,17 @@ class InvoiceService:
|
|||||||
raise ValueError("User not found")
|
raise ValueError("User not found")
|
||||||
|
|
||||||
# Generate invoice number
|
# Generate invoice number
|
||||||
invoice_number = generate_invoice_number(db)
|
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
|
||||||
|
|
||||||
|
# If invoice_amount is specified, we need to adjust item calculations
|
||||||
|
# This will be handled in the item creation section below
|
||||||
|
|
||||||
# Calculate amounts - subtotal will be recalculated after adding items
|
# Calculate amounts - subtotal will be recalculated after adding items
|
||||||
# Initial subtotal is booking total (room + services)
|
# Initial subtotal is booking total (room + services) or invoice_amount if specified
|
||||||
subtotal = float(booking.total_price)
|
if invoice_amount is not None:
|
||||||
|
subtotal = float(invoice_amount)
|
||||||
|
else:
|
||||||
|
subtotal = float(booking.total_price)
|
||||||
|
|
||||||
# Calculate tax and total amounts
|
# Calculate tax and total amounts
|
||||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||||
@@ -121,6 +130,7 @@ class InvoiceService:
|
|||||||
amount_paid=amount_paid,
|
amount_paid=amount_paid,
|
||||||
balance_due=balance_due,
|
balance_due=balance_due,
|
||||||
status=status,
|
status=status,
|
||||||
|
is_proforma=is_proforma,
|
||||||
company_name=kwargs.get("company_name"),
|
company_name=kwargs.get("company_name"),
|
||||||
company_address=kwargs.get("company_address"),
|
company_address=kwargs.get("company_address"),
|
||||||
company_phone=kwargs.get("company_phone"),
|
company_phone=kwargs.get("company_phone"),
|
||||||
@@ -146,17 +156,28 @@ class InvoiceService:
|
|||||||
services_total = sum(
|
services_total = sum(
|
||||||
float(su.total_price) for su in booking.service_usages
|
float(su.total_price) for su in booking.service_usages
|
||||||
)
|
)
|
||||||
room_price = float(booking.total_price) - services_total
|
booking_total = float(booking.total_price)
|
||||||
|
room_price = booking_total - services_total
|
||||||
|
|
||||||
# Calculate number of nights
|
# Calculate number of nights
|
||||||
nights = (booking.check_out_date - booking.check_in_date).days
|
nights = (booking.check_out_date - booking.check_in_date).days
|
||||||
if nights <= 0:
|
if nights <= 0:
|
||||||
nights = 1
|
nights = 1
|
||||||
|
|
||||||
|
# If invoice_amount is specified (for partial invoices), calculate proportion
|
||||||
|
if invoice_amount is not None and invoice_amount < booking_total:
|
||||||
|
# Calculate proportion for partial invoice
|
||||||
|
proportion = float(invoice_amount) / booking_total
|
||||||
|
room_price = room_price * proportion
|
||||||
|
services_total = services_total * proportion
|
||||||
|
item_description_suffix = f" (Partial: {proportion * 100:.0f}%)"
|
||||||
|
else:
|
||||||
|
item_description_suffix = ""
|
||||||
|
|
||||||
# Room item
|
# Room item
|
||||||
room_item = InvoiceItem(
|
room_item = InvoiceItem(
|
||||||
invoice_id=invoice.id,
|
invoice_id=invoice.id,
|
||||||
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''})",
|
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''}){item_description_suffix}",
|
||||||
quantity=nights,
|
quantity=nights,
|
||||||
unit_price=room_price / nights if nights > 0 else room_price,
|
unit_price=room_price / nights if nights > 0 else room_price,
|
||||||
tax_rate=tax_rate,
|
tax_rate=tax_rate,
|
||||||
@@ -168,14 +189,20 @@ class InvoiceService:
|
|||||||
|
|
||||||
# Add service items if any
|
# Add service items if any
|
||||||
for service_usage in booking.service_usages:
|
for service_usage in booking.service_usages:
|
||||||
|
service_item_price = float(service_usage.total_price)
|
||||||
|
if invoice_amount is not None and invoice_amount < booking_total:
|
||||||
|
# Apply proportion to service items
|
||||||
|
proportion = float(invoice_amount) / booking_total
|
||||||
|
service_item_price = service_item_price * proportion
|
||||||
|
|
||||||
service_item = InvoiceItem(
|
service_item = InvoiceItem(
|
||||||
invoice_id=invoice.id,
|
invoice_id=invoice.id,
|
||||||
description=f"Service: {service_usage.service.name}",
|
description=f"Service: {service_usage.service.name}{item_description_suffix}",
|
||||||
quantity=float(service_usage.quantity),
|
quantity=float(service_usage.quantity),
|
||||||
unit_price=float(service_usage.unit_price),
|
unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price,
|
||||||
tax_rate=tax_rate,
|
tax_rate=tax_rate,
|
||||||
discount_amount=0.0,
|
discount_amount=0.0,
|
||||||
line_total=float(service_usage.total_price),
|
line_total=service_item_price,
|
||||||
service_id=service_usage.service_id,
|
service_id=service_usage.service_id,
|
||||||
)
|
)
|
||||||
db.add(service_item)
|
db.add(service_item)
|
||||||
@@ -391,6 +418,7 @@ class InvoiceService:
|
|||||||
"notes": invoice.notes,
|
"notes": invoice.notes,
|
||||||
"terms_and_conditions": invoice.terms_and_conditions,
|
"terms_and_conditions": invoice.terms_and_conditions,
|
||||||
"payment_instructions": invoice.payment_instructions,
|
"payment_instructions": invoice.payment_instructions,
|
||||||
|
"is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
PayPal payment service for processing PayPal payments
|
PayPal payment service for processing PayPal payments
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
|
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
|
||||||
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
|
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
|
||||||
from paypalcheckoutsdk.payments import CapturesRefundRequest
|
from paypalcheckoutsdk.payments import CapturesRefundRequest
|
||||||
@@ -13,6 +14,8 @@ from sqlalchemy.orm import Session
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_paypal_client_id(db: Session) -> Optional[str]:
|
def get_paypal_client_id(db: Session) -> Optional[str]:
|
||||||
"""Get PayPal client ID from database or environment variable"""
|
"""Get PayPal client ID from database or environment variable"""
|
||||||
@@ -282,7 +285,7 @@ class PayPalService:
|
|||||||
raise ValueError(f"PayPal error: {error_msg}")
|
raise ValueError(f"PayPal error: {error_msg}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def confirm_payment(
|
async def confirm_payment(
|
||||||
order_id: str,
|
order_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
booking_id: Optional[int] = None
|
booking_id: Optional[int] = None
|
||||||
@@ -337,6 +340,15 @@ class PayPalService:
|
|||||||
Payment.payment_status == PaymentStatus.pending
|
Payment.payment_status == PaymentStatus.pending
|
||||||
).order_by(Payment.created_at.desc()).first()
|
).order_by(Payment.created_at.desc()).first()
|
||||||
|
|
||||||
|
# If still not found, try to find pending deposit payment (for cash bookings with deposit)
|
||||||
|
# This allows updating the payment_method from the default to paypal
|
||||||
|
if not payment:
|
||||||
|
payment = db.query(Payment).filter(
|
||||||
|
Payment.booking_id == booking_id,
|
||||||
|
Payment.payment_type == PaymentType.deposit,
|
||||||
|
Payment.payment_status == PaymentStatus.pending
|
||||||
|
).order_by(Payment.created_at.desc()).first()
|
||||||
|
|
||||||
amount = capture_data["amount"]
|
amount = capture_data["amount"]
|
||||||
capture_id = capture_data.get("capture_id")
|
capture_id = capture_data.get("capture_id")
|
||||||
|
|
||||||
@@ -347,6 +359,7 @@ class PayPalService:
|
|||||||
payment.payment_date = datetime.utcnow()
|
payment.payment_date = datetime.utcnow()
|
||||||
# If pending, keep as pending
|
# If pending, keep as pending
|
||||||
payment.amount = amount
|
payment.amount = amount
|
||||||
|
payment.payment_method = PaymentMethod.paypal # Update payment method to PayPal
|
||||||
if capture_id:
|
if capture_id:
|
||||||
payment.transaction_id = f"{order_id}|{capture_id}"
|
payment.transaction_id = f"{order_id}|{capture_id}"
|
||||||
else:
|
else:
|
||||||
@@ -380,18 +393,142 @@ class PayPalService:
|
|||||||
if payment.payment_status == PaymentStatus.completed:
|
if payment.payment_status == PaymentStatus.completed:
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
|
# Calculate total paid from all completed payments (now includes current payment)
|
||||||
|
# This needs to be calculated before the if/elif blocks
|
||||||
|
total_paid = sum(
|
||||||
|
float(p.amount) for p in booking.payments
|
||||||
|
if p.payment_status == PaymentStatus.completed
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update invoice status based on payment
|
||||||
|
from ..models.invoice import Invoice, InvoiceStatus
|
||||||
|
|
||||||
|
# Find invoices for this booking and update their status
|
||||||
|
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
|
||||||
|
for invoice in invoices:
|
||||||
|
# Update invoice amount_paid and balance_due
|
||||||
|
invoice.amount_paid = total_paid
|
||||||
|
invoice.balance_due = float(invoice.total_amount) - total_paid
|
||||||
|
|
||||||
|
# Update invoice status
|
||||||
|
if invoice.balance_due <= 0:
|
||||||
|
invoice.status = InvoiceStatus.paid
|
||||||
|
invoice.paid_date = datetime.utcnow()
|
||||||
|
elif invoice.amount_paid > 0:
|
||||||
|
invoice.status = InvoiceStatus.sent
|
||||||
|
|
||||||
|
booking_was_confirmed = False
|
||||||
|
should_send_email = False
|
||||||
if payment.payment_type == PaymentType.deposit:
|
if payment.payment_type == PaymentType.deposit:
|
||||||
booking.deposit_paid = True
|
booking.deposit_paid = True
|
||||||
if booking.status == BookingStatus.pending:
|
# Restore cancelled bookings or confirm pending bookings
|
||||||
|
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||||
booking.status = BookingStatus.confirmed
|
booking.status = BookingStatus.confirmed
|
||||||
|
booking_was_confirmed = True
|
||||||
|
should_send_email = True
|
||||||
|
elif booking.status == BookingStatus.confirmed:
|
||||||
|
# Booking already confirmed, but deposit was just paid
|
||||||
|
should_send_email = True
|
||||||
elif payment.payment_type == PaymentType.full:
|
elif payment.payment_type == PaymentType.full:
|
||||||
total_paid = sum(
|
# Confirm booking and restore cancelled bookings when payment succeeds
|
||||||
float(p.amount) for p in booking.payments
|
|
||||||
if p.payment_status == PaymentStatus.completed
|
|
||||||
)
|
|
||||||
|
|
||||||
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
||||||
booking.status = BookingStatus.confirmed
|
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||||
|
booking.status = BookingStatus.confirmed
|
||||||
|
booking_was_confirmed = True
|
||||||
|
should_send_email = True
|
||||||
|
elif booking.status == BookingStatus.confirmed:
|
||||||
|
# Booking already confirmed, but full payment was just completed
|
||||||
|
should_send_email = True
|
||||||
|
|
||||||
|
# Send booking confirmation email if booking was just confirmed or payment completed
|
||||||
|
if should_send_email:
|
||||||
|
try:
|
||||||
|
from ..utils.mailer import send_email
|
||||||
|
from ..utils.email_templates import booking_confirmation_email_template
|
||||||
|
from ..models.system_settings import SystemSettings
|
||||||
|
from ..models.room import Room
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
import os
|
||||||
|
from ..config.settings import settings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
|
|
||||||
|
# Load booking with room details for email
|
||||||
|
booking_with_room = db.query(Booking).options(
|
||||||
|
selectinload(Booking.room).selectinload(Room.room_type)
|
||||||
|
).filter(Booking.id == booking_id).first()
|
||||||
|
|
||||||
|
room = booking_with_room.room if booking_with_room else None
|
||||||
|
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
||||||
|
|
||||||
|
# Calculate amount paid and remaining due
|
||||||
|
amount_paid = total_paid
|
||||||
|
payment_type_str = payment.payment_type.value if payment.payment_type else None
|
||||||
|
|
||||||
|
email_html = booking_confirmation_email_template(
|
||||||
|
booking_number=booking.booking_number,
|
||||||
|
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||||
|
room_number=room.room_number if room else "N/A",
|
||||||
|
room_type=room_type_name,
|
||||||
|
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
|
||||||
|
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
|
||||||
|
num_guests=booking.num_guests,
|
||||||
|
total_price=float(booking.total_price),
|
||||||
|
requires_deposit=False, # Payment completed, no deposit message needed
|
||||||
|
deposit_amount=None,
|
||||||
|
amount_paid=amount_paid,
|
||||||
|
payment_type=payment_type_str,
|
||||||
|
client_url=client_url,
|
||||||
|
currency_symbol=currency_symbol
|
||||||
|
)
|
||||||
|
if booking.user:
|
||||||
|
await send_email(
|
||||||
|
to=booking.user.email,
|
||||||
|
subject=f"Booking Confirmed - {booking.booking_number}",
|
||||||
|
html=email_html
|
||||||
|
)
|
||||||
|
logger.info(f"Booking confirmation email sent to {booking.user.email}")
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
|
||||||
|
|
||||||
|
# Send invoice email if payment is completed and invoice is now paid
|
||||||
|
from ..utils.mailer import send_email
|
||||||
|
from ..services.invoice_service import InvoiceService
|
||||||
|
from ..models.invoice import InvoiceStatus
|
||||||
|
|
||||||
|
# Load user for email
|
||||||
|
from ..models.user import User
|
||||||
|
user = db.query(User).filter(User.id == booking.user_id).first()
|
||||||
|
|
||||||
|
for invoice in invoices:
|
||||||
|
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
|
||||||
|
try:
|
||||||
|
invoice_dict = InvoiceService.invoice_to_dict(invoice)
|
||||||
|
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
|
||||||
|
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
|
||||||
|
if user:
|
||||||
|
await send_email(
|
||||||
|
to=user.email,
|
||||||
|
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
|
||||||
|
html=invoice_html
|
||||||
|
)
|
||||||
|
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.error(f"Failed to send invoice email: {str(email_error)}")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Stripe payment service for processing card payments
|
Stripe payment service for processing card payments
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import stripe
|
import stripe
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from ..config.settings import settings
|
from ..config.settings import settings
|
||||||
@@ -10,6 +11,8 @@ from ..models.system_settings import SystemSettings
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_stripe_secret_key(db: Session) -> Optional[str]:
|
def get_stripe_secret_key(db: Session) -> Optional[str]:
|
||||||
"""Get Stripe secret key from database or environment variable"""
|
"""Get Stripe secret key from database or environment variable"""
|
||||||
@@ -183,7 +186,7 @@ class StripeService:
|
|||||||
raise ValueError(f"Stripe error: {str(e)}")
|
raise ValueError(f"Stripe error: {str(e)}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def confirm_payment(
|
async def confirm_payment(
|
||||||
payment_intent_id: str,
|
payment_intent_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
booking_id: Optional[int] = None
|
booking_id: Optional[int] = None
|
||||||
@@ -230,6 +233,15 @@ class StripeService:
|
|||||||
Payment.payment_method == PaymentMethod.stripe
|
Payment.payment_method == PaymentMethod.stripe
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
# If not found, try to find pending deposit payment (for cash bookings with deposit)
|
||||||
|
# This allows updating the payment_method from the default to stripe
|
||||||
|
if not payment:
|
||||||
|
payment = db.query(Payment).filter(
|
||||||
|
Payment.booking_id == booking_id,
|
||||||
|
Payment.payment_type == PaymentType.deposit,
|
||||||
|
Payment.payment_status == PaymentStatus.pending
|
||||||
|
).order_by(Payment.created_at.desc()).first()
|
||||||
|
|
||||||
amount = intent_data["amount"]
|
amount = intent_data["amount"]
|
||||||
|
|
||||||
if payment:
|
if payment:
|
||||||
@@ -240,6 +252,7 @@ class StripeService:
|
|||||||
payment.payment_date = datetime.utcnow()
|
payment.payment_date = datetime.utcnow()
|
||||||
# If processing, keep as pending (will be updated by webhook)
|
# If processing, keep as pending (will be updated by webhook)
|
||||||
payment.amount = amount
|
payment.amount = amount
|
||||||
|
payment.payment_method = PaymentMethod.stripe # Update payment method to Stripe
|
||||||
else:
|
else:
|
||||||
# Create new payment record
|
# Create new payment record
|
||||||
payment_type = PaymentType.full
|
payment_type = PaymentType.full
|
||||||
@@ -271,25 +284,148 @@ class StripeService:
|
|||||||
# Refresh booking to get updated payments relationship
|
# Refresh booking to get updated payments relationship
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
|
# Calculate total paid from all completed payments (now includes current payment)
|
||||||
|
# This needs to be calculated before the if/elif blocks
|
||||||
|
total_paid = sum(
|
||||||
|
float(p.amount) for p in booking.payments
|
||||||
|
if p.payment_status == PaymentStatus.completed
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update invoice status based on payment
|
||||||
|
from ..models.invoice import Invoice, InvoiceStatus
|
||||||
|
from ..services.invoice_service import InvoiceService
|
||||||
|
|
||||||
|
# Find invoices for this booking and update their status
|
||||||
|
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
|
||||||
|
for invoice in invoices:
|
||||||
|
# Update invoice amount_paid and balance_due
|
||||||
|
invoice.amount_paid = total_paid
|
||||||
|
invoice.balance_due = float(invoice.total_amount) - total_paid
|
||||||
|
|
||||||
|
# Update invoice status
|
||||||
|
if invoice.balance_due <= 0:
|
||||||
|
invoice.status = InvoiceStatus.paid
|
||||||
|
invoice.paid_date = datetime.utcnow()
|
||||||
|
elif invoice.amount_paid > 0:
|
||||||
|
invoice.status = InvoiceStatus.sent
|
||||||
|
|
||||||
|
booking_was_confirmed = False
|
||||||
|
should_send_email = False
|
||||||
if payment.payment_type == PaymentType.deposit:
|
if payment.payment_type == PaymentType.deposit:
|
||||||
# Mark deposit as paid and confirm booking
|
# Mark deposit as paid and confirm booking
|
||||||
booking.deposit_paid = True
|
booking.deposit_paid = True
|
||||||
if booking.status == BookingStatus.pending:
|
# Restore cancelled bookings or confirm pending bookings
|
||||||
|
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||||
booking.status = BookingStatus.confirmed
|
booking.status = BookingStatus.confirmed
|
||||||
|
booking_was_confirmed = True
|
||||||
|
should_send_email = True
|
||||||
|
elif booking.status == BookingStatus.confirmed:
|
||||||
|
# Booking already confirmed, but deposit was just paid
|
||||||
|
should_send_email = True
|
||||||
elif payment.payment_type == PaymentType.full:
|
elif payment.payment_type == PaymentType.full:
|
||||||
# Calculate total paid from all completed payments (now includes current payment)
|
|
||||||
total_paid = sum(
|
|
||||||
float(p.amount) for p in booking.payments
|
|
||||||
if p.payment_status == PaymentStatus.completed
|
|
||||||
)
|
|
||||||
|
|
||||||
# Confirm booking if:
|
# Confirm booking if:
|
||||||
# 1. Total paid (all payments) covers the booking price, OR
|
# 1. Total paid (all payments) covers the booking price, OR
|
||||||
# 2. This single payment covers the entire booking amount
|
# 2. This single payment covers the entire booking amount
|
||||||
|
# Also restore cancelled bookings when payment succeeds
|
||||||
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
||||||
booking.status = BookingStatus.confirmed
|
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||||
|
booking.status = BookingStatus.confirmed
|
||||||
|
booking_was_confirmed = True
|
||||||
|
should_send_email = True
|
||||||
|
elif booking.status == BookingStatus.confirmed:
|
||||||
|
# Booking already confirmed, but full payment was just completed
|
||||||
|
should_send_email = True
|
||||||
|
|
||||||
# Commit booking status update
|
# Send booking confirmation email if booking was just confirmed or payment completed
|
||||||
|
if should_send_email:
|
||||||
|
try:
|
||||||
|
from ..utils.mailer import send_email
|
||||||
|
from ..utils.email_templates import booking_confirmation_email_template
|
||||||
|
from ..models.system_settings import SystemSettings
|
||||||
|
from ..models.room import Room
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
import os
|
||||||
|
from ..config.settings import settings
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
# Get platform currency for email
|
||||||
|
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||||
|
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||||
|
|
||||||
|
# Get currency symbol
|
||||||
|
currency_symbols = {
|
||||||
|
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||||
|
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||||
|
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||||
|
}
|
||||||
|
currency_symbol = currency_symbols.get(currency, currency)
|
||||||
|
|
||||||
|
# Load booking with room details for email
|
||||||
|
booking_with_room = db.query(Booking).options(
|
||||||
|
selectinload(Booking.room).selectinload(Room.room_type)
|
||||||
|
).filter(Booking.id == booking_id).first()
|
||||||
|
|
||||||
|
room = booking_with_room.room if booking_with_room else None
|
||||||
|
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
||||||
|
|
||||||
|
# Calculate amount paid and remaining due
|
||||||
|
amount_paid = total_paid
|
||||||
|
payment_type_str = payment.payment_type.value if payment.payment_type else None
|
||||||
|
|
||||||
|
email_html = booking_confirmation_email_template(
|
||||||
|
booking_number=booking.booking_number,
|
||||||
|
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||||
|
room_number=room.room_number if room else "N/A",
|
||||||
|
room_type=room_type_name,
|
||||||
|
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
|
||||||
|
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
|
||||||
|
num_guests=booking.num_guests,
|
||||||
|
total_price=float(booking.total_price),
|
||||||
|
requires_deposit=False, # Payment completed, no deposit message needed
|
||||||
|
deposit_amount=None,
|
||||||
|
amount_paid=amount_paid,
|
||||||
|
payment_type=payment_type_str,
|
||||||
|
client_url=client_url,
|
||||||
|
currency_symbol=currency_symbol
|
||||||
|
)
|
||||||
|
if booking.user:
|
||||||
|
await send_email(
|
||||||
|
to=booking.user.email,
|
||||||
|
subject=f"Booking Confirmed - {booking.booking_number}",
|
||||||
|
html=email_html
|
||||||
|
)
|
||||||
|
logger.info(f"Booking confirmation email sent to {booking.user.email}")
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
|
||||||
|
|
||||||
|
# Send invoice email if payment is completed and invoice is now paid
|
||||||
|
from ..utils.mailer import send_email
|
||||||
|
from ..services.invoice_service import InvoiceService
|
||||||
|
|
||||||
|
# Load user for email
|
||||||
|
from ..models.user import User
|
||||||
|
user = db.query(User).filter(User.id == booking.user_id).first()
|
||||||
|
|
||||||
|
for invoice in invoices:
|
||||||
|
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
|
||||||
|
try:
|
||||||
|
invoice_dict = InvoiceService.invoice_to_dict(invoice)
|
||||||
|
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
|
||||||
|
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
|
||||||
|
if user:
|
||||||
|
await send_email(
|
||||||
|
to=user.email,
|
||||||
|
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
|
||||||
|
html=invoice_html
|
||||||
|
)
|
||||||
|
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.error(f"Failed to send invoice email: {str(email_error)}")
|
||||||
|
|
||||||
|
# Commit booking and invoice status updates
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
@@ -335,7 +471,7 @@ class StripeService:
|
|||||||
raise ValueError(f"Error confirming payment: {error_msg}")
|
raise ValueError(f"Error confirming payment: {error_msg}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_webhook(
|
async def handle_webhook(
|
||||||
payload: bytes,
|
payload: bytes,
|
||||||
signature: str,
|
signature: str,
|
||||||
db: Session
|
db: Session
|
||||||
@@ -375,14 +511,16 @@ class StripeService:
|
|||||||
booking_id = metadata.get("booking_id")
|
booking_id = metadata.get("booking_id")
|
||||||
|
|
||||||
if booking_id:
|
if booking_id:
|
||||||
try:
|
try:
|
||||||
StripeService.confirm_payment(
|
await StripeService.confirm_payment(
|
||||||
payment_intent_id=payment_intent_id,
|
payment_intent_id=payment_intent_id,
|
||||||
db=db,
|
db=db,
|
||||||
booking_id=int(booking_id)
|
booking_id=int(booking_id)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error processing webhook for booking {booking_id}: {str(e)}")
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error processing webhook for booking {booking_id}: {str(e)}")
|
||||||
|
|
||||||
elif event["type"] == "payment_intent.payment_failed":
|
elif event["type"] == "payment_intent.payment_failed":
|
||||||
payment_intent = event["data"]["object"]
|
payment_intent = event["data"]["object"]
|
||||||
@@ -400,6 +538,42 @@ class StripeService:
|
|||||||
if payment:
|
if payment:
|
||||||
payment.payment_status = PaymentStatus.failed
|
payment.payment_status = PaymentStatus.failed
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Auto-cancel booking when payment fails
|
||||||
|
booking = db.query(Booking).filter(Booking.id == int(booking_id)).first()
|
||||||
|
if booking and booking.status != BookingStatus.cancelled:
|
||||||
|
booking.status = BookingStatus.cancelled
|
||||||
|
db.commit()
|
||||||
|
db.refresh(booking)
|
||||||
|
|
||||||
|
# Send cancellation email (non-blocking)
|
||||||
|
try:
|
||||||
|
if booking.user:
|
||||||
|
from ..utils.mailer import send_email
|
||||||
|
from ..utils.email_templates import booking_status_changed_email_template
|
||||||
|
from ..models.system_settings import SystemSettings
|
||||||
|
from ..config.settings import settings
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Get client URL from settings
|
||||||
|
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||||
|
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||||
|
|
||||||
|
email_html = booking_status_changed_email_template(
|
||||||
|
booking_number=booking.booking_number,
|
||||||
|
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||||
|
status="cancelled",
|
||||||
|
client_url=client_url
|
||||||
|
)
|
||||||
|
await send_email(
|
||||||
|
to=booking.user.email,
|
||||||
|
subject=f"Booking Cancelled - {booking.booking_number}",
|
||||||
|
html=email_html
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Failed to send cancellation email: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|||||||
Binary file not shown.
@@ -260,19 +260,66 @@ def booking_confirmation_email_template(
|
|||||||
total_price: float,
|
total_price: float,
|
||||||
requires_deposit: bool,
|
requires_deposit: bool,
|
||||||
deposit_amount: Optional[float] = None,
|
deposit_amount: Optional[float] = None,
|
||||||
client_url: str = "http://localhost:5173"
|
amount_paid: Optional[float] = None,
|
||||||
|
payment_type: Optional[str] = None,
|
||||||
|
original_price: Optional[float] = None,
|
||||||
|
discount_amount: Optional[float] = None,
|
||||||
|
promotion_code: Optional[str] = None,
|
||||||
|
client_url: str = "http://localhost:5173",
|
||||||
|
currency_symbol: str = "$"
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Booking confirmation email template"""
|
"""Booking confirmation email template"""
|
||||||
deposit_info = ""
|
deposit_info = ""
|
||||||
if requires_deposit and deposit_amount:
|
if requires_deposit and deposit_amount and amount_paid is None:
|
||||||
deposit_info = f"""
|
deposit_info = f"""
|
||||||
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);">
|
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);">
|
||||||
<p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p>
|
<p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p>
|
||||||
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">€{deposit_amount:.2f}</strong> to confirm your booking.</p>
|
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">{currency_symbol}{deposit_amount:.2f}</strong> to confirm your booking.</p>
|
||||||
<p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
|
<p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Payment breakdown section (shown when payment is completed)
|
||||||
|
payment_breakdown = ""
|
||||||
|
if amount_paid is not None:
|
||||||
|
remaining_due = total_price - amount_paid
|
||||||
|
payment_type_label = "Deposit Payment" if payment_type == "deposit" else "Full Payment"
|
||||||
|
payment_breakdown = f"""
|
||||||
|
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.2);">
|
||||||
|
<h3 style="margin-top: 0; margin-bottom: 20px; color: #065F46; font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600;">Payment Information</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Payment Type:</td>
|
||||||
|
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: rgba(16, 185, 129, 0.1);">
|
||||||
|
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Amount Paid:</td>
|
||||||
|
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 18px;">{currency_symbol}{amount_paid:.2f}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
|
||||||
|
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
if remaining_due > 0:
|
||||||
|
payment_breakdown += f"""
|
||||||
|
<tr style="background-color: rgba(245, 158, 11, 0.1); border-top: 2px solid #F59E0B;">
|
||||||
|
<td style="padding: 10px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
|
||||||
|
<td style="padding: 10px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
payment_breakdown += f"""
|
||||||
|
<tr style="background-color: rgba(16, 185, 129, 0.1); border-top: 2px solid #10B981;">
|
||||||
|
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 600;">Status:</td>
|
||||||
|
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 16px;">✅ Fully Paid</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
payment_breakdown += """
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
content = f"""
|
content = f"""
|
||||||
<div style="text-align: center; margin-bottom: 30px;">
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
|
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
|
||||||
@@ -308,13 +355,25 @@ def booking_confirmation_email_template(
|
|||||||
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
|
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
|
||||||
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
|
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{f'''
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Subtotal:</td>
|
||||||
|
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{original_price:.2f}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: rgba(16, 185, 129, 0.1);">
|
||||||
|
<td style="padding: 12px 0; color: #065F46; font-size: 14px; font-weight: 500;">Promotion Discount{f' ({promotion_code})' if promotion_code else ''}:</td>
|
||||||
|
<td style="padding: 12px 0; font-weight: 700; color: #059669; font-size: 15px;">-{currency_symbol}{discount_amount:.2f}</td>
|
||||||
|
</tr>
|
||||||
|
''' if original_price and discount_amount and discount_amount > 0 else ''}
|
||||||
<tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
|
<tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
|
||||||
<td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
|
<td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
|
||||||
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">€{total_price:.2f}</td>
|
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{currency_symbol}{total_price:.2f}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{payment_breakdown}
|
||||||
|
|
||||||
{deposit_info}
|
{deposit_info}
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 40px;">
|
<div style="text-align: center; margin-top: 40px;">
|
||||||
@@ -334,7 +393,10 @@ def payment_confirmation_email_template(
|
|||||||
amount: float,
|
amount: float,
|
||||||
payment_method: str,
|
payment_method: str,
|
||||||
transaction_id: Optional[str] = None,
|
transaction_id: Optional[str] = None,
|
||||||
client_url: str = "http://localhost:5173"
|
payment_type: Optional[str] = None,
|
||||||
|
total_price: Optional[float] = None,
|
||||||
|
client_url: str = "http://localhost:5173",
|
||||||
|
currency_symbol: str = "$"
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Payment confirmation email template"""
|
"""Payment confirmation email template"""
|
||||||
transaction_info = ""
|
transaction_info = ""
|
||||||
@@ -346,6 +408,34 @@ def payment_confirmation_email_template(
|
|||||||
</tr>
|
</tr>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
payment_type_info = ""
|
||||||
|
if payment_type:
|
||||||
|
payment_type_label = "Deposit Payment (20%)" if payment_type == "deposit" else "Full Payment"
|
||||||
|
payment_type_info = f"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Type:</td>
|
||||||
|
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
total_price_info = ""
|
||||||
|
remaining_due_info = ""
|
||||||
|
if total_price is not None:
|
||||||
|
total_price_info = f"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
|
||||||
|
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
if payment_type == "deposit" and total_price > amount:
|
||||||
|
remaining_due = total_price - amount
|
||||||
|
remaining_due_info = f"""
|
||||||
|
<tr style="background-color: rgba(245, 158, 11, 0.1);">
|
||||||
|
<td style="padding: 12px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
|
||||||
|
<td style="padding: 12px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
content = f"""
|
content = f"""
|
||||||
<div style="text-align: center; margin-bottom: 30px;">
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
|
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
|
||||||
@@ -370,10 +460,13 @@ def payment_confirmation_email_template(
|
|||||||
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
|
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{transaction_info}
|
{transaction_info}
|
||||||
|
{payment_type_info}
|
||||||
|
{total_price_info}
|
||||||
<tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;">
|
<tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;">
|
||||||
<td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td>
|
<td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td>
|
||||||
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">€{amount:.2f}</td>
|
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">{currency_symbol}{amount:.2f}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{remaining_due_info}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
46
Frontend/package-lock.json
generated
46
Frontend/package-lock.json
generated
@@ -13,12 +13,14 @@
|
|||||||
"@stripe/react-stripe-js": "^2.9.0",
|
"@stripe/react-stripe-js": "^2.9.0",
|
||||||
"@stripe/stripe-js": "^2.4.0",
|
"@stripe/stripe-js": "^2.4.0",
|
||||||
"@types/react-datepicker": "^6.2.0",
|
"@types/react-datepicker": "^6.2.0",
|
||||||
|
"@types/react-google-recaptcha": "^2.1.9",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-datepicker": "^8.9.0",
|
"react-datepicker": "^8.9.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-google-recaptcha": "^3.1.0",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
@@ -1610,6 +1612,15 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-google-recaptcha": {
|
||||||
|
"version": "2.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
|
||||||
|
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/semver": {
|
"node_modules/@types/semver": {
|
||||||
"version": "7.7.1",
|
"version": "7.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||||
@@ -3179,6 +3190,15 @@
|
|||||||
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
|
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4124,6 +4144,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-async-script": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hoist-non-react-statics": "^3.3.0",
|
||||||
|
"prop-types": "^15.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-datepicker": {
|
"node_modules/react-datepicker": {
|
||||||
"version": "8.9.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz",
|
||||||
@@ -4187,6 +4220,19 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-google-recaptcha": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.5.0",
|
||||||
|
"react-async-script": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.65.0",
|
"version": "7.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
|
||||||
|
|||||||
@@ -11,19 +11,21 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"@paypal/react-paypal-js": "^8.1.3",
|
||||||
"@stripe/react-stripe-js": "^2.9.0",
|
"@stripe/react-stripe-js": "^2.9.0",
|
||||||
"@stripe/stripe-js": "^2.4.0",
|
"@stripe/stripe-js": "^2.4.0",
|
||||||
"@types/react-datepicker": "^6.2.0",
|
"@types/react-datepicker": "^6.2.0",
|
||||||
|
"@types/react-google-recaptcha": "^2.1.9",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-datepicker": "^8.9.0",
|
"react-datepicker": "^8.9.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-google-recaptcha": "^3.1.0",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"@paypal/react-paypal-js": "^8.1.3",
|
|
||||||
"yup": "^1.3.3",
|
"yup": "^1.3.3",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="deposit-payment/:bookingId"
|
path="payment/deposit/:bookingId"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<DepositPaymentPage />
|
<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 React, { useState, useEffect } from 'react';
|
||||||
import { createPayPalOrder } from '../../services/api/paymentService';
|
import { createPayPalOrder } from '../../services/api/paymentService';
|
||||||
import { Loader2, AlertCircle } from 'lucide-react';
|
import { Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||||
|
|
||||||
interface PayPalPaymentWrapperProps {
|
interface PayPalPaymentWrapperProps {
|
||||||
bookingId: number;
|
bookingId: number;
|
||||||
@@ -12,9 +13,12 @@ interface PayPalPaymentWrapperProps {
|
|||||||
const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||||
bookingId,
|
bookingId,
|
||||||
amount,
|
amount,
|
||||||
currency = 'USD',
|
currency: propCurrency,
|
||||||
onError,
|
onError,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Get currency from context if not provided as prop
|
||||||
|
const { currency: contextCurrency } = useFormatCurrency();
|
||||||
|
const currency = propCurrency || contextCurrency || 'USD';
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
|
const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
|
||||||
@@ -75,22 +79,29 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
|
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||||
<span className="ml-2 text-gray-600">Initializing PayPal payment...</span>
|
rounded-full flex items-center justify-center
|
||||||
|
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[#d4af37]" />
|
||||||
|
</div>
|
||||||
|
<span className="ml-4 text-gray-300 font-light tracking-wide">
|
||||||
|
Initializing PayPal payment...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||||
<div className="flex items-start gap-3">
|
border border-red-500/30 rounded-xl p-6 backdrop-blur-sm">
|
||||||
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
<div className="flex items-start gap-4">
|
||||||
|
<AlertCircle className="w-6 h-6 text-red-400 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-red-900 mb-1">
|
<h3 className="text-lg font-serif font-semibold text-red-300 mb-2 tracking-wide">
|
||||||
Payment Initialization Failed
|
Payment Initialization Failed
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-red-800">
|
<p className="text-sm text-red-200/80 font-light tracking-wide">
|
||||||
{error || 'Unable to initialize PayPal payment. Please try again.'}
|
{error || 'Unable to initialize PayPal payment. Please try again.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,57 +113,65 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
|||||||
if (!approvalUrl) {
|
if (!approvalUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
|
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||||
<span className="ml-2 text-gray-600">Loading PayPal...</span>
|
rounded-full flex items-center justify-center
|
||||||
|
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[#d4af37]" />
|
||||||
|
</div>
|
||||||
|
<span className="ml-4 text-gray-300 font-light tracking-wide">
|
||||||
|
Loading PayPal...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<div className="mb-6">
|
||||||
<div className="mb-4">
|
<svg
|
||||||
<svg
|
className="mx-auto h-14 w-auto"
|
||||||
className="mx-auto h-12 w-auto"
|
viewBox="0 0 283 64"
|
||||||
viewBox="0 0 283 64"
|
fill="none"
|
||||||
fill="none"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.7-9.2 12.2-9.2z"
|
|
||||||
fill="#003087"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Complete Payment with PayPal
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
|
||||||
You will be redirected to PayPal to securely complete your payment of{' '}
|
|
||||||
<span className="font-semibold">
|
|
||||||
{new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
}).format(amount)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handlePayPalClick}
|
|
||||||
className="w-full bg-[#0070ba] hover:bg-[#005ea6] text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
className="w-6 h-6"
|
d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.7-9.2 12.2-9.2z"
|
||||||
viewBox="0 0 24 24"
|
fill="#003087"
|
||||||
fill="currentColor"
|
/>
|
||||||
>
|
</svg>
|
||||||
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
|
|
||||||
</svg>
|
|
||||||
Pay with PayPal
|
|
||||||
</button>
|
|
||||||
<p className="text-xs text-gray-500 mt-4">
|
|
||||||
Secure payment powered by PayPal
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-xl font-serif font-semibold text-[#d4af37] mb-3 tracking-wide">
|
||||||
|
Complete Payment with PayPal
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-300/80 font-light text-lg mb-8 tracking-wide">
|
||||||
|
You will be redirected to PayPal to securely complete your payment of{' '}
|
||||||
|
<span className="font-semibold text-[#d4af37]">
|
||||||
|
{new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
}).format(amount)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handlePayPalClick}
|
||||||
|
className="w-full bg-gradient-to-r from-[#0070ba] to-[#005ea6]
|
||||||
|
hover:from-[#0080cc] hover:to-[#0070ba] text-white
|
||||||
|
font-semibold py-4 px-8 rounded-sm transition-all duration-300
|
||||||
|
flex items-center justify-center gap-3 shadow-lg shadow-blue-500/30
|
||||||
|
hover:shadow-xl hover:shadow-blue-500/40 tracking-wide"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
|
||||||
|
</svg>
|
||||||
|
Pay with PayPal
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400/70 mt-6 font-light tracking-wide">
|
||||||
|
Secure payment powered by PayPal
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ const RatingStars: React.FC<RatingStarsProps> = ({
|
|||||||
<Star
|
<Star
|
||||||
className={`${sizeClasses[size]} ${
|
className={`${sizeClasses[size]} ${
|
||||||
isFilled
|
isFilled
|
||||||
? 'text-yellow-500 fill-yellow-500'
|
? 'text-[#d4af37] fill-[#d4af37]'
|
||||||
: 'text-gray-300'
|
: 'text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{showNumber && (
|
{showNumber && (
|
||||||
<span className="ml-2 text-sm font-semibold text-gray-700">
|
<span className="ml-2 text-xs sm:text-sm font-semibold text-white">
|
||||||
{rating.toFixed(1)}
|
{rating.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type Review,
|
type Review,
|
||||||
} from '../../services/api/reviewService';
|
} from '../../services/api/reviewService';
|
||||||
import useAuthStore from '../../store/useAuthStore';
|
import useAuthStore from '../../store/useAuthStore';
|
||||||
|
import Recaptcha from '../common/Recaptcha';
|
||||||
|
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||||
|
|
||||||
interface ReviewSectionProps {
|
interface ReviewSectionProps {
|
||||||
roomId: number;
|
roomId: number;
|
||||||
@@ -42,6 +44,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [averageRating, setAverageRating] = useState<number>(0);
|
const [averageRating, setAverageRating] = useState<number>(0);
|
||||||
const [totalReviews, setTotalReviews] = useState<number>(0);
|
const [totalReviews, setTotalReviews] = useState<number>(0);
|
||||||
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -87,6 +90,22 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify reCAPTCHA if enabled
|
||||||
|
if (recaptchaToken) {
|
||||||
|
try {
|
||||||
|
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||||
|
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const response = await createReview({
|
const response = await createReview({
|
||||||
@@ -101,12 +120,14 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
|||||||
);
|
);
|
||||||
reset();
|
reset();
|
||||||
fetchReviews();
|
fetchReviews();
|
||||||
|
setRecaptchaToken(null);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message =
|
||||||
error.response?.data?.message ||
|
error.response?.data?.message ||
|
||||||
'Unable to submit review';
|
'Unable to submit review';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
|
setRecaptchaToken(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -121,24 +142,26 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-4">
|
||||||
{/* Rating Summary */}
|
{/* Rating Summary */}
|
||||||
<div className="bg-gray-50 rounded-lg p-6">
|
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
<h3 className="text-sm sm:text-base font-serif font-semibold text-white mb-3 tracking-wide">
|
||||||
Customer Reviews
|
Customer Reviews
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-5xl font-bold text-gray-900">
|
<div className="text-2xl sm:text-3xl font-serif font-bold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
|
||||||
{averageRating > 0
|
{averageRating > 0
|
||||||
? averageRating.toFixed(1)
|
? averageRating.toFixed(1)
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<RatingStars
|
<div className="mt-1">
|
||||||
rating={averageRating}
|
<RatingStars
|
||||||
size="md"
|
rating={averageRating}
|
||||||
/>
|
size="sm"
|
||||||
<div className="text-sm text-gray-600 mt-2">
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] sm:text-xs text-gray-400 mt-1.5 font-light">
|
||||||
{totalReviews} review{totalReviews !== 1 ? 's' : ''}
|
{totalReviews} review{totalReviews !== 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,29 +170,29 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
|||||||
|
|
||||||
{/* Review Form */}
|
{/* Review Form */}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
|
||||||
<h4 className="text-xl font-semibold mb-4">
|
<h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
|
||||||
Write Your Review
|
Write Your Review
|
||||||
</h4>
|
</h4>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}
|
<form onSubmit={handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="space-y-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium
|
<label className="block text-[10px] sm:text-xs font-light
|
||||||
text-gray-700 mb-2"
|
text-gray-300 mb-1.5 tracking-wide"
|
||||||
>
|
>
|
||||||
Your Rating
|
Your Rating
|
||||||
</label>
|
</label>
|
||||||
<RatingStars
|
<RatingStars
|
||||||
rating={rating}
|
rating={rating}
|
||||||
size="lg"
|
size="sm"
|
||||||
interactive
|
interactive
|
||||||
onRatingChange={(value) =>
|
onRatingChange={(value) =>
|
||||||
setValue('rating', value)
|
setValue('rating', value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{errors.rating && (
|
{errors.rating && (
|
||||||
<p className="text-red-600 text-sm mt-1">
|
<p className="text-red-400 text-[10px] sm:text-xs mt-1 font-light">
|
||||||
{errors.rating.message}
|
{errors.rating.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -178,51 +201,66 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="comment"
|
htmlFor="comment"
|
||||||
className="block text-sm font-medium
|
className="block text-[10px] sm:text-xs font-light
|
||||||
text-gray-700 mb-2"
|
text-gray-300 mb-1.5 tracking-wide"
|
||||||
>
|
>
|
||||||
Comment
|
Comment
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
{...register('comment')}
|
{...register('comment')}
|
||||||
id="comment"
|
id="comment"
|
||||||
rows={4}
|
rows={3}
|
||||||
className="w-full px-4 py-2 border
|
className="w-full px-2.5 py-1.5 bg-[#0a0a0a] border
|
||||||
border-gray-300 rounded-lg
|
border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 text-xs sm:text-sm
|
||||||
focus:ring-2 focus:ring-blue-500
|
focus:ring-2 focus:ring-[#d4af37]/50
|
||||||
focus:border-transparent"
|
focus:border-[#d4af37] transition-all duration-300
|
||||||
|
font-light tracking-wide resize-none"
|
||||||
placeholder="Share your experience..."
|
placeholder="Share your experience..."
|
||||||
/>
|
/>
|
||||||
{errors.comment && (
|
{errors.comment && (
|
||||||
<p className="text-red-600 text-sm mt-1">
|
<p className="text-red-400 text-[10px] sm:text-xs mt-1 font-light">
|
||||||
{errors.comment.message}
|
{errors.comment.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* reCAPTCHA */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Recaptcha
|
||||||
|
onChange={(token) => setRecaptchaToken(token)}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('reCAPTCHA error:', error);
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
}}
|
||||||
|
theme="dark"
|
||||||
|
size="normal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-6 py-3 bg-blue-600 text-white
|
className="px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||||
rounded-lg hover:bg-blue-700
|
text-[#0f0f0f] rounded-sm hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||||
disabled:bg-gray-400
|
disabled:bg-gray-800 disabled:text-gray-500
|
||||||
disabled:cursor-not-allowed
|
disabled:cursor-not-allowed
|
||||||
transition-colors font-medium"
|
transition-all duration-300 font-medium text-xs sm:text-sm
|
||||||
|
shadow-sm shadow-[#d4af37]/30 tracking-wide"
|
||||||
>
|
>
|
||||||
{submitting ? 'Submitting...' : 'Submit Review'}
|
{submitting ? 'Submitting...' : 'Submit Review'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-blue-50 border border-blue-200
|
<div className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 border border-[#d4af37]/30
|
||||||
rounded-lg p-6 text-center"
|
rounded-lg p-3 sm:p-4 text-center backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<p className="text-blue-800">
|
<p className="text-[#d4af37] text-xs sm:text-sm font-light">
|
||||||
Please{' '}
|
Please{' '}
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
className="font-semibold underline
|
className="font-semibold underline
|
||||||
hover:text-blue-900"
|
hover:text-[#f5d76e] transition-colors"
|
||||||
>
|
>
|
||||||
login
|
login
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
@@ -233,71 +271,70 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
|||||||
|
|
||||||
{/* Reviews List */}
|
{/* Reviews List */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xl font-semibold mb-6">
|
<h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
|
||||||
All Reviews ({totalReviews})
|
All Reviews ({totalReviews})
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-2.5">
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-gray-100 rounded-lg p-6
|
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3
|
||||||
animate-pulse"
|
animate-pulse"
|
||||||
>
|
>
|
||||||
<div className="h-4 bg-gray-300
|
<div className="h-3 bg-gray-700
|
||||||
rounded w-1/4 mb-2"
|
rounded w-1/4 mb-2"
|
||||||
/>
|
/>
|
||||||
<div className="h-4 bg-gray-300
|
<div className="h-3 bg-gray-700
|
||||||
rounded w-3/4"
|
rounded w-3/4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : reviews.length === 0 ? (
|
) : reviews.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-gray-50
|
<div className="text-center py-6 sm:py-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-4"
|
||||||
rounded-lg"
|
|
||||||
>
|
>
|
||||||
<p className="text-gray-600 text-lg">
|
<p className="text-gray-300 text-sm sm:text-base font-light">
|
||||||
No reviews yet
|
No reviews yet
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-sm mt-2">
|
<p className="text-gray-400 text-xs sm:text-sm mt-1.5 font-light">
|
||||||
Be the first to review this room!
|
Be the first to review this room!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-2.5 sm:space-y-3">
|
||||||
{reviews.map((review) => (
|
{reviews.map((review) => (
|
||||||
<div
|
<div
|
||||||
key={review.id}
|
key={review.id}
|
||||||
className="bg-white rounded-lg shadow-md
|
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20
|
||||||
p-6"
|
p-3 sm:p-4 backdrop-blur-xl shadow-sm shadow-[#d4af37]/5"
|
||||||
>
|
>
|
||||||
<div className="flex items-start
|
<div className="flex items-start
|
||||||
justify-between mb-3"
|
justify-between mb-2"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-semibold
|
<h5 className="font-semibold
|
||||||
text-gray-900"
|
text-white text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{review.user?.full_name || 'Guest'}
|
{review.user?.full_name || 'Guest'}
|
||||||
</h5>
|
</h5>
|
||||||
<div className="flex items-center
|
<div className="flex items-center
|
||||||
gap-2 mt-1"
|
gap-1.5 mt-1"
|
||||||
>
|
>
|
||||||
<RatingStars
|
<RatingStars
|
||||||
rating={review.rating}
|
rating={review.rating}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm
|
<span className="text-[10px] sm:text-xs
|
||||||
text-gray-500"
|
text-gray-400 font-light"
|
||||||
>
|
>
|
||||||
{formatDate(review.created_at)}
|
{formatDate(review.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700 leading-relaxed">
|
<p className="text-gray-300 leading-relaxed text-xs sm:text-sm font-light">
|
||||||
{review.comment}
|
{review.comment}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { pageContentService } from '../services/api';
|
|||||||
import type { PageContent } from '../services/api/pageContentService';
|
import type { PageContent } from '../services/api/pageContentService';
|
||||||
import { useCompanySettings } from '../contexts/CompanySettingsContext';
|
import { useCompanySettings } from '../contexts/CompanySettingsContext';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import Recaptcha from '../components/common/Recaptcha';
|
||||||
|
import { recaptchaService } from '../services/api/systemSettingsService';
|
||||||
|
|
||||||
const ContactPage: React.FC = () => {
|
const ContactPage: React.FC = () => {
|
||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
@@ -18,6 +20,7 @@ const ContactPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
@@ -53,6 +56,22 @@ const ContactPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify reCAPTCHA if enabled
|
||||||
|
if (recaptchaToken) {
|
||||||
|
try {
|
||||||
|
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||||
|
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await submitContactForm(formData);
|
await submitContactForm(formData);
|
||||||
@@ -67,9 +86,11 @@ const ContactPage: React.FC = () => {
|
|||||||
message: '',
|
message: '',
|
||||||
});
|
});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
setRecaptchaToken(null);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
|
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
|
setRecaptchaToken(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -400,6 +421,20 @@ const ContactPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* reCAPTCHA */}
|
||||||
|
<div className="pt-2 sm:pt-3">
|
||||||
|
<Recaptcha
|
||||||
|
onChange={(token) => setRecaptchaToken(token)}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('reCAPTCHA error:', error);
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
}}
|
||||||
|
theme="dark"
|
||||||
|
size="normal"
|
||||||
|
className="flex justify-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="pt-2 sm:pt-3 md:pt-4">
|
<div className="pt-2 sm:pt-3 md:pt-4">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -214,9 +214,37 @@ const BookingManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-8 py-5 whitespace-nowrap">
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
<div className="text-sm font-bold text-slate-900 bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
|
{(() => {
|
||||||
{formatCurrency(booking.total_price)}
|
const completedPayments = booking.payments?.filter(
|
||||||
</div>
|
(p) => p.payment_status === 'completed'
|
||||||
|
) || [];
|
||||||
|
const amountPaid = completedPayments.reduce(
|
||||||
|
(sum, p) => sum + (p.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const remainingDue = booking.total_price - amountPaid;
|
||||||
|
const hasPayments = completedPayments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-slate-900 bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
|
||||||
|
{formatCurrency(booking.total_price)}
|
||||||
|
</div>
|
||||||
|
{hasPayments && (
|
||||||
|
<div className="text-xs mt-1">
|
||||||
|
<div className="text-green-600 font-medium">
|
||||||
|
Paid: {formatCurrency(amountPaid)}
|
||||||
|
</div>
|
||||||
|
{remainingDue > 0 && (
|
||||||
|
<div className="text-amber-600 font-medium">
|
||||||
|
Due: {formatCurrency(remainingDue)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-8 py-5 whitespace-nowrap">
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
{getStatusBadge(booking.status)}
|
{getStatusBadge(booking.status)}
|
||||||
@@ -369,12 +397,219 @@ const BookingManagementPage: React.FC = () => {
|
|||||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total Price - Highlighted */}
|
{/* Payment Method & Status */}
|
||||||
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg">
|
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
|
||||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Total Price</label>
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||||
<p className="text-4xl font-bold bg-gradient-to-r from-amber-600 via-amber-700 to-amber-600 bg-clip-text text-transparent">
|
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
|
||||||
{formatCurrency(selectedBooking.total_price)}
|
Payment Information
|
||||||
</p>
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
|
||||||
|
<p className="text-base font-semibold text-slate-900">
|
||||||
|
{selectedBooking.payment_method === 'cash'
|
||||||
|
? '💵 Pay at Hotel'
|
||||||
|
: selectedBooking.payment_method === 'stripe'
|
||||||
|
? '💳 Stripe (Card)'
|
||||||
|
: selectedBooking.payment_method === 'paypal'
|
||||||
|
? '💳 PayPal'
|
||||||
|
: selectedBooking.payment_method || 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
|
||||||
|
<p className={`text-base font-semibold ${
|
||||||
|
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||||
|
? 'text-green-600'
|
||||||
|
: selectedBooking.payment_status === 'pending'
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||||
|
? '✅ Paid'
|
||||||
|
: selectedBooking.payment_status === 'pending'
|
||||||
|
? '⏳ Pending'
|
||||||
|
: selectedBooking.payment_status === 'failed'
|
||||||
|
? '❌ Failed'
|
||||||
|
: selectedBooking.payment_status || 'Unpaid'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Usages */}
|
||||||
|
{selectedBooking.service_usages && selectedBooking.service_usages.length > 0 && (
|
||||||
|
<div className="bg-gradient-to-br from-purple-50/50 to-pink-50/50 p-6 rounded-xl border border-purple-100">
|
||||||
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||||
|
<div className="w-1 h-4 bg-gradient-to-b from-purple-400 to-purple-600 rounded-full"></div>
|
||||||
|
Additional Services
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedBooking.service_usages.map((service: any, idx: number) => (
|
||||||
|
<div key={service.id || idx} className="flex justify-between items-center py-2 border-b border-purple-100 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">{service.service_name || service.name || 'Service'}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{formatCurrency(service.unit_price || service.price || 0)} × {service.quantity || 1}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{formatCurrency(service.total_price || (service.unit_price || service.price || 0) * (service.quantity || 1))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Breakdown */}
|
||||||
|
{(() => {
|
||||||
|
const completedPayments = selectedBooking.payments?.filter(
|
||||||
|
(p) => p.payment_status === 'completed'
|
||||||
|
) || [];
|
||||||
|
const allPayments = selectedBooking.payments || [];
|
||||||
|
const amountPaid = completedPayments.reduce(
|
||||||
|
(sum, p) => sum + (p.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const remainingDue = selectedBooking.total_price - amountPaid;
|
||||||
|
const hasPayments = allPayments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasPayments && (
|
||||||
|
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-100">
|
||||||
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||||
|
<div className="w-1 h-4 bg-gradient-to-b from-teal-400 to-teal-600 rounded-full"></div>
|
||||||
|
Payment History
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{allPayments.map((payment: any, idx: number) => (
|
||||||
|
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{formatCurrency(payment.amount || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
|
||||||
|
{' • '}
|
||||||
|
{payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||||
|
payment.payment_status === 'completed' || payment.payment_status === 'paid'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: payment.payment_status === 'pending'
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{payment.transaction_id && (
|
||||||
|
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
|
||||||
|
)}
|
||||||
|
{payment.payment_date && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Payment Summary - Always show, even if no payments */}
|
||||||
|
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||||
|
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||||
|
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||||
|
{formatCurrency(amountPaid)}
|
||||||
|
</p>
|
||||||
|
{hasPayments && completedPayments.length > 0 && (
|
||||||
|
<p className="text-xs text-green-600 mt-2">
|
||||||
|
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
|
||||||
|
{amountPaid > 0 && selectedBooking.total_price > 0 && (
|
||||||
|
<span className="ml-2">
|
||||||
|
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{amountPaid === 0 && !hasPayments && (
|
||||||
|
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remaining Due - Show prominently if there's remaining balance */}
|
||||||
|
{remainingDue > 0 && (
|
||||||
|
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
|
||||||
|
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||||
|
<p className="text-3xl font-bold text-amber-600">
|
||||||
|
{formatCurrency(remainingDue)}
|
||||||
|
</p>
|
||||||
|
{selectedBooking.total_price > 0 && (
|
||||||
|
<p className="text-xs text-amber-600 mt-2">
|
||||||
|
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Total Booking Price - Show as reference */}
|
||||||
|
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||||
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||||
|
<p className="text-2xl font-bold text-slate-700">
|
||||||
|
{formatCurrency(selectedBooking.total_price)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
This is the total amount for the booking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Booking Metadata */}
|
||||||
|
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
||||||
|
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||||
|
<div className="w-1 h-4 bg-gradient-to-b from-slate-400 to-slate-600 rounded-full"></div>
|
||||||
|
Booking Metadata
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{selectedBooking.createdAt && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Created At</p>
|
||||||
|
<p className="text-sm font-medium text-slate-900">
|
||||||
|
{new Date(selectedBooking.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.updatedAt && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Last Updated</p>
|
||||||
|
<p className="text-sm font-medium text-slate-900">
|
||||||
|
{new Date(selectedBooking.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.requires_deposit !== undefined && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Deposit Required</p>
|
||||||
|
<p className="text-sm font-medium text-slate-900">
|
||||||
|
{selectedBooking.requires_deposit ? 'Yes (20%)' : 'No'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.deposit_paid !== undefined && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Deposit Paid</p>
|
||||||
|
<p className={`text-sm font-medium ${selectedBooking.deposit_paid ? 'text-green-600' : 'text-amber-600'}`}>
|
||||||
|
{selectedBooking.deposit_paid ? '✅ Yes' : '❌ No'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
|
|||||||
@@ -742,6 +742,7 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Booking Number</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Booking Number</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Customer</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Customer</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Method</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Method</th>
|
||||||
|
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Type</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Amount</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Amount</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Payment Date</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Payment Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -756,11 +757,28 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
<div className="text-sm font-semibold text-emerald-600">{payment.booking?.booking_number}</div>
|
<div className="text-sm font-semibold text-emerald-600">{payment.booking?.booking_number}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-8 py-5 whitespace-nowrap">
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{payment.booking?.user?.name}</div>
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{payment.booking?.user?.name || payment.booking?.user?.full_name || 'N/A'}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-8 py-5 whitespace-nowrap">
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
{getPaymentMethodBadge(payment.payment_method)}
|
{getPaymentMethodBadge(payment.payment_method)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
|
{payment.payment_type === 'deposit' ? (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||||
|
Deposit (20%)
|
||||||
|
</span>
|
||||||
|
) : payment.payment_type === 'remaining' ? (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
|
||||||
|
Remaining
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||||
|
Full Payment
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-8 py-5 whitespace-nowrap">
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||||
{formatCurrency(payment.amount)}
|
{formatCurrency(payment.amount)}
|
||||||
|
|||||||
@@ -35,7 +35,17 @@ const CheckInPage: React.FC = () => {
|
|||||||
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
||||||
setBooking(response.data.booking);
|
setBooking(response.data.booking);
|
||||||
setActualRoomNumber(response.data.booking.room?.room_number || '');
|
setActualRoomNumber(response.data.booking.room?.room_number || '');
|
||||||
toast.success('Booking found');
|
|
||||||
|
// Show warning if there's remaining balance
|
||||||
|
if ((response as any).warning) {
|
||||||
|
const warning = (response as any).warning;
|
||||||
|
toast.warning(
|
||||||
|
`⚠️ Payment Reminder: Guest has remaining balance of ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
|
||||||
|
{ autoClose: 8000 }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success('Booking found');
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.response?.data?.message || 'Booking not found');
|
toast.error(error.response?.data?.message || 'Booking not found');
|
||||||
setBooking(null);
|
setBooking(null);
|
||||||
@@ -89,12 +99,21 @@ const CheckInPage: React.FC = () => {
|
|||||||
// Calculate additional fee
|
// Calculate additional fee
|
||||||
calculateAdditionalFee();
|
calculateAdditionalFee();
|
||||||
|
|
||||||
await bookingService.updateBooking(booking.id, {
|
const response = await bookingService.updateBooking(booking.id, {
|
||||||
status: 'checked_in',
|
status: 'checked_in',
|
||||||
// Can send additional data about guests, room_number, additional_fee
|
// Can send additional data about guests, room_number, additional_fee
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
toast.success('Check-in successful');
|
// Show warning if there's remaining balance
|
||||||
|
if ((response as any).warning) {
|
||||||
|
const warning = (response as any).warning;
|
||||||
|
toast.warning(
|
||||||
|
`⚠️ Check-in successful, but guest has remaining balance: ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
|
||||||
|
{ autoClose: 10000 }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success('Check-in successful');
|
||||||
|
}
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setBooking(null);
|
setBooking(null);
|
||||||
@@ -201,6 +220,150 @@ const CheckInPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Warning Alert */}
|
||||||
|
{booking.payment_balance && booking.payment_balance.remaining_balance > 0.01 && (
|
||||||
|
<div className="mt-6 p-4 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg border-2 border-amber-400">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-6 h-6 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-bold text-amber-900 mb-2">
|
||||||
|
⚠️ Payment Reminder
|
||||||
|
</h3>
|
||||||
|
<p className="text-amber-800 mb-3">
|
||||||
|
This guest has <strong>not fully paid</strong> for their booking. Please collect the remaining balance during check-in.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4 bg-white/50 p-3 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-amber-700">Total Price:</span>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{formatCurrency(booking.payment_balance.total_price)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-amber-700">Amount Paid:</span>
|
||||||
|
<p className="text-lg font-bold text-green-600">{formatCurrency(booking.payment_balance.total_paid)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-amber-700">Payment Progress:</span>
|
||||||
|
<p className="text-lg font-bold text-blue-600">{booking.payment_balance.payment_percentage.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-amber-700">Remaining Balance:</span>
|
||||||
|
<p className="text-xl font-bold text-red-600">{formatCurrency(booking.payment_balance.remaining_balance)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Information */}
|
||||||
|
<div className="mt-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||||
|
<h3 className="text-md font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
Payment Information
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Payment Method:</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{booking.payment_method === 'cash'
|
||||||
|
? '💵 Pay at Hotel'
|
||||||
|
: booking.payment_method === 'stripe'
|
||||||
|
? '💳 Stripe (Card)'
|
||||||
|
: booking.payment_method === 'paypal'
|
||||||
|
? '💳 PayPal'
|
||||||
|
: booking.payment_method || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Payment Status:</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
booking.payment_status === 'paid' || booking.payment_status === 'completed'
|
||||||
|
? 'text-green-600'
|
||||||
|
: booking.payment_status === 'pending'
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{booking.payment_status === 'paid' || booking.payment_status === 'completed'
|
||||||
|
? '✅ Paid'
|
||||||
|
: booking.payment_status === 'pending'
|
||||||
|
? '⏳ Pending'
|
||||||
|
: booking.payment_status === 'failed'
|
||||||
|
? '❌ Failed'
|
||||||
|
: booking.payment_status || 'Unpaid'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(() => {
|
||||||
|
// Use payment_balance from API if available, otherwise calculate from payments
|
||||||
|
const paymentBalance = booking.payment_balance || (() => {
|
||||||
|
const completedPayments = booking.payments?.filter(
|
||||||
|
(p) => p.payment_status === 'completed'
|
||||||
|
) || [];
|
||||||
|
const amountPaid = completedPayments.reduce(
|
||||||
|
(sum, p) => sum + (p.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const remainingDue = booking.total_price - amountPaid;
|
||||||
|
return {
|
||||||
|
total_paid: amountPaid,
|
||||||
|
total_price: booking.total_price,
|
||||||
|
remaining_balance: remainingDue,
|
||||||
|
is_fully_paid: remainingDue <= 0.01,
|
||||||
|
payment_percentage: booking.total_price > 0 ? (amountPaid / booking.total_price * 100) : 0
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const completedPayments = booking.payments?.filter(
|
||||||
|
(p) => p.payment_status === 'completed'
|
||||||
|
) || [];
|
||||||
|
const hasPayments = completedPayments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Total Price:</span>
|
||||||
|
<span className="font-semibold text-gray-900">{formatCurrency(paymentBalance.total_price)}</span>
|
||||||
|
</div>
|
||||||
|
{hasPayments && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Amount Paid:</span>
|
||||||
|
<span className="font-semibold text-green-600">{formatCurrency(paymentBalance.total_paid)}</span>
|
||||||
|
</div>
|
||||||
|
{paymentBalance.remaining_balance > 0.01 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Remaining Due:</span>
|
||||||
|
<span className="font-semibold text-red-600">{formatCurrency(paymentBalance.remaining_balance)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{completedPayments.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-green-200">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Payment Details:</p>
|
||||||
|
{completedPayments.map((payment, idx) => (
|
||||||
|
<div key={payment.id || idx} className="text-xs text-gray-600">
|
||||||
|
• {formatCurrency(payment.amount)} via {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||||
|
{payment.payment_type === 'deposit' && ' (Deposit 20%)'}
|
||||||
|
{payment.transaction_id && ` - ${payment.transaction_id}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{booking.status !== 'confirmed' && (
|
{booking.status !== 'confirmed' && (
|
||||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||||
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ const PaymentManagementPage: React.FC = () => {
|
|||||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
|
||||||
|
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
|
||||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
|
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -181,6 +182,21 @@ const PaymentManagementPage: React.FC = () => {
|
|||||||
<td className="px-8 py-5 whitespace-nowrap">
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
{getMethodBadge(payment.payment_method)}
|
{getMethodBadge(payment.payment_method)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
|
{payment.payment_type === 'deposit' ? (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||||
|
Deposit (20%)
|
||||||
|
</span>
|
||||||
|
) : payment.payment_type === 'remaining' ? (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
|
||||||
|
Remaining
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||||
|
Full Payment
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-8 py-5 whitespace-nowrap">
|
<td className="px-8 py-5 whitespace-nowrap">
|
||||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||||
{formatCurrency(payment.amount)}
|
{formatCurrency(payment.amount)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
LogIn,
|
LogIn,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -189,6 +189,14 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate additional fee when extraPersons or children change
|
||||||
|
useEffect(() => {
|
||||||
|
const extraPersonFee = extraPersons * 200000;
|
||||||
|
const childrenFee = children * 100000;
|
||||||
|
const total = extraPersonFee + childrenFee;
|
||||||
|
setAdditionalFee(total);
|
||||||
|
}, [extraPersons, children]);
|
||||||
|
|
||||||
const handleCheckIn = async () => {
|
const handleCheckIn = async () => {
|
||||||
if (!checkInBooking) return;
|
if (!checkInBooking) return;
|
||||||
|
|
||||||
@@ -272,8 +280,11 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateDeposit = () => {
|
const calculateTotalPaid = () => {
|
||||||
return checkOutBooking?.total_price ? checkOutBooking.total_price * 0.3 : 0;
|
if (!checkOutBooking?.payments) return 0;
|
||||||
|
return checkOutBooking.payments
|
||||||
|
.filter(payment => payment.payment_status === 'completed')
|
||||||
|
.reduce((sum, payment) => sum + (payment.amount || 0), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateSubtotal = () => {
|
const calculateSubtotal = () => {
|
||||||
@@ -285,7 +296,9 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calculateRemaining = () => {
|
const calculateRemaining = () => {
|
||||||
return calculateTotal() - calculateDeposit();
|
const total = calculateTotal();
|
||||||
|
const totalPaid = calculateTotalPaid();
|
||||||
|
return total - totalPaid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckOut = async () => {
|
const handleCheckOut = async () => {
|
||||||
@@ -326,17 +339,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Bookings Management Functions
|
// Bookings Management Functions
|
||||||
useEffect(() => {
|
const fetchBookings = useCallback(async () => {
|
||||||
setBookingCurrentPage(1);
|
|
||||||
}, [bookingFilters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'bookings') {
|
|
||||||
fetchBookings();
|
|
||||||
}
|
|
||||||
}, [bookingFilters, bookingCurrentPage, activeTab]);
|
|
||||||
|
|
||||||
const fetchBookings = async () => {
|
|
||||||
try {
|
try {
|
||||||
setBookingsLoading(true);
|
setBookingsLoading(true);
|
||||||
const response = await bookingService.getAllBookings({
|
const response = await bookingService.getAllBookings({
|
||||||
@@ -354,7 +357,17 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setBookingsLoading(false);
|
setBookingsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [bookingFilters.search, bookingFilters.status, bookingCurrentPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBookingCurrentPage(1);
|
||||||
|
}, [bookingFilters.search, bookingFilters.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'bookings') {
|
||||||
|
fetchBookings();
|
||||||
|
}
|
||||||
|
}, [activeTab, fetchBookings]);
|
||||||
|
|
||||||
const handleUpdateBookingStatus = async (id: number, status: string) => {
|
const handleUpdateBookingStatus = async (id: number, status: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -426,19 +439,63 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Rooms Management Functions
|
// Rooms Management Functions
|
||||||
|
const fetchAvailableAmenities = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await roomService.getAmenities();
|
||||||
|
if (response.data?.amenities) {
|
||||||
|
setAvailableAmenities(response.data.amenities);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch amenities:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRooms = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRoomsLoading(true);
|
||||||
|
const response = await roomService.getRooms({
|
||||||
|
...roomFilters,
|
||||||
|
page: roomCurrentPage,
|
||||||
|
limit: roomItemsPerPage,
|
||||||
|
});
|
||||||
|
setRooms(response.data.rooms);
|
||||||
|
if (response.data.pagination) {
|
||||||
|
setRoomTotalPages(response.data.pagination.totalPages);
|
||||||
|
setRoomTotalItems(response.data.pagination.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||||
|
response.data.rooms.forEach((room: Room) => {
|
||||||
|
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
|
||||||
|
uniqueRoomTypes.set(room.room_type.id, {
|
||||||
|
id: room.room_type.id,
|
||||||
|
name: room.room_type.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setRoomTypes(Array.from(uniqueRoomTypes.values()));
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Unable to load rooms list');
|
||||||
|
} finally {
|
||||||
|
setRoomsLoading(false);
|
||||||
|
}
|
||||||
|
}, [roomFilters.search, roomFilters.status, roomFilters.type, roomCurrentPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRoomCurrentPage(1);
|
setRoomCurrentPage(1);
|
||||||
setSelectedRooms([]);
|
setSelectedRooms([]);
|
||||||
}, [roomFilters]);
|
}, [roomFilters.search, roomFilters.status, roomFilters.type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'rooms') {
|
if (activeTab === 'rooms') {
|
||||||
fetchRooms();
|
fetchRooms();
|
||||||
fetchAvailableAmenities();
|
fetchAvailableAmenities();
|
||||||
}
|
}
|
||||||
}, [roomFilters, roomCurrentPage, activeTab]);
|
}, [activeTab, fetchRooms, fetchAvailableAmenities]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'rooms') return;
|
||||||
|
|
||||||
const fetchAllRoomTypes = async () => {
|
const fetchAllRoomTypes = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||||
@@ -474,60 +531,21 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
if (allUniqueRoomTypes.size > 0) {
|
if (allUniqueRoomTypes.size > 0) {
|
||||||
const roomTypesList = Array.from(allUniqueRoomTypes.values());
|
const roomTypesList = Array.from(allUniqueRoomTypes.values());
|
||||||
setRoomTypes(roomTypesList);
|
setRoomTypes(roomTypesList);
|
||||||
if (!editingRoom && roomFormData.room_type_id === 1 && roomTypesList.length > 0) {
|
setRoomFormData(prev => {
|
||||||
setRoomFormData(prev => ({ ...prev, room_type_id: roomTypesList[0].id }));
|
if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
|
||||||
}
|
return { ...prev, room_type_id: roomTypesList[0].id };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch room types:', err);
|
console.error('Failed to fetch room types:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (activeTab === 'rooms') {
|
|
||||||
fetchAllRoomTypes();
|
fetchAllRoomTypes();
|
||||||
}
|
}, [activeTab, editingRoom]);
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
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) => {
|
const handleRoomSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -866,17 +884,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Services Management Functions
|
// Services Management Functions
|
||||||
useEffect(() => {
|
const fetchServices = useCallback(async () => {
|
||||||
setServiceCurrentPage(1);
|
|
||||||
}, [serviceFilters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'services') {
|
|
||||||
fetchServices();
|
|
||||||
}
|
|
||||||
}, [serviceFilters, serviceCurrentPage, activeTab]);
|
|
||||||
|
|
||||||
const fetchServices = async () => {
|
|
||||||
try {
|
try {
|
||||||
setServicesLoading(true);
|
setServicesLoading(true);
|
||||||
const response = await serviceService.getServices({
|
const response = await serviceService.getServices({
|
||||||
@@ -894,7 +902,17 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setServicesLoading(false);
|
setServicesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [serviceFilters.search, serviceFilters.status, serviceCurrentPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServiceCurrentPage(1);
|
||||||
|
}, [serviceFilters.search, serviceFilters.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'services') {
|
||||||
|
fetchServices();
|
||||||
|
}
|
||||||
|
}, [activeTab, fetchServices]);
|
||||||
|
|
||||||
const handleServiceSubmit = async (e: React.FormEvent) => {
|
const handleServiceSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1311,6 +1329,100 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Information */}
|
||||||
|
<div className="mt-6 p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-200">
|
||||||
|
<h3 className="text-md font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
Payment Information
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-green-100">
|
||||||
|
<span className="text-gray-600 font-medium">Payment Method:</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{checkInBooking.payment_method === 'cash'
|
||||||
|
? '💵 Pay at Hotel'
|
||||||
|
: checkInBooking.payment_method === 'stripe'
|
||||||
|
? '💳 Stripe (Card)'
|
||||||
|
: checkInBooking.payment_method === 'paypal'
|
||||||
|
? '💳 PayPal'
|
||||||
|
: checkInBooking.payment_method || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-2">
|
||||||
|
<span className="text-gray-600 font-medium">Payment Status:</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
|
||||||
|
? 'text-green-600'
|
||||||
|
: checkInBooking.payment_status === 'pending'
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
|
||||||
|
? '✅ Paid'
|
||||||
|
: checkInBooking.payment_status === 'pending'
|
||||||
|
? '⏳ Pending'
|
||||||
|
: checkInBooking.payment_status === 'failed'
|
||||||
|
? '❌ Failed'
|
||||||
|
: checkInBooking.payment_status || 'Unpaid'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(() => {
|
||||||
|
const completedPayments = checkInBooking.payments?.filter(
|
||||||
|
(p) => p.payment_status === 'completed'
|
||||||
|
) || [];
|
||||||
|
const amountPaid = completedPayments.reduce(
|
||||||
|
(sum, p) => sum + (p.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const remainingDue = checkInBooking.total_price - amountPaid;
|
||||||
|
const hasPayments = completedPayments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-green-100">
|
||||||
|
<span className="text-gray-600 font-medium">Total Price:</span>
|
||||||
|
<span className="font-semibold text-gray-900">{formatCurrency(checkInBooking.total_price)}</span>
|
||||||
|
</div>
|
||||||
|
{hasPayments && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-green-100">
|
||||||
|
<span className="text-gray-600 font-medium">Amount Paid:</span>
|
||||||
|
<span className="font-semibold text-green-600">{formatCurrency(amountPaid)}</span>
|
||||||
|
</div>
|
||||||
|
{remainingDue > 0 && (
|
||||||
|
<div className="flex justify-between items-center py-2">
|
||||||
|
<span className="text-gray-600 font-medium">Remaining Due:</span>
|
||||||
|
<span className="font-semibold text-amber-600">{formatCurrency(remainingDue)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{completedPayments.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-green-200">
|
||||||
|
<p className="text-xs text-gray-500 mb-2 font-medium">Payment Details:</p>
|
||||||
|
{completedPayments.map((payment, idx) => (
|
||||||
|
<div key={payment.id || idx} className="text-xs text-gray-600 mb-1">
|
||||||
|
• {formatCurrency(payment.amount)} via {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||||
|
{payment.payment_type === 'deposit' && ' (Deposit 20%)'}
|
||||||
|
{payment.transaction_id && ` - ${payment.transaction_id}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{checkInBooking.status !== 'confirmed' && (
|
{checkInBooking.status !== 'confirmed' && (
|
||||||
<div className="mt-6 p-4 bg-gradient-to-br from-amber-50 to-yellow-50 border border-amber-200 rounded-xl flex items-start gap-3">
|
<div className="mt-6 p-4 bg-gradient-to-br from-amber-50 to-yellow-50 border border-amber-200 rounded-xl flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
@@ -1458,7 +1570,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="block text-sm font-semibold text-gray-900">Total Additional Fee</label>
|
<label className="block text-sm font-semibold text-gray-900">Total Additional Fee</label>
|
||||||
<div className="px-4 py-3.5 bg-gradient-to-br from-emerald-50 to-green-50 border-2 border-emerald-200 rounded-xl text-lg font-bold text-emerald-600">
|
<div className="px-4 py-3.5 bg-gradient-to-br from-emerald-50 to-green-50 border-2 border-emerald-200 rounded-xl text-lg font-bold text-emerald-600">
|
||||||
{formatCurrency(calculateCheckInAdditionalFee())}
|
{formatCurrency(additionalFee)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1693,10 +1805,12 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span>{formatCurrency(calculateTotal())}</span>
|
<span>{formatCurrency(calculateTotal())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-lg text-gray-600">
|
{calculateTotalPaid() > 0 && (
|
||||||
<span>Deposit paid:</span>
|
<div className="flex justify-between items-center text-lg text-gray-600">
|
||||||
<span className="font-semibold">-{formatCurrency(calculateDeposit())}</span>
|
<span>Total paid:</span>
|
||||||
</div>
|
<span className="font-semibold">-{formatCurrency(calculateTotalPaid())}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between items-center text-3xl font-extrabold text-emerald-600 pt-4 border-t-2 border-gray-300">
|
<div className="flex justify-between items-center text-3xl font-extrabold text-emerald-600 pt-4 border-t-2 border-gray-300">
|
||||||
<span>Remaining payment:</span>
|
<span>Remaining payment:</span>
|
||||||
<span>{formatCurrency(calculateRemaining())}</span>
|
<span>{formatCurrency(calculateRemaining())}</span>
|
||||||
@@ -2075,13 +2189,178 @@ const ReceptionDashboardPage: React.FC = () => {
|
|||||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg">
|
{/* Payment Method & Status */}
|
||||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Total Price</label>
|
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
|
||||||
<p className="text-4xl font-bold bg-gradient-to-r from-amber-600 via-amber-700 to-amber-600 bg-clip-text text-transparent">
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||||
{formatCurrency(selectedBooking.total_price)}
|
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
|
||||||
</p>
|
Payment Information
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
|
||||||
|
<p className="text-base font-semibold text-slate-900">
|
||||||
|
{selectedBooking.payment_method === 'cash'
|
||||||
|
? '💵 Pay at Hotel'
|
||||||
|
: selectedBooking.payment_method === 'stripe'
|
||||||
|
? '💳 Stripe (Card)'
|
||||||
|
: selectedBooking.payment_method === 'paypal'
|
||||||
|
? '💳 PayPal'
|
||||||
|
: selectedBooking.payment_method || 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
|
||||||
|
<p className={`text-base font-semibold ${
|
||||||
|
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||||
|
? 'text-green-600'
|
||||||
|
: selectedBooking.payment_status === 'pending'
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||||
|
? '✅ Paid'
|
||||||
|
: selectedBooking.payment_status === 'pending'
|
||||||
|
? '⏳ Pending'
|
||||||
|
: selectedBooking.payment_status === 'failed'
|
||||||
|
? '❌ Failed'
|
||||||
|
: selectedBooking.payment_status || 'Unpaid'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment History */}
|
||||||
|
{selectedBooking.payments && selectedBooking.payments.length > 0 && (
|
||||||
|
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-100">
|
||||||
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||||
|
<div className="w-1 h-4 bg-gradient-to-b from-teal-400 to-teal-600 rounded-full"></div>
|
||||||
|
Payment History
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedBooking.payments.map((payment: any, idx: number) => (
|
||||||
|
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{formatCurrency(payment.amount || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
|
||||||
|
{' • '}
|
||||||
|
{payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||||
|
payment.payment_status === 'completed' || payment.payment_status === 'paid'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: payment.payment_status === 'pending'
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{payment.transaction_id && (
|
||||||
|
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
|
||||||
|
)}
|
||||||
|
{payment.payment_date && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Breakdown */}
|
||||||
|
{(() => {
|
||||||
|
const completedPayments = selectedBooking.payments?.filter(
|
||||||
|
(p) => p.payment_status === 'completed'
|
||||||
|
) || [];
|
||||||
|
const amountPaid = completedPayments.reduce(
|
||||||
|
(sum, p) => sum + (p.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const remainingDue = selectedBooking.total_price - amountPaid;
|
||||||
|
const hasPayments = selectedBooking.payments && selectedBooking.payments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Payment Summary */}
|
||||||
|
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||||
|
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||||
|
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||||
|
{formatCurrency(amountPaid)}
|
||||||
|
</p>
|
||||||
|
{hasPayments && completedPayments.length > 0 && (
|
||||||
|
<p className="text-xs text-green-600 mt-2">
|
||||||
|
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
|
||||||
|
{amountPaid > 0 && selectedBooking.total_price > 0 && (
|
||||||
|
<span className="ml-2">
|
||||||
|
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{amountPaid === 0 && !hasPayments && (
|
||||||
|
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remaining Due */}
|
||||||
|
{remainingDue > 0 && (
|
||||||
|
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
|
||||||
|
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||||
|
<p className="text-3xl font-bold text-amber-600">
|
||||||
|
{formatCurrency(remainingDue)}
|
||||||
|
</p>
|
||||||
|
{selectedBooking.total_price > 0 && (
|
||||||
|
<p className="text-xs text-amber-600 mt-2">
|
||||||
|
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Total Booking Price */}
|
||||||
|
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||||
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||||
|
{selectedBooking.original_price && selectedBooking.discount_amount && selectedBooking.discount_amount > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-sm text-slate-600">Subtotal:</span>
|
||||||
|
<span className="text-lg font-semibold text-slate-700">{formatCurrency(selectedBooking.original_price)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-green-600">
|
||||||
|
Discount{selectedBooking.promotion_code ? ` (${selectedBooking.promotion_code})` : ''}:
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-semibold text-green-600">-{formatCurrency(selectedBooking.discount_amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-300 pt-2 mt-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-semibold text-slate-700">Total:</span>
|
||||||
|
<span className="text-2xl font-bold text-slate-700">{formatCurrency(selectedBooking.total_price)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-bold text-slate-700">
|
||||||
|
{formatCurrency(selectedBooking.total_price)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
This is the total amount for the booking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{selectedBooking.notes && (
|
{selectedBooking.notes && (
|
||||||
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
||||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label>
|
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label>
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ import systemSettingsService, {
|
|||||||
CompanySettingsResponse,
|
CompanySettingsResponse,
|
||||||
UpdateCompanySettingsRequest,
|
UpdateCompanySettingsRequest,
|
||||||
} from '../../services/api/systemSettingsService';
|
} from '../../services/api/systemSettingsService';
|
||||||
|
import { recaptchaService, RecaptchaSettingsAdminResponse, UpdateRecaptchaSettingsRequest } from '../../services/api/systemSettingsService';
|
||||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||||
import { Loading } from '../../components/common';
|
import { Loading } from '../../components/common';
|
||||||
import { getCurrencySymbol } from '../../utils/format';
|
import { getCurrencySymbol } from '../../utils/format';
|
||||||
|
|
||||||
type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company';
|
type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company' | 'recaptcha';
|
||||||
|
|
||||||
const SettingsPage: React.FC = () => {
|
const SettingsPage: React.FC = () => {
|
||||||
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
|
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
|
||||||
@@ -105,12 +106,22 @@ const SettingsPage: React.FC = () => {
|
|||||||
company_phone: '',
|
company_phone: '',
|
||||||
company_email: '',
|
company_email: '',
|
||||||
company_address: '',
|
company_address: '',
|
||||||
|
tax_rate: 0,
|
||||||
});
|
});
|
||||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||||
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
||||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||||
|
|
||||||
|
// reCAPTCHA Settings State
|
||||||
|
const [recaptchaSettings, setRecaptchaSettings] = useState<RecaptchaSettingsAdminResponse['data'] | null>(null);
|
||||||
|
const [recaptchaFormData, setRecaptchaFormData] = useState<UpdateRecaptchaSettingsRequest>({
|
||||||
|
recaptcha_site_key: '',
|
||||||
|
recaptcha_secret_key: '',
|
||||||
|
recaptcha_enabled: false,
|
||||||
|
});
|
||||||
|
const [showRecaptchaSecret, setShowRecaptchaSecret] = useState(false);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -146,6 +157,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
if (activeTab === 'company') {
|
if (activeTab === 'company') {
|
||||||
loadCompanySettings();
|
loadCompanySettings();
|
||||||
}
|
}
|
||||||
|
if (activeTab === 'recaptcha') {
|
||||||
|
loadRecaptchaSettings();
|
||||||
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -219,6 +233,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
company_phone: companyRes.data.company_phone || '',
|
company_phone: companyRes.data.company_phone || '',
|
||||||
company_email: companyRes.data.company_email || '',
|
company_email: companyRes.data.company_email || '',
|
||||||
company_address: companyRes.data.company_address || '',
|
company_address: companyRes.data.company_address || '',
|
||||||
|
tax_rate: companyRes.data.tax_rate || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set previews if URLs exist
|
// Set previews if URLs exist
|
||||||
@@ -579,6 +594,41 @@ const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadRecaptchaSettings = async () => {
|
||||||
|
try {
|
||||||
|
const recaptchaRes = await recaptchaService.getRecaptchaSettingsAdmin();
|
||||||
|
setRecaptchaSettings(recaptchaRes.data);
|
||||||
|
setRecaptchaFormData({
|
||||||
|
recaptcha_site_key: recaptchaRes.data.recaptcha_site_key || '',
|
||||||
|
recaptcha_secret_key: '',
|
||||||
|
recaptcha_enabled: recaptchaRes.data.recaptcha_enabled || false,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
error.response?.data?.message ||
|
||||||
|
'Failed to load reCAPTCHA settings'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveRecaptcha = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await recaptchaService.updateRecaptchaSettings(recaptchaFormData);
|
||||||
|
toast.success('reCAPTCHA settings saved successfully');
|
||||||
|
await loadRecaptchaSettings();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
error.response?.data?.message ||
|
||||||
|
'Failed to save reCAPTCHA settings'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loading fullScreen={false} text="Loading settings..." />;
|
return <Loading fullScreen={false} text="Loading settings..." />;
|
||||||
}
|
}
|
||||||
@@ -590,6 +640,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
{ id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard },
|
{ id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard },
|
||||||
{ id: 'smtp' as SettingsTab, label: 'Email Server', icon: Mail },
|
{ id: 'smtp' as SettingsTab, label: 'Email Server', icon: Mail },
|
||||||
{ id: 'company' as SettingsTab, label: 'Company Info', icon: Building2 },
|
{ id: 'company' as SettingsTab, label: 'Company Info', icon: Building2 },
|
||||||
|
{ id: 'recaptcha' as SettingsTab, label: 'reCAPTCHA', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2154,6 +2205,29 @@ const SettingsPage: React.FC = () => {
|
|||||||
Physical address of your company or hotel
|
Physical address of your company or hotel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tax Rate */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||||
|
<DollarSign className="w-4 h-4 text-gray-600" />
|
||||||
|
Tax Rate (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={companyFormData.tax_rate || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCompanyFormData({ ...companyFormData, tax_rate: parseFloat(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Default tax rate percentage to be applied to all invoices (e.g., 10 for 10%). This will be used for all bookings unless overridden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2178,6 +2252,152 @@ const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'recaptcha' && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||||
|
<Shield className="w-6 h-6 text-amber-600" />
|
||||||
|
Google reCAPTCHA Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Configure Google reCAPTCHA to protect your forms from spam and abuse
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* reCAPTCHA Settings Form */}
|
||||||
|
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-900">
|
||||||
|
Enable reCAPTCHA
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Enable or disable reCAPTCHA verification across all forms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={recaptchaFormData.recaptcha_enabled || false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRecaptchaFormData({
|
||||||
|
...recaptchaFormData,
|
||||||
|
recaptcha_enabled: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-amber-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||||
|
<Key className="w-4 h-4 text-gray-600" />
|
||||||
|
reCAPTCHA Site Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recaptchaFormData.recaptcha_site_key || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRecaptchaFormData({
|
||||||
|
...recaptchaFormData,
|
||||||
|
recaptcha_site_key: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
|
||||||
|
className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Your reCAPTCHA site key from Google. Get it from{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/recaptcha/admin"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-amber-600 hover:underline"
|
||||||
|
>
|
||||||
|
Google reCAPTCHA Admin
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secret Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||||
|
<Lock className="w-4 h-4 text-gray-600" />
|
||||||
|
reCAPTCHA Secret Key
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showRecaptchaSecret ? 'text' : 'password'}
|
||||||
|
value={recaptchaFormData.recaptcha_secret_key || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRecaptchaFormData({
|
||||||
|
...recaptchaFormData,
|
||||||
|
recaptcha_secret_key: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={recaptchaSettings?.recaptcha_secret_key_masked || 'Enter secret key'}
|
||||||
|
className="w-full px-4 py-3.5 pr-12 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRecaptchaSecret(!showRecaptchaSecret)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{showRecaptchaSecret ? (
|
||||||
|
<EyeOff className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Your reCAPTCHA secret key (keep this secure). Leave empty to keep existing value.
|
||||||
|
</p>
|
||||||
|
{recaptchaSettings?.recaptcha_secret_key_masked && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
Current: {recaptchaSettings.recaptcha_secret_key_masked}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-semibold mb-1">About reCAPTCHA</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
reCAPTCHA protects your forms from spam and abuse. You can use reCAPTCHA v2 (checkbox) or v3 (invisible).
|
||||||
|
Make sure to use the same version for both site key and secret key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveRecaptcha}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
{saving ? 'Saving...' : 'Save reCAPTCHA Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
} from '../../utils/validationSchemas';
|
} from '../../utils/validationSchemas';
|
||||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||||
import * as yup from 'yup';
|
import * as yup from 'yup';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import Recaptcha from '../../components/common/Recaptcha';
|
||||||
|
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||||
|
|
||||||
const mfaTokenSchema = yup.object().shape({
|
const mfaTokenSchema = yup.object().shape({
|
||||||
mfaToken: yup
|
mfaToken: yup
|
||||||
@@ -41,6 +44,7 @@ const LoginPage: React.FC = () => {
|
|||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
|
||||||
// MFA form setup
|
// MFA form setup
|
||||||
const {
|
const {
|
||||||
@@ -78,6 +82,23 @@ const LoginPage: React.FC = () => {
|
|||||||
const onSubmit = async (data: LoginFormData) => {
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
try {
|
try {
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
|
// Verify reCAPTCHA if enabled
|
||||||
|
if (recaptchaToken) {
|
||||||
|
try {
|
||||||
|
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||||
|
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await login({
|
await login({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
@@ -91,9 +112,11 @@ const LoginPage: React.FC = () => {
|
|||||||
'/dashboard';
|
'/dashboard';
|
||||||
navigate(from, { replace: true });
|
navigate(from, { replace: true });
|
||||||
}
|
}
|
||||||
|
setRecaptchaToken(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error has been handled in store
|
// Error has been handled in store
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
|
setRecaptchaToken(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,6 +414,19 @@ const LoginPage: React.FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* reCAPTCHA */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Recaptcha
|
||||||
|
onChange={(token) => setRecaptchaToken(token)}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('reCAPTCHA error:', error);
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
}}
|
||||||
|
theme="light"
|
||||||
|
size="normal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
RegisterFormData,
|
RegisterFormData,
|
||||||
} from '../../utils/validationSchemas';
|
} from '../../utils/validationSchemas';
|
||||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import Recaptcha from '../../components/common/Recaptcha';
|
||||||
|
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||||
|
|
||||||
const RegisterPage: React.FC = () => {
|
const RegisterPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -32,6 +35,7 @@ const RegisterPage: React.FC = () => {
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] =
|
const [showConfirmPassword, setShowConfirmPassword] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
|
||||||
// Update page title
|
// Update page title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,6 +91,23 @@ const RegisterPage: React.FC = () => {
|
|||||||
const onSubmit = async (data: RegisterFormData) => {
|
const onSubmit = async (data: RegisterFormData) => {
|
||||||
try {
|
try {
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
|
// Verify reCAPTCHA if enabled
|
||||||
|
if (recaptchaToken) {
|
||||||
|
try {
|
||||||
|
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||||
|
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await registerUser({
|
await registerUser({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
@@ -96,9 +117,11 @@ const RegisterPage: React.FC = () => {
|
|||||||
|
|
||||||
// Redirect to login page
|
// Redirect to login page
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
|
setRecaptchaToken(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error has been handled in store
|
// Error has been handled in store
|
||||||
console.error('Register error:', error);
|
console.error('Register error:', error);
|
||||||
|
setRecaptchaToken(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -443,6 +466,19 @@ const RegisterPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* reCAPTCHA */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Recaptcha
|
||||||
|
onChange={(token) => setRecaptchaToken(token)}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('reCAPTCHA error:', error);
|
||||||
|
setRecaptchaToken(null);
|
||||||
|
}}
|
||||||
|
theme="light"
|
||||||
|
size="normal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ const BookingDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payment Method */}
|
{/* Payment Method & Status */}
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<p className="text-sm text-gray-600 mb-1">
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
<CreditCard className="w-4 h-4 inline mr-1" />
|
<CreditCard className="w-4 h-4 inline mr-1" />
|
||||||
@@ -529,6 +529,70 @@ const BookingDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment History */}
|
||||||
|
{booking.payments && booking.payments.length > 0 && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Payment History
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{booking.payments.map((payment: any, index: number) => (
|
||||||
|
<div key={payment.id || index} className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg font-bold text-green-700">
|
||||||
|
{formatPrice(payment.amount || 0)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||||
|
payment.payment_status === 'completed' || payment.payment_status === 'paid'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: payment.payment_status === 'pending'
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">Payment Method:</span>{' '}
|
||||||
|
{payment.payment_method === 'stripe' ? '💳 Stripe (Card)' :
|
||||||
|
payment.payment_method === 'paypal' ? '💳 PayPal' :
|
||||||
|
payment.payment_method === 'cash' ? '💵 Cash' :
|
||||||
|
payment.payment_method || 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">Payment Type:</span>{' '}
|
||||||
|
{payment.payment_type === 'deposit' ? 'Deposit (20%)' :
|
||||||
|
payment.payment_type === 'remaining' ? 'Remaining Payment' :
|
||||||
|
'Full Payment'}
|
||||||
|
</p>
|
||||||
|
{payment.transaction_id && (
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
Transaction ID: {payment.transaction_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payment.payment_date && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Paid on: {new Date(payment.payment_date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Price Breakdown */}
|
{/* Price Breakdown */}
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
@@ -579,17 +643,59 @@ const BookingDetailPage: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Total */}
|
{/* Payment Breakdown */}
|
||||||
<div className="border-t pt-3 mt-3">
|
{(() => {
|
||||||
<div className="flex justify-between items-center">
|
// Calculate amount paid from completed payments
|
||||||
<span className="text-lg font-semibold text-gray-900">
|
const completedPayments = booking.payments?.filter(
|
||||||
Total Payment
|
(p) => p.payment_status === 'completed'
|
||||||
</span>
|
) || [];
|
||||||
<span className="text-2xl font-bold text-indigo-600">
|
const amountPaid = completedPayments.reduce(
|
||||||
{formatPrice(booking.total_price)}
|
(sum, p) => sum + (p.amount || 0),
|
||||||
</span>
|
0
|
||||||
</div>
|
);
|
||||||
</div>
|
const remainingDue = booking.total_price - amountPaid;
|
||||||
|
const hasPayments = completedPayments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasPayments && (
|
||||||
|
<>
|
||||||
|
<div className="border-t pt-3 mt-3">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
Amount Paid:
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-semibold text-green-600">
|
||||||
|
{formatPrice(amountPaid)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{remainingDue > 0 && (
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
Remaining Due:
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-semibold text-amber-600">
|
||||||
|
{formatPrice(remainingDue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Total */}
|
||||||
|
<div className="border-t pt-3 mt-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900">
|
||||||
|
Total Booking Price
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-indigo-600">
|
||||||
|
{formatPrice(booking.total_price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -484,20 +484,33 @@ const BookingSuccessPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Total Price */}
|
{/* Total Price */}
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<div className="flex justify-between
|
{booking.original_price && booking.discount_amount && booking.discount_amount > 0 ? (
|
||||||
items-center"
|
<>
|
||||||
>
|
<div className="mb-2">
|
||||||
<span className="text-lg font-semibold
|
<div className="flex justify-between items-center mb-1">
|
||||||
text-gray-900"
|
<span className="text-sm text-gray-600">Subtotal:</span>
|
||||||
>
|
<span className="text-base font-semibold text-gray-900">{formatPrice(booking.original_price)}</span>
|
||||||
Total Payment
|
</div>
|
||||||
</span>
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-2xl font-bold
|
<span className="text-sm text-green-600">
|
||||||
text-indigo-600"
|
Discount{booking.promotion_code ? ` (${booking.promotion_code})` : ''}:
|
||||||
>
|
</span>
|
||||||
{formatPrice(booking.total_price)}
|
<span className="text-base font-semibold text-green-600">-{formatPrice(booking.discount_amount)}</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div className="border-t border-gray-300 pt-2 mt-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900">Total Payment</span>
|
||||||
|
<span className="text-2xl font-bold text-indigo-600">{formatPrice(booking.total_price)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900">Total Payment</span>
|
||||||
|
<span className="text-2xl font-bold text-indigo-600">{formatPrice(booking.total_price)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { getBookingById, type Booking } from
|
import { getBookingById, cancelBooking, type Booking } from
|
||||||
'../../services/api/bookingService';
|
'../../services/api/bookingService';
|
||||||
import {
|
import {
|
||||||
getPaymentsByBookingId,
|
getPaymentsByBookingId,
|
||||||
@@ -21,13 +22,15 @@ import PayPalPaymentWrapper from '../../components/payments/PayPalPaymentWrapper
|
|||||||
const DepositPaymentPage: React.FC = () => {
|
const DepositPaymentPage: React.FC = () => {
|
||||||
const { bookingId } = useParams<{ bookingId: string }>();
|
const { bookingId } = useParams<{ bookingId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatCurrency } = useFormatCurrency();
|
const { formatCurrency, currency } = useFormatCurrency();
|
||||||
|
|
||||||
const [booking, setBooking] = useState<Booking | null>(null);
|
const [booking, setBooking] = useState<Booking | null>(null);
|
||||||
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
|
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [paymentSuccess, setPaymentSuccess] = useState(false);
|
const [paymentSuccess, setPaymentSuccess] = useState(false);
|
||||||
|
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<'stripe' | 'paypal' | null>(null);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookingId) {
|
if (bookingId) {
|
||||||
@@ -86,30 +89,82 @@ const DepositPaymentPage: React.FC = () => {
|
|||||||
|
|
||||||
const formatPrice = (price: number) => formatCurrency(price);
|
const formatPrice = (price: number) => formatCurrency(price);
|
||||||
|
|
||||||
|
const handleCancelBooking = async () => {
|
||||||
|
if (!booking) return;
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Are you sure you want to cancel this booking?\n\n` +
|
||||||
|
`Booking Number: ${booking.booking_number}\n\n` +
|
||||||
|
`⚠️ Note: This will cancel your booking and free up the room.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCancelling(true);
|
||||||
|
const response = await cancelBooking(booking.id);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(
|
||||||
|
`✅ Booking ${booking.booking_number} has been cancelled successfully!`
|
||||||
|
);
|
||||||
|
// Navigate to bookings list after cancellation
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/bookings');
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || 'Unable to cancel booking');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error cancelling booking:', err);
|
||||||
|
const message =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Unable to cancel booking. Please try again.';
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loading fullScreen text="Loading..." />;
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
|
||||||
|
<Loading fullScreen text="Loading payment information..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !booking || !depositPayment) {
|
if (error || !booking || !depositPayment) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
<div
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8 sm:py-12 w-screen relative -mt-6 -mb-6"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'calc(50% - 50vw)',
|
||||||
|
marginRight: 'calc(50% - 50vw)',
|
||||||
|
width: '100vw',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8">
|
||||||
<div
|
<div
|
||||||
className="bg-red-50 border border-red-200
|
className="bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||||
rounded-lg p-8 text-center"
|
border border-red-500/30 rounded-xl p-6 sm:p-12 text-center
|
||||||
|
backdrop-blur-xl shadow-2xl shadow-red-500/10"
|
||||||
>
|
>
|
||||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
<AlertCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400 mx-auto mb-4" />
|
||||||
<p className="text-red-700 font-medium mb-4">
|
<p className="text-red-300 font-light text-base sm:text-lg mb-6 tracking-wide px-2">
|
||||||
{error || 'Payment information not found'}
|
{error || 'Payment information not found'}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/bookings"
|
to="/bookings"
|
||||||
className="inline-flex items-center gap-2 px-6 py-2
|
className="inline-flex items-center gap-2 bg-gradient-to-r
|
||||||
bg-red-600 text-white rounded-lg hover:bg-red-700
|
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
|
||||||
transition-colors"
|
px-4 py-2 sm:px-6 sm:py-3 rounded-sm hover:from-[#f5d76e]
|
||||||
|
hover:to-[#d4af37] transition-all duration-300
|
||||||
|
font-medium tracking-wide shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
Back to booking list
|
Back to booking list
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,36 +178,71 @@ const DepositPaymentPage: React.FC = () => {
|
|||||||
const isDepositPaid = depositPayment.payment_status === 'completed';
|
const isDepositPaid = depositPayment.payment_status === 'completed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
<div
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8 sm:py-12 w-screen relative -mt-6 -mb-6"
|
||||||
{/* Back Button */}
|
style={{
|
||||||
<Link
|
marginLeft: 'calc(50% - 50vw)',
|
||||||
to={`/bookings/${bookingId}`}
|
marginRight: 'calc(50% - 50vw)',
|
||||||
className="inline-flex items-center gap-2 text-gray-600
|
width: '100vw',
|
||||||
hover:text-gray-900 mb-6 transition-colors"
|
zIndex: 1
|
||||||
>
|
}}
|
||||||
<ArrowLeft className="w-5 h-5" />
|
>
|
||||||
<span>Back to booking details</span>
|
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||||
</Link>
|
{/* Back Button and Cancel Button */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 mb-3 sm:mb-4">
|
||||||
|
<Link
|
||||||
|
to={`/bookings/${bookingId}`}
|
||||||
|
className="inline-flex items-center gap-1
|
||||||
|
text-[#d4af37]/80 hover:text-[#d4af37]
|
||||||
|
transition-colors font-light tracking-wide text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
<span>Back to booking details</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Cancel Booking Button - Only show if deposit not paid */}
|
||||||
|
{!isDepositPaid && booking && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancelBooking}
|
||||||
|
disabled={cancelling}
|
||||||
|
className="inline-flex items-center gap-1
|
||||||
|
bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||||
|
border border-red-500/30 text-red-300
|
||||||
|
px-2.5 py-1 rounded-sm
|
||||||
|
hover:border-red-400/50 hover:bg-gradient-to-br
|
||||||
|
hover:from-red-800/30 hover:to-red-700/20
|
||||||
|
transition-all duration-300 font-light tracking-wide text-xs sm:text-sm
|
||||||
|
backdrop-blur-sm shadow-sm shadow-red-500/10
|
||||||
|
hover:shadow-md hover:shadow-red-500/20
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{cancelling ? 'Cancelling...' : 'Cancel Booking'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Success Header (if paid) */}
|
{/* Success Header (if paid) */}
|
||||||
{isDepositPaid && (
|
{isDepositPaid && (
|
||||||
<div
|
<div
|
||||||
className="bg-green-50 border-2 border-green-200
|
className="bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||||
rounded-lg p-6 mb-6"
|
border border-green-500/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||||
|
backdrop-blur-xl shadow-lg shadow-green-500/10"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-16 h-16 bg-green-100 rounded-full
|
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-green-500/20 to-green-600/20
|
||||||
flex items-center justify-center"
|
rounded-full flex items-center justify-center flex-shrink-0
|
||||||
|
border border-green-500/30 shadow-sm shadow-green-500/20"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
<CheckCircle className="w-6 h-6 sm:w-7 sm:h-7 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 text-center sm:text-left">
|
||||||
<h1 className="text-2xl font-bold text-green-900 mb-1">
|
<h1 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-1 tracking-wide">
|
||||||
Deposit payment successful!
|
Deposit Payment Successful!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-green-700">
|
<p className="text-green-200/80 font-light text-xs sm:text-sm tracking-wide">
|
||||||
Your booking has been confirmed.
|
Your booking has been confirmed.
|
||||||
Remaining amount to be paid at check-in.
|
Remaining amount to be paid at check-in.
|
||||||
</p>
|
</p>
|
||||||
@@ -164,22 +254,24 @@ const DepositPaymentPage: React.FC = () => {
|
|||||||
{/* Pending Header */}
|
{/* Pending Header */}
|
||||||
{!isDepositPaid && (
|
{!isDepositPaid && (
|
||||||
<div
|
<div
|
||||||
className="bg-orange-50 border-2 border-orange-200
|
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||||
rounded-lg p-6 mb-6"
|
border border-[#d4af37]/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||||
|
backdrop-blur-xl shadow-lg shadow-[#d4af37]/10"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-16 h-16 bg-orange-100 rounded-full
|
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||||
flex items-center justify-center"
|
rounded-full flex items-center justify-center flex-shrink-0
|
||||||
|
border border-[#d4af37]/30 shadow-sm shadow-[#d4af37]/20"
|
||||||
>
|
>
|
||||||
<CreditCard className="w-10 h-10 text-orange-600" />
|
<CreditCard className="w-6 h-6 sm:w-7 sm:h-7 text-[#d4af37]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 text-center sm:text-left">
|
||||||
<h1 className="text-2xl font-bold text-orange-900 mb-1">
|
<h1 className="text-base sm:text-lg font-serif font-semibold text-[#d4af37] mb-1 tracking-wide">
|
||||||
Deposit Payment
|
Deposit Payment Required
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-orange-700">
|
<p className="text-gray-300/80 font-light text-xs sm:text-sm tracking-wide">
|
||||||
Please pay <strong>20% deposit</strong> to
|
Please pay <strong className="text-[#d4af37] font-medium">20% deposit</strong> to
|
||||||
confirm your booking
|
confirm your booking
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,132 +279,225 @@ const DepositPaymentPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="w-full">
|
||||||
{/* Payment Info */}
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
{/* Payment Info */}
|
||||||
{/* Payment Summary */}
|
<div className="lg:col-span-2 space-y-3">
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
{/* Payment Summary */}
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
Payment Information
|
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||||
</h2>
|
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||||
|
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-2.5 sm:mb-3 tracking-wide">
|
||||||
|
Payment Information
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-0 py-1.5 border-b border-gray-700/30">
|
||||||
<span className="text-gray-600">Total Room Price</span>
|
<span className="text-gray-300 font-light tracking-wide text-xs sm:text-sm">Total Room Price</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium text-gray-100 text-xs sm:text-sm">
|
||||||
{formatPrice(booking.total_price)}
|
{formatPrice(booking.total_price)}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-0 py-2
|
||||||
|
border-t-2 border-[#d4af37]/30 pt-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-[#d4af37] text-xs sm:text-sm tracking-wide">
|
||||||
|
Deposit Amount to Pay (20%)
|
||||||
|
</span>
|
||||||
|
<span className="text-base sm:text-lg font-bold text-[#d4af37]">
|
||||||
|
{formatPrice(depositAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between gap-0.5 text-[10px] sm:text-xs text-gray-400/80 font-light pt-1">
|
||||||
|
<span>Remaining amount to be paid at check-in</span>
|
||||||
|
<span className="text-gray-300">{formatPrice(remainingAmount)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{isDepositPaid && (
|
||||||
className="flex justify-between border-t pt-3
|
<div className="mt-4 sm:mt-6 bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||||
text-orange-600"
|
border border-green-500/30 rounded-lg p-3 sm:p-4 backdrop-blur-sm">
|
||||||
>
|
<p className="text-xs sm:text-sm text-green-300 font-light break-words">
|
||||||
<span className="font-medium">
|
✓ Deposit paid on:{' '}
|
||||||
Deposit Amount to Pay (20%)
|
{depositPayment.payment_date
|
||||||
</span>
|
? new Date(depositPayment.payment_date).toLocaleString('en-US')
|
||||||
<span className="text-xl font-bold">
|
: 'N/A'}
|
||||||
{formatPrice(depositAmount)}
|
</p>
|
||||||
</span>
|
{depositPayment.transaction_id && (
|
||||||
</div>
|
<p className="text-xs text-green-400/70 mt-2 font-mono break-all">
|
||||||
|
Transaction ID: {depositPayment.transaction_id}
|
||||||
<div className="flex justify-between text-sm text-gray-500">
|
</p>
|
||||||
<span>Remaining amount to be paid at check-in</span>
|
)}
|
||||||
<span>{formatPrice(remainingAmount)}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDepositPaid && (
|
{/* Payment Method Selection */}
|
||||||
<div className="mt-4 bg-green-50 border border-green-200 rounded p-3">
|
{!isDepositPaid && !selectedPaymentMethod && (
|
||||||
<p className="text-sm text-green-800">
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
✓ Deposit paid on:{' '}
|
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||||
{depositPayment.payment_date
|
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||||
? new Date(depositPayment.payment_date).toLocaleString('en-US')
|
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-1.5 tracking-wide">
|
||||||
: 'N/A'}
|
Choose Payment Method
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-300/80 font-light mb-3 sm:mb-4 tracking-wide text-xs sm:text-sm">
|
||||||
|
Please select how you would like to pay the deposit:
|
||||||
</p>
|
</p>
|
||||||
{depositPayment.transaction_id && (
|
|
||||||
<p className="text-xs text-green-700 mt-1">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 sm:gap-3">
|
||||||
Transaction ID: {depositPayment.transaction_id}
|
{/* Stripe Option */}
|
||||||
</p>
|
<button
|
||||||
|
onClick={() => setSelectedPaymentMethod('stripe')}
|
||||||
|
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||||
|
border-2 border-gray-600/30 rounded-lg p-3
|
||||||
|
hover:border-[#d4af37]/50 hover:bg-gradient-to-br
|
||||||
|
hover:from-[#d4af37]/10 hover:to-[#c9a227]/5
|
||||||
|
transition-all duration-300 text-left group
|
||||||
|
backdrop-blur-sm shadow-sm shadow-black/10
|
||||||
|
hover:shadow-md hover:shadow-[#d4af37]/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-indigo-500/20 to-indigo-600/20
|
||||||
|
rounded-lg flex items-center justify-center
|
||||||
|
border border-indigo-500/30 group-hover:border-[#d4af37]/50
|
||||||
|
transition-colors">
|
||||||
|
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-400 group-hover:text-[#d4af37] transition-colors" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] sm:text-xs font-semibold text-indigo-400 group-hover:text-[#d4af37]
|
||||||
|
transition-colors tracking-wide">Card Payment</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-300/70 font-light group-hover:text-gray-200 transition-colors">
|
||||||
|
Pay with credit or debit card via Stripe
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* PayPal Option */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPaymentMethod('paypal')}
|
||||||
|
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||||
|
border-2 border-gray-600/30 rounded-lg p-3
|
||||||
|
hover:border-[#d4af37]/50 hover:bg-gradient-to-br
|
||||||
|
hover:from-[#d4af37]/10 hover:to-[#c9a227]/5
|
||||||
|
transition-all duration-300 text-left group
|
||||||
|
backdrop-blur-sm shadow-sm shadow-black/10
|
||||||
|
hover:shadow-md hover:shadow-[#d4af37]/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500/20 to-blue-600/20
|
||||||
|
rounded-lg flex items-center justify-center
|
||||||
|
border border-blue-500/30 group-hover:border-[#d4af37]/50
|
||||||
|
transition-colors">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400 group-hover:text-[#d4af37] transition-colors"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] sm:text-xs font-semibold text-blue-400 group-hover:text-[#d4af37]
|
||||||
|
transition-colors tracking-wide">PayPal</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-300/70 font-light group-hover:text-gray-200 transition-colors">
|
||||||
|
Pay securely with your PayPal account
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Method Selection Header (when method is selected) */}
|
||||||
|
{!isDepositPaid && selectedPaymentMethod && (
|
||||||
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
|
border border-gray-700/50 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||||
|
backdrop-blur-xl shadow-lg shadow-black/10">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-0.5 tracking-wide">
|
||||||
|
{selectedPaymentMethod === 'stripe' ? 'Card Payment' : 'PayPal Payment'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-300/80 font-light tracking-wide">
|
||||||
|
Pay deposit of <span className="text-[#d4af37] font-medium">{formatPrice(depositAmount)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPaymentMethod(null)}
|
||||||
|
className="text-[10px] sm:text-xs text-gray-400 hover:text-[#d4af37]
|
||||||
|
underline transition-colors font-light tracking-wide self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
Change method
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stripe Payment Panel */}
|
||||||
|
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'stripe' && (
|
||||||
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
|
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||||
|
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||||
|
{paymentSuccess ? (
|
||||||
|
<div className="bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||||
|
border border-green-500/30 rounded-lg p-4 sm:p-5 text-center
|
||||||
|
backdrop-blur-sm">
|
||||||
|
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400 mx-auto mb-3" />
|
||||||
|
<h3 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-2 tracking-wide">
|
||||||
|
Payment Successful!
|
||||||
|
</h3>
|
||||||
|
<p className="text-green-200/80 mb-4 font-light tracking-wide text-xs sm:text-sm">
|
||||||
|
Your deposit payment has been confirmed.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||||
|
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||||
|
text-[#0f0f0f] px-4 py-1.5 sm:px-5 sm:py-2 rounded-sm
|
||||||
|
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||||
|
transition-all duration-300 font-medium tracking-wide
|
||||||
|
shadow-sm shadow-[#d4af37]/30 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
View Booking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<StripePaymentWrapper
|
||||||
|
bookingId={booking.id}
|
||||||
|
amount={depositAmount}
|
||||||
|
onSuccess={() => {
|
||||||
|
setPaymentSuccess(true);
|
||||||
|
toast.success('✅ Payment successful! Your booking has been confirmed.');
|
||||||
|
// Navigate to booking details after successful payment
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/bookings/${booking.id}`);
|
||||||
|
}, 2000);
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
toast.error(error || 'Payment failed');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Payment Method Selection */}
|
{/* PayPal Payment Panel */}
|
||||||
{!isDepositPaid && (
|
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'paypal' && (
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||||
Payment Method
|
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||||
</h2>
|
<PayPalPaymentWrapper
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Pay with your credit or debit card
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stripe Payment Panel */}
|
|
||||||
{!isDepositPaid && booking && depositPayment && (
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
||||||
<CreditCard className="w-5 h-5 inline mr-2" />
|
|
||||||
Card Payment
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{paymentSuccess ? (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
|
|
||||||
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-3" />
|
|
||||||
<h3 className="text-lg font-bold text-green-900 mb-2">
|
|
||||||
Payment Successful!
|
|
||||||
</h3>
|
|
||||||
<p className="text-green-700 mb-4">
|
|
||||||
Your deposit payment has been confirmed.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
|
||||||
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition-colors"
|
|
||||||
>
|
|
||||||
View Booking
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<StripePaymentWrapper
|
|
||||||
bookingId={booking.id}
|
bookingId={booking.id}
|
||||||
amount={depositAmount}
|
amount={depositAmount}
|
||||||
onSuccess={() => {
|
currency={currency || 'USD'}
|
||||||
setPaymentSuccess(true);
|
|
||||||
toast.success('✅ Payment successful! Your booking has been confirmed.');
|
|
||||||
// Navigate to booking details after successful payment
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate(`/bookings/${booking.id}`);
|
|
||||||
}, 2000);
|
|
||||||
}}
|
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
toast.error(error || 'Payment failed');
|
toast.error(error || 'Payment failed');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* PayPal Payment Panel */}
|
|
||||||
{!paymentSuccess && booking && depositPayment && (
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
||||||
<CreditCard className="w-5 h-5 inline mr-2" />
|
|
||||||
PayPal Payment
|
|
||||||
</h2>
|
|
||||||
<PayPalPaymentWrapper
|
|
||||||
bookingId={booking.id}
|
|
||||||
amount={depositAmount}
|
|
||||||
onError={(error) => {
|
|
||||||
toast.error(error || 'Payment failed');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -564,6 +564,33 @@ const MyBookingsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{formatPrice(booking.total_price)}
|
{formatPrice(booking.total_price)}
|
||||||
</p>
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const completedPayments = booking.payments?.filter(
|
||||||
|
(p) => p.payment_status === 'completed'
|
||||||
|
) || [];
|
||||||
|
const amountPaid = completedPayments.reduce(
|
||||||
|
(sum, p) => sum + (p.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const remainingDue = booking.total_price - amountPaid;
|
||||||
|
const hasPayments = completedPayments.length > 0;
|
||||||
|
|
||||||
|
if (hasPayments) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs mt-1">
|
||||||
|
<div className="text-green-600 font-medium">
|
||||||
|
Paid: {formatPrice(amountPaid)}
|
||||||
|
</div>
|
||||||
|
{remainingDue > 0 && (
|
||||||
|
<div className="text-amber-600 font-medium">
|
||||||
|
Due: {formatPrice(remainingDue)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,95 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { XCircle, ArrowLeft } from 'lucide-react';
|
import { XCircle, ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
|
import { cancelPayPalPayment } from '../../services/api/paymentService';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
const PayPalCancelPage: React.FC = () => {
|
const PayPalCancelPage: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const bookingId = searchParams.get('bookingId');
|
const bookingId = searchParams.get('bookingId');
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!bookingId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCancelling(true);
|
||||||
|
const response = await cancelPayPalPayment(Number(bookingId));
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.info('Payment canceled. Your booking has been automatically cancelled.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error canceling payment:', err);
|
||||||
|
// Don't show error toast - user already canceled, just log it
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCancel();
|
||||||
|
}, [bookingId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<XCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
|
||||||
Payment Cancelled
|
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||||
</h1>
|
{cancelling ? (
|
||||||
<p className="text-gray-600 mb-6">
|
<>
|
||||||
You cancelled the PayPal payment. No charges were made.
|
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-orange-500/20 to-orange-600/20
|
||||||
</p>
|
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||||
<div className="flex gap-3">
|
border border-orange-500/30 shadow-lg shadow-orange-500/20">
|
||||||
|
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 text-orange-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-orange-300 mb-3 tracking-wide">
|
||||||
|
Processing Cancellation
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
|
||||||
|
Canceling your payment and booking...
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-orange-500/20 to-orange-600/20
|
||||||
|
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||||
|
border border-orange-500/30 shadow-lg shadow-orange-500/20">
|
||||||
|
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-orange-300 mb-3 tracking-wide">
|
||||||
|
Payment Cancelled
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide leading-relaxed px-2">
|
||||||
|
You cancelled the PayPal payment. No charges were made. Your booking has been automatically cancelled.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
{bookingId && (
|
{bookingId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/deposit-payment/${bookingId}`)}
|
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
|
||||||
className="flex-1 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center justify-center gap-2"
|
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||||
|
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||||
|
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||||
|
transition-all duration-300 font-medium tracking-wide
|
||||||
|
shadow-lg shadow-[#d4af37]/30 flex items-center justify-center gap-2
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed text-sm sm:text-base"
|
||||||
|
disabled={cancelling}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
Try Again
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/bookings')}
|
onClick={() => navigate('/bookings')}
|
||||||
className="flex-1 bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition-colors"
|
className="flex-1 bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||||
|
border border-gray-600/30 text-gray-300 px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||||
|
hover:border-[#d4af37]/50 hover:text-[#d4af37]
|
||||||
|
transition-all duration-300 font-light tracking-wide
|
||||||
|
backdrop-blur-sm text-sm sm:text-base"
|
||||||
>
|
>
|
||||||
My Bookings
|
My Bookings
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { capturePayPalPayment } from '../../services/api/paymentService';
|
import { capturePayPalPayment, cancelPayPalPayment } from '../../services/api/paymentService';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||||
import Loading from '../../components/common/Loading';
|
import Loading from '../../components/common/Loading';
|
||||||
@@ -37,6 +37,13 @@ const PayPalReturnPage: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setError(response.message || 'Payment capture failed');
|
setError(response.message || 'Payment capture failed');
|
||||||
toast.error(response.message || 'Payment capture failed');
|
toast.error(response.message || 'Payment capture failed');
|
||||||
|
|
||||||
|
// If payment capture fails, cancel the payment and booking
|
||||||
|
try {
|
||||||
|
await cancelPayPalPayment(Number(bookingId));
|
||||||
|
} catch (cancelErr) {
|
||||||
|
console.error('Error canceling payment after capture failure:', cancelErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
|
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
|
||||||
@@ -52,10 +59,17 @@ const PayPalReturnPage: React.FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||||
<div className="text-center">
|
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<Loader2 className="w-12 h-12 animate-spin text-indigo-600 mx-auto mb-4" />
|
<div className="text-center w-full max-w-2xl mx-auto">
|
||||||
<p className="text-gray-600">Processing your payment...</p>
|
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||||
|
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||||
|
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
|
||||||
|
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 text-[#d4af37] animate-spin" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300/80 font-light text-base sm:text-lg tracking-wide">
|
||||||
|
Processing your payment...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -63,18 +77,29 @@ const PayPalReturnPage: React.FC = () => {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
|
||||||
|
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-green-500/20 to-green-600/20
|
||||||
|
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||||
|
border border-green-500/30 shadow-lg shadow-green-500/20">
|
||||||
|
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-green-300 mb-3 tracking-wide">
|
||||||
Payment Successful!
|
Payment Successful!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
|
||||||
Your payment has been confirmed. Redirecting to booking details...
|
Your payment has been confirmed. Redirecting to booking details...
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/bookings/${bookingId}`)}
|
onClick={() => navigate(`/bookings/${bookingId}`)}
|
||||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||||
|
text-[#0f0f0f] px-6 py-2 sm:px-8 sm:py-3 rounded-sm
|
||||||
|
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||||
|
transition-all duration-300 font-medium tracking-wide
|
||||||
|
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
View Booking
|
View Booking
|
||||||
</button>
|
</button>
|
||||||
@@ -84,25 +109,40 @@ const PayPalReturnPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl text-center
|
||||||
|
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-red-500/20 to-red-600/20
|
||||||
|
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||||
|
border border-red-500/30 shadow-lg shadow-red-500/20">
|
||||||
|
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-red-300 mb-3 tracking-wide">
|
||||||
Payment Failed
|
Payment Failed
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide leading-relaxed px-2">
|
||||||
{error || 'Unable to process your payment. Please try again.'}
|
{error || 'Unable to process your payment. Please try again.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/deposit-payment/${bookingId}`)}
|
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
|
||||||
className="flex-1 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||||
|
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||||
|
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||||
|
transition-all duration-300 font-medium tracking-wide
|
||||||
|
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
|
||||||
>
|
>
|
||||||
Retry Payment
|
Retry Payment
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/bookings')}
|
onClick={() => navigate('/bookings')}
|
||||||
className="flex-1 bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition-colors"
|
className="flex-1 bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||||
|
border border-gray-600/30 text-gray-300 px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||||
|
hover:border-[#d4af37]/50 hover:text-[#d4af37]
|
||||||
|
transition-all duration-300 font-light tracking-wide
|
||||||
|
backdrop-blur-sm text-sm sm:text-base"
|
||||||
>
|
>
|
||||||
My Bookings
|
My Bookings
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -71,8 +71,18 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
|
<div
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'calc(50% - 50vw)',
|
||||||
|
marginRight: 'calc(50% - 50vw)',
|
||||||
|
width: '100vw',
|
||||||
|
paddingTop: '1.5rem',
|
||||||
|
paddingBottom: '1.5rem',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8 sm:py-12">
|
||||||
<div className="animate-pulse space-y-6">
|
<div className="animate-pulse space-y-6">
|
||||||
<div className="h-[600px] bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-xl border border-[#d4af37]/20" />
|
<div className="h-[600px] bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-xl border border-[#d4af37]/20" />
|
||||||
<div className="h-12 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg w-1/3 border border-[#d4af37]/10" />
|
<div className="h-12 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg w-1/3 border border-[#d4af37]/10" />
|
||||||
@@ -85,8 +95,18 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
if (error || !room) {
|
if (error || !room) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
|
<div
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'calc(50% - 50vw)',
|
||||||
|
marginRight: 'calc(50% - 50vw)',
|
||||||
|
width: '100vw',
|
||||||
|
paddingTop: '1.5rem',
|
||||||
|
paddingBottom: '1.5rem',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8 sm:py-12">
|
||||||
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
|
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||||
border border-red-500/30 rounded-xl p-12 text-center
|
border border-red-500/30 rounded-xl p-12 text-center
|
||||||
backdrop-blur-xl shadow-2xl shadow-red-500/10"
|
backdrop-blur-xl shadow-2xl shadow-red-500/10"
|
||||||
@@ -115,22 +135,32 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
const formattedPrice = formatCurrency(room?.price || roomType?.base_price || 0);
|
const formattedPrice = formatCurrency(room?.price || roomType?.base_price || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
|
<div
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'calc(50% - 50vw)',
|
||||||
|
marginRight: 'calc(50% - 50vw)',
|
||||||
|
width: '100vw',
|
||||||
|
paddingTop: '1.5rem',
|
||||||
|
paddingBottom: '1.5rem',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Link
|
<Link
|
||||||
to="/rooms"
|
to="/rooms"
|
||||||
className="inline-flex items-center gap-2
|
className="inline-flex items-center gap-1
|
||||||
text-[#d4af37]/80 hover:text-[#d4af37]
|
text-[#d4af37]/80 hover:text-[#d4af37]
|
||||||
mb-8 transition-all duration-300
|
mb-3 transition-all duration-300
|
||||||
group font-light tracking-wide"
|
group font-light tracking-wide text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
<ArrowLeft className="w-3.5 h-3.5 group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>Back to room list</span>
|
<span>Back to room list</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Image Gallery */}
|
{/* Image Gallery */}
|
||||||
<div className="mb-12">
|
<div className="mb-4">
|
||||||
<RoomGallery
|
<RoomGallery
|
||||||
images={(room.images && room.images.length > 0)
|
images={(room.images && room.images.length > 0)
|
||||||
? room.images
|
? room.images
|
||||||
@@ -140,30 +170,30 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Room Information */}
|
{/* Room Information */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-16">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4 lg:gap-6 mb-4 sm:mb-5">
|
||||||
{/* Main Info */}
|
{/* Main Info */}
|
||||||
<div className="lg:col-span-8 space-y-10">
|
<div className="lg:col-span-8 space-y-3 sm:space-y-4">
|
||||||
{/* Title & Basic Info */}
|
{/* Title & Basic Info */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
{/* Room Name with Luxury Badge */}
|
{/* Room Name with Luxury Badge */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
{room.featured && (
|
{room.featured && (
|
||||||
<div className="flex items-center gap-2
|
<div className="flex items-center gap-1
|
||||||
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||||
text-[#0f0f0f] px-4 py-1.5 rounded-sm
|
text-[#0f0f0f] px-2 py-0.5 rounded-sm
|
||||||
text-xs font-medium tracking-wide
|
text-[10px] sm:text-xs font-medium tracking-wide
|
||||||
shadow-lg shadow-[#d4af37]/30"
|
shadow-sm shadow-[#d4af37]/30"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
<Sparkles className="w-2.5 h-2.5" />
|
||||||
Featured
|
Featured
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-1.5 rounded-sm
|
className={`px-2 py-0.5 rounded-sm
|
||||||
text-xs font-medium tracking-wide
|
text-[10px] sm:text-xs font-medium tracking-wide
|
||||||
backdrop-blur-sm shadow-lg
|
backdrop-blur-sm shadow-sm
|
||||||
${
|
${
|
||||||
room.status === 'available'
|
room.status === 'available'
|
||||||
? 'bg-green-500/90 text-white border border-green-400/50'
|
? 'bg-green-500/90 text-white border border-green-400/50'
|
||||||
@@ -180,8 +210,8 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-5xl font-serif font-semibold
|
<h1 className="text-xl sm:text-2xl lg:text-3xl font-serif font-semibold
|
||||||
text-white mb-6 tracking-tight leading-tight
|
text-white mb-2 tracking-tight leading-tight
|
||||||
bg-gradient-to-r from-white via-[#d4af37] to-white
|
bg-gradient-to-r from-white via-[#d4af37] to-white
|
||||||
bg-clip-text text-transparent"
|
bg-clip-text text-transparent"
|
||||||
>
|
>
|
||||||
@@ -191,63 +221,63 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic Info Grid */}
|
{/* Basic Info Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-3 mb-3">
|
||||||
<div className="flex items-center gap-3
|
<div className="flex items-center gap-2
|
||||||
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||||
rounded-lg border border-[#d4af37]/20
|
rounded-lg border border-[#d4af37]/20
|
||||||
hover:border-[#d4af37]/40 transition-all duration-300"
|
hover:border-[#d4af37]/40 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||||
border border-[#d4af37]/30">
|
border border-[#d4af37]/30">
|
||||||
<MapPin className="w-5 h-5 text-[#d4af37]" />
|
<MapPin className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||||
Location
|
Location
|
||||||
</p>
|
</p>
|
||||||
<p className="text-white font-light tracking-wide">
|
<p className="text-xs sm:text-sm text-white font-light tracking-wide">
|
||||||
Room {room.room_number} - Floor {room.floor}
|
Room {room.room_number} - Floor {room.floor}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3
|
<div className="flex items-center gap-2
|
||||||
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||||
rounded-lg border border-[#d4af37]/20"
|
rounded-lg border border-[#d4af37]/20"
|
||||||
>
|
>
|
||||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||||
border border-[#d4af37]/30">
|
border border-[#d4af37]/30">
|
||||||
<Users className="w-5 h-5 text-[#d4af37]" />
|
<Users className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||||
Capacity
|
Capacity
|
||||||
</p>
|
</p>
|
||||||
<p className="text-white font-light tracking-wide">
|
<p className="text-xs sm:text-sm text-white font-light tracking-wide">
|
||||||
{room?.capacity || roomType?.capacity || 0} guests
|
{room?.capacity || roomType?.capacity || 0} guests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{room.average_rating != null && (
|
{room.average_rating != null && (
|
||||||
<div className="flex items-center gap-3
|
<div className="flex items-center gap-2
|
||||||
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||||
rounded-lg border border-[#d4af37]/20
|
rounded-lg border border-[#d4af37]/20
|
||||||
hover:border-[#d4af37]/40 transition-all duration-300"
|
hover:border-[#d4af37]/40 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||||
border border-[#d4af37]/30">
|
border border-[#d4af37]/30">
|
||||||
<Star className="w-5 h-5 text-[#d4af37] fill-[#d4af37]" />
|
<Star className="w-3.5 h-3.5 text-[#d4af37] fill-[#d4af37]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||||
Rating
|
Rating
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<p className="text-white font-semibold">
|
<p className="text-xs sm:text-sm text-white font-semibold">
|
||||||
{Number(room.average_rating).toFixed(1)}
|
{Number(room.average_rating).toFixed(1)}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-gray-500 font-light">
|
<span className="text-[10px] sm:text-xs text-gray-500 font-light">
|
||||||
({room.total_reviews || 0})
|
({room.total_reviews || 0})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,23 +289,23 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Description - Show room-specific description first, fallback to room type */}
|
{/* Description - Show room-specific description first, fallback to room type */}
|
||||||
{(room?.description || roomType?.description) && (
|
{(room?.description || roomType?.description) && (
|
||||||
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
<div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||||
rounded-xl border border-[#d4af37]/20
|
rounded-lg border border-[#d4af37]/20
|
||||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
|
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||||
border border-[#d4af37]/30">
|
border border-[#d4af37]/30">
|
||||||
<Award className="w-5 h-5 text-[#d4af37]" />
|
<Award className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-serif font-semibold
|
<h2 className="text-sm sm:text-base font-serif font-semibold
|
||||||
text-white tracking-wide"
|
text-white tracking-wide"
|
||||||
>
|
>
|
||||||
{room?.description ? 'Room Description' : 'Room Type Description'}
|
{room?.description ? 'Room Description' : 'Room Type Description'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-300 leading-relaxed
|
<p className="text-gray-300 leading-relaxed
|
||||||
font-light tracking-wide text-lg"
|
font-light tracking-wide text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{room?.description || roomType?.description}
|
{room?.description || roomType?.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -283,16 +313,16 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Amenities */}
|
{/* Amenities */}
|
||||||
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
<div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||||
rounded-xl border border-[#d4af37]/20
|
rounded-lg border border-[#d4af37]/20
|
||||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
|
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||||
border border-[#d4af37]/30">
|
border border-[#d4af37]/30">
|
||||||
<Sparkles className="w-5 h-5 text-[#d4af37]" />
|
<Sparkles className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-serif font-semibold
|
<h2 className="text-sm sm:text-base font-serif font-semibold
|
||||||
text-white tracking-wide"
|
text-white tracking-wide"
|
||||||
>
|
>
|
||||||
Amenities & Features
|
Amenities & Features
|
||||||
@@ -311,25 +341,25 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
{/* Booking Card */}
|
{/* Booking Card */}
|
||||||
<aside className="lg:col-span-4">
|
<aside className="lg:col-span-4">
|
||||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
|
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
|
||||||
rounded-xl border border-[#d4af37]/30
|
rounded-lg border border-[#d4af37]/30
|
||||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20
|
backdrop-blur-xl shadow-lg shadow-[#d4af37]/20
|
||||||
p-8 sticky top-6"
|
p-3 sm:p-4 sticky top-4"
|
||||||
>
|
>
|
||||||
{/* Price Section */}
|
{/* Price Section */}
|
||||||
<div className="mb-8 pb-8 border-b border-[#d4af37]/20">
|
<div className="mb-4 pb-4 border-b border-[#d4af37]/20">
|
||||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-2">
|
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-1">
|
||||||
Starting from
|
Starting from
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<CurrencyIcon className="text-[#d4af37]" size={24} />
|
<CurrencyIcon className="text-[#d4af37]" size={16} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl font-serif font-semibold
|
<div className="text-2xl sm:text-3xl font-serif font-semibold
|
||||||
bg-gradient-to-r from-[#d4af37] to-[#f5d76e]
|
bg-gradient-to-r from-[#d4af37] to-[#f5d76e]
|
||||||
bg-clip-text text-transparent tracking-tight"
|
bg-clip-text text-transparent tracking-tight"
|
||||||
>
|
>
|
||||||
{formattedPrice}
|
{formattedPrice}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400 font-light tracking-wide mt-1">
|
<div className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mt-0.5">
|
||||||
/ night
|
/ night
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,23 +367,23 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Button */}
|
{/* Booking Button */}
|
||||||
<div className="mb-6">
|
<div className="mb-3">
|
||||||
<Link
|
<Link
|
||||||
to={`/booking/${room.id}`}
|
to={`/booking/${room.id}`}
|
||||||
className={`block w-full py-4 text-center
|
className={`block w-full py-2 text-center
|
||||||
font-medium rounded-sm transition-all duration-300
|
font-medium rounded-sm transition-all duration-300
|
||||||
tracking-wide relative overflow-hidden group
|
tracking-wide relative overflow-hidden group text-xs sm:text-sm
|
||||||
${
|
${
|
||||||
room.status === 'available'
|
room.status === 'available'
|
||||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-lg shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50'
|
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-sm shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50'
|
||||||
: 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
|
: 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (room.status !== 'available') e.preventDefault();
|
if (room.status !== 'available') e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
<span className="relative z-10 flex items-center justify-center gap-1.5">
|
||||||
<Calendar className="w-5 h-5" />
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
||||||
</span>
|
</span>
|
||||||
{room.status === 'available' && (
|
{room.status === 'available' && (
|
||||||
@@ -363,42 +393,42 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{room.status === 'available' && (
|
{room.status === 'available' && (
|
||||||
<div className="flex items-start gap-3 p-4 bg-[#d4af37]/5
|
<div className="flex items-start gap-2 p-2 bg-[#d4af37]/5
|
||||||
rounded-lg border border-[#d4af37]/20 mb-6"
|
rounded-lg border border-[#d4af37]/20 mb-3"
|
||||||
>
|
>
|
||||||
<Shield className="w-5 h-5 text-[#d4af37] mt-0.5 flex-shrink-0" />
|
<Shield className="w-3.5 h-3.5 text-[#d4af37] mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-sm text-gray-300 font-light tracking-wide">
|
<p className="text-[10px] sm:text-xs text-gray-300 font-light tracking-wide leading-relaxed">
|
||||||
No immediate charge — secure your booking now and pay at the hotel
|
No immediate charge — secure your booking now and pay at the hotel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Room Details */}
|
{/* Room Details */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between
|
<div className="flex items-center justify-between
|
||||||
py-3 border-b border-[#d4af37]/10"
|
py-1.5 border-b border-[#d4af37]/10"
|
||||||
>
|
>
|
||||||
<span className="text-gray-400 font-light tracking-wide">Room Type</span>
|
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Type</span>
|
||||||
<strong className="text-white font-light">{roomType?.name}</strong>
|
<strong className="text-xs sm:text-sm text-white font-light">{roomType?.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between
|
<div className="flex items-center justify-between
|
||||||
py-3 border-b border-[#d4af37]/10"
|
py-1.5 border-b border-[#d4af37]/10"
|
||||||
>
|
>
|
||||||
<span className="text-gray-400 font-light tracking-wide">Max Guests</span>
|
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Max Guests</span>
|
||||||
<span className="text-white font-light">{(room?.capacity || roomType?.capacity || 0)} guests</span>
|
<span className="text-xs sm:text-sm text-white font-light">{(room?.capacity || roomType?.capacity || 0)} guests</span>
|
||||||
</div>
|
</div>
|
||||||
{room?.room_size && (
|
{room?.room_size && (
|
||||||
<div className="flex items-center justify-between
|
<div className="flex items-center justify-between
|
||||||
py-3 border-b border-[#d4af37]/10"
|
py-1.5 border-b border-[#d4af37]/10"
|
||||||
>
|
>
|
||||||
<span className="text-gray-400 font-light tracking-wide">Room Size</span>
|
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Size</span>
|
||||||
<span className="text-white font-light">{room.room_size}</span>
|
<span className="text-xs sm:text-sm text-white font-light">{room.room_size}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{room?.view && (
|
{room?.view && (
|
||||||
<div className={`flex items-center justify-between ${room?.room_size ? 'py-3 border-b border-[#d4af37]/10' : 'py-3'}`}>
|
<div className={`flex items-center justify-between ${room?.room_size ? 'py-1.5 border-b border-[#d4af37]/10' : 'py-1.5'}`}>
|
||||||
<span className="text-gray-400 font-light tracking-wide">View</span>
|
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">View</span>
|
||||||
<span className="text-white font-light">{room.view}</span>
|
<span className="text-xs sm:text-sm text-white font-light">{room.view}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -407,9 +437,9 @@ const RoomDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reviews Section */}
|
{/* Reviews Section */}
|
||||||
<div className="mb-12 p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
<div className="mb-4 p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||||
rounded-xl border border-[#d4af37]/20
|
rounded-lg border border-[#d4af37]/20
|
||||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
|
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||||
>
|
>
|
||||||
<ReviewSection roomId={room.id} />
|
<ReviewSection roomId={room.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface Booking {
|
|||||||
check_out_date: string;
|
check_out_date: string;
|
||||||
guest_count: number;
|
guest_count: number;
|
||||||
total_price: number;
|
total_price: number;
|
||||||
|
original_price?: number;
|
||||||
|
discount_amount?: number;
|
||||||
|
promotion_code?: string;
|
||||||
status:
|
status:
|
||||||
| 'pending'
|
| 'pending'
|
||||||
| 'confirmed'
|
| 'confirmed'
|
||||||
@@ -70,6 +73,13 @@ export interface Booking {
|
|||||||
phone_number?: string;
|
phone_number?: string;
|
||||||
};
|
};
|
||||||
payments?: Payment[];
|
payments?: Payment[];
|
||||||
|
payment_balance?: {
|
||||||
|
total_paid: number;
|
||||||
|
total_price: number;
|
||||||
|
remaining_balance: number;
|
||||||
|
is_fully_paid: boolean;
|
||||||
|
payment_percentage: number;
|
||||||
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,6 +365,30 @@ export const capturePayPalPayment = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel PayPal payment (when user cancels on PayPal page)
|
||||||
|
* POST /api/payments/paypal/cancel
|
||||||
|
*/
|
||||||
|
export const cancelPayPalPayment = async (
|
||||||
|
bookingId: number
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
'/payments/paypal/cancel',
|
||||||
|
{
|
||||||
|
booking_id: bookingId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Map backend response format (status: "success") to frontend format (success: true)
|
||||||
|
const data = response.data;
|
||||||
|
return {
|
||||||
|
success: data.status === "success" || data.success === true,
|
||||||
|
message: data.message,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
createPayment,
|
createPayment,
|
||||||
getPayments,
|
getPayments,
|
||||||
@@ -378,4 +402,5 @@ export default {
|
|||||||
confirmStripePayment,
|
confirmStripePayment,
|
||||||
createPayPalOrder,
|
createPayPalOrder,
|
||||||
capturePayPalPayment,
|
capturePayPalPayment,
|
||||||
|
cancelPayPalPayment,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export interface CompanySettingsResponse {
|
|||||||
company_phone: string;
|
company_phone: string;
|
||||||
company_email: string;
|
company_email: string;
|
||||||
company_address: string;
|
company_address: string;
|
||||||
|
tax_rate: number;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
updated_by?: string | null;
|
updated_by?: string | null;
|
||||||
};
|
};
|
||||||
@@ -119,6 +120,7 @@ export interface UpdateCompanySettingsRequest {
|
|||||||
company_phone?: string;
|
company_phone?: string;
|
||||||
company_email?: string;
|
company_email?: string;
|
||||||
company_address?: string;
|
company_address?: string;
|
||||||
|
tax_rate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadLogoResponse {
|
export interface UploadLogoResponse {
|
||||||
@@ -139,6 +141,49 @@ export interface UploadFaviconResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecaptchaSettingsResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
recaptcha_site_key: string;
|
||||||
|
recaptcha_enabled: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecaptchaSettingsAdminResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
recaptcha_site_key: string;
|
||||||
|
recaptcha_secret_key: string;
|
||||||
|
recaptcha_secret_key_masked: string;
|
||||||
|
recaptcha_enabled: boolean;
|
||||||
|
has_site_key: boolean;
|
||||||
|
has_secret_key: boolean;
|
||||||
|
updated_at?: string | null;
|
||||||
|
updated_by?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRecaptchaSettingsRequest {
|
||||||
|
recaptcha_site_key?: string;
|
||||||
|
recaptcha_secret_key?: string;
|
||||||
|
recaptcha_enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyRecaptchaRequest {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyRecaptchaResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
verified: boolean;
|
||||||
|
score?: number;
|
||||||
|
action?: string;
|
||||||
|
error_codes?: string[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const systemSettingsService = {
|
const systemSettingsService = {
|
||||||
/**
|
/**
|
||||||
* Get platform currency (public endpoint)
|
* Get platform currency (public endpoint)
|
||||||
@@ -311,7 +356,56 @@ const systemSettingsService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recaptchaService = {
|
||||||
|
/**
|
||||||
|
* Get reCAPTCHA settings (public endpoint)
|
||||||
|
*/
|
||||||
|
getRecaptchaSettings: async (): Promise<RecaptchaSettingsResponse> => {
|
||||||
|
const response = await apiClient.get<RecaptchaSettingsResponse>(
|
||||||
|
'/api/admin/system-settings/recaptcha'
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reCAPTCHA settings (admin only)
|
||||||
|
*/
|
||||||
|
getRecaptchaSettingsAdmin: async (): Promise<RecaptchaSettingsAdminResponse> => {
|
||||||
|
const response = await apiClient.get<RecaptchaSettingsAdminResponse>(
|
||||||
|
'/api/admin/system-settings/recaptcha/admin'
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update reCAPTCHA settings (admin only)
|
||||||
|
*/
|
||||||
|
updateRecaptchaSettings: async (
|
||||||
|
settings: UpdateRecaptchaSettingsRequest
|
||||||
|
): Promise<RecaptchaSettingsAdminResponse> => {
|
||||||
|
const response = await apiClient.put<RecaptchaSettingsAdminResponse>(
|
||||||
|
'/api/admin/system-settings/recaptcha',
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify reCAPTCHA token
|
||||||
|
*/
|
||||||
|
verifyRecaptcha: async (
|
||||||
|
token: string
|
||||||
|
): Promise<VerifyRecaptchaResponse> => {
|
||||||
|
const response = await apiClient.post<VerifyRecaptchaResponse>(
|
||||||
|
'/api/admin/system-settings/recaptcha/verify',
|
||||||
|
{ token }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default systemSettingsService;
|
export default systemSettingsService;
|
||||||
|
export { recaptchaService };
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
PlatformCurrencyResponse,
|
PlatformCurrencyResponse,
|
||||||
@@ -328,5 +422,10 @@ export type {
|
|||||||
UpdateCompanySettingsRequest,
|
UpdateCompanySettingsRequest,
|
||||||
UploadLogoResponse,
|
UploadLogoResponse,
|
||||||
UploadFaviconResponse,
|
UploadFaviconResponse,
|
||||||
|
RecaptchaSettingsResponse,
|
||||||
|
RecaptchaSettingsAdminResponse,
|
||||||
|
UpdateRecaptchaSettingsRequest,
|
||||||
|
VerifyRecaptchaRequest,
|
||||||
|
VerifyRecaptchaResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user