updates
This commit is contained in:
Binary file not shown.
@@ -194,9 +194,9 @@ app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
# Import and include other routes
|
||||
from .routes import (
|
||||
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
|
||||
favorite_routes, service_routes, promotion_routes, report_routes,
|
||||
favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes,
|
||||
review_routes, user_routes, audit_routes, admin_privacy_routes,
|
||||
system_settings_routes
|
||||
system_settings_routes, contact_routes
|
||||
)
|
||||
|
||||
# Legacy routes (maintain backward compatibility)
|
||||
@@ -207,6 +207,7 @@ app.include_router(invoice_routes.router, prefix="/api")
|
||||
app.include_router(banner_routes.router, prefix="/api")
|
||||
app.include_router(favorite_routes.router, prefix="/api")
|
||||
app.include_router(service_routes.router, prefix="/api")
|
||||
app.include_router(service_booking_routes.router, prefix="/api")
|
||||
app.include_router(promotion_routes.router, prefix="/api")
|
||||
app.include_router(report_routes.router, prefix="/api")
|
||||
app.include_router(review_routes.router, prefix="/api")
|
||||
@@ -214,6 +215,7 @@ app.include_router(user_routes.router, prefix="/api")
|
||||
app.include_router(audit_routes.router, prefix="/api")
|
||||
app.include_router(admin_privacy_routes.router, prefix="/api")
|
||||
app.include_router(system_settings_routes.router, prefix="/api")
|
||||
app.include_router(contact_routes.router, prefix="/api")
|
||||
|
||||
# Versioned routes (v1)
|
||||
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
@@ -223,6 +225,7 @@ app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(service_booking_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
@@ -230,6 +233,7 @@ app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
logger.info("All routes registered successfully")
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from .booking import Booking
|
||||
from .payment import Payment
|
||||
from .service import Service
|
||||
from .service_usage import ServiceUsage
|
||||
from .service_booking import ServiceBooking, ServiceBookingItem, ServicePayment, ServiceBookingStatus, ServicePaymentStatus, ServicePaymentMethod
|
||||
from .promotion import Promotion
|
||||
from .checkin_checkout import CheckInCheckOut
|
||||
from .banner import Banner
|
||||
@@ -30,6 +31,12 @@ __all__ = [
|
||||
"Payment",
|
||||
"Service",
|
||||
"ServiceUsage",
|
||||
"ServiceBooking",
|
||||
"ServiceBookingItem",
|
||||
"ServicePayment",
|
||||
"ServiceBookingStatus",
|
||||
"ServicePaymentStatus",
|
||||
"ServicePaymentMethod",
|
||||
"Promotion",
|
||||
"CheckInCheckOut",
|
||||
"Banner",
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/models/__pycache__/service_booking.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/service_booking.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
78
Backend/src/models/service_booking.py
Normal file
78
Backend/src/models/service_booking.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class ServiceBookingStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
confirmed = "confirmed"
|
||||
completed = "completed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class ServiceBooking(Base):
|
||||
__tablename__ = "service_bookings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_number = Column(String(50), unique=True, nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
total_amount = Column(Numeric(10, 2), nullable=False)
|
||||
status = Column(Enum(ServiceBookingStatus), nullable=False, default=ServiceBookingStatus.pending)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="service_bookings")
|
||||
service_items = relationship("ServiceBookingItem", back_populates="service_booking", cascade="all, delete-orphan")
|
||||
payments = relationship("ServicePayment", back_populates="service_booking", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServiceBookingItem(Base):
|
||||
__tablename__ = "service_booking_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False)
|
||||
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False, default=1)
|
||||
unit_price = Column(Numeric(10, 2), nullable=False)
|
||||
total_price = Column(Numeric(10, 2), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
service_booking = relationship("ServiceBooking", back_populates="service_items")
|
||||
service = relationship("Service")
|
||||
|
||||
|
||||
class ServicePaymentStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
refunded = "refunded"
|
||||
|
||||
|
||||
class ServicePaymentMethod(str, enum.Enum):
|
||||
cash = "cash"
|
||||
stripe = "stripe"
|
||||
bank_transfer = "bank_transfer"
|
||||
|
||||
|
||||
class ServicePayment(Base):
|
||||
__tablename__ = "service_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False)
|
||||
amount = Column(Numeric(10, 2), nullable=False)
|
||||
payment_method = Column(Enum(ServicePaymentMethod), nullable=False)
|
||||
payment_status = Column(Enum(ServicePaymentStatus), nullable=False, default=ServicePaymentStatus.pending)
|
||||
transaction_id = Column(String(100), nullable=True)
|
||||
payment_date = Column(DateTime, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
service_booking = relationship("ServiceBooking", back_populates="payments")
|
||||
|
||||
@@ -28,4 +28,5 @@ class User(Base):
|
||||
checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by")
|
||||
reviews = relationship("Review", back_populates="user")
|
||||
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
|
||||
service_bookings = relationship("ServiceBooking", back_populates="user")
|
||||
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,7 @@ from ..models.booking import Booking, BookingStatus
|
||||
from ..models.room import Room, RoomStatus
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..models.service_usage import ServiceUsage
|
||||
from ..services.room_service import normalize_images, get_base_url
|
||||
from fastapi import Request
|
||||
from ..utils.mailer import send_email
|
||||
@@ -83,8 +84,8 @@ async def get_all_bookings(
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_id,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_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,
|
||||
"num_guests": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
@@ -148,8 +149,8 @@ async def get_my_bookings(
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_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,
|
||||
"num_guests": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
@@ -217,8 +218,18 @@ async def create_booking(
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
|
||||
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
|
||||
# Parse dates as date-only strings (YYYY-MM-DD) - treat as naive datetime
|
||||
if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date:
|
||||
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
# Date-only format (YYYY-MM-DD) - parse as naive datetime
|
||||
check_in = datetime.strptime(check_in_date, '%Y-%m-%d')
|
||||
|
||||
if 'T' in check_out_date or 'Z' in check_out_date or '+' in check_out_date:
|
||||
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
# Date-only format (YYYY-MM-DD) - parse as naive datetime
|
||||
check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
|
||||
|
||||
# Check for overlapping bookings
|
||||
overlapping = db.query(Booking).filter(
|
||||
@@ -286,12 +297,72 @@ async def create_booking(
|
||||
# Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record
|
||||
# The payment will be created when the customer pays at check-in
|
||||
|
||||
# Add services to booking if provided
|
||||
services = booking_data.get("services", [])
|
||||
if services:
|
||||
from ..models.service import Service
|
||||
from ..models.service_usage import ServiceUsage
|
||||
|
||||
for service_item in services:
|
||||
service_id = service_item.get("service_id")
|
||||
quantity = service_item.get("quantity", 1)
|
||||
|
||||
if not service_id:
|
||||
continue
|
||||
|
||||
# Check if service exists and is active
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service or not service.is_active:
|
||||
continue
|
||||
|
||||
# Calculate total price for this service
|
||||
unit_price = float(service.price)
|
||||
total_price = unit_price * quantity
|
||||
|
||||
# Create service usage
|
||||
service_usage = ServiceUsage(
|
||||
booking_id=booking.id,
|
||||
service_id=service_id,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
total_price=total_price,
|
||||
)
|
||||
db.add(service_usage)
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Fetch with relations for proper serialization (eager load payments)
|
||||
from sqlalchemy.orm import joinedload
|
||||
booking = db.query(Booking).options(joinedload(Booking.payments)).filter(Booking.id == booking.id).first()
|
||||
# Automatically create invoice for the booking
|
||||
try:
|
||||
from ..services.invoice_service import InvoiceService
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
# Reload booking with service_usages for invoice creation
|
||||
booking = db.query(Booking).options(
|
||||
selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
|
||||
).filter(Booking.id == booking.id).first()
|
||||
|
||||
# Create invoice automatically
|
||||
invoice = InvoiceService.create_invoice_from_booking(
|
||||
booking_id=booking.id,
|
||||
db=db,
|
||||
created_by_id=current_user.id,
|
||||
tax_rate=0.0, # Default no tax, can be configured
|
||||
discount_amount=0.0,
|
||||
due_days=30,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail booking creation if invoice creation fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to create invoice for booking {booking.id}: {str(e)}")
|
||||
|
||||
# Fetch with relations for proper serialization (eager load payments and service_usages)
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
booking = db.query(Booking).options(
|
||||
joinedload(Booking.payments),
|
||||
selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
|
||||
).filter(Booking.id == booking.id).first()
|
||||
|
||||
# Determine payment_method and payment_status from payments
|
||||
payment_method_from_payments = None
|
||||
@@ -310,8 +381,8 @@ async def create_booking(
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_id,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_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,
|
||||
"guest_count": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
@@ -349,6 +420,31 @@ async def create_booking(
|
||||
for p in booking.payments
|
||||
]
|
||||
|
||||
# Add service usages if they exist
|
||||
service_usages = getattr(booking, 'service_usages', None)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Booking {booking.id} - service_usages: {service_usages}, type: {type(service_usages)}")
|
||||
|
||||
if service_usages and len(service_usages) > 0:
|
||||
logger.info(f"Booking {booking.id} - Found {len(service_usages)} service usages")
|
||||
booking_dict["service_usages"] = [
|
||||
{
|
||||
"id": su.id,
|
||||
"service_id": su.service_id,
|
||||
"service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service",
|
||||
"quantity": su.quantity,
|
||||
"unit_price": float(su.unit_price) if su.unit_price else 0.0,
|
||||
"total_price": float(su.total_price) if su.total_price else 0.0,
|
||||
}
|
||||
for su in service_usages
|
||||
]
|
||||
logger.info(f"Booking {booking.id} - Serialized service_usages: {booking_dict['service_usages']}")
|
||||
else:
|
||||
# Initialize empty array if no service_usages
|
||||
logger.info(f"Booking {booking.id} - No service_usages found, initializing empty array")
|
||||
booking_dict["service_usages"] = []
|
||||
|
||||
# Add room info if available
|
||||
if booking.room:
|
||||
booking_dict["room"] = {
|
||||
@@ -414,9 +510,11 @@ async def get_booking_by_id(
|
||||
try:
|
||||
# Eager load all relationships to avoid N+1 queries
|
||||
# Using selectinload for better performance with multiple relationships
|
||||
from sqlalchemy.orm import selectinload
|
||||
booking = db.query(Booking)\
|
||||
.options(
|
||||
selectinload(Booking.payments),
|
||||
selectinload(Booking.service_usages).selectinload(ServiceUsage.service),
|
||||
joinedload(Booking.user),
|
||||
joinedload(Booking.room).joinedload(Room.room_type)
|
||||
)\
|
||||
@@ -448,8 +546,8 @@ async def get_booking_by_id(
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_id,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_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,
|
||||
"guest_count": booking.num_guests, # Frontend expects guest_count
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
@@ -513,6 +611,32 @@ async def get_booking_by_id(
|
||||
for p in booking.payments
|
||||
]
|
||||
|
||||
# Add service usages if they exist
|
||||
# Use getattr to safely access service_usages in case relationship isn't loaded
|
||||
service_usages = getattr(booking, 'service_usages', None)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Get booking {id} - service_usages: {service_usages}, type: {type(service_usages)}")
|
||||
|
||||
if service_usages and len(service_usages) > 0:
|
||||
logger.info(f"Get booking {id} - Found {len(service_usages)} service usages")
|
||||
booking_dict["service_usages"] = [
|
||||
{
|
||||
"id": su.id,
|
||||
"service_id": su.service_id,
|
||||
"service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service",
|
||||
"quantity": su.quantity,
|
||||
"unit_price": float(su.unit_price) if su.unit_price else 0.0,
|
||||
"total_price": float(su.total_price) if su.total_price else 0.0,
|
||||
}
|
||||
for su in service_usages
|
||||
]
|
||||
logger.info(f"Get booking {id} - Serialized service_usages: {booking_dict['service_usages']}")
|
||||
else:
|
||||
# Initialize empty array if no service_usages
|
||||
logger.info(f"Get booking {id} - No service_usages found, initializing empty array")
|
||||
booking_dict["service_usages"] = []
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"booking": booking_dict}
|
||||
@@ -657,8 +781,8 @@ async def check_booking_by_number(
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_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,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
}
|
||||
|
||||
|
||||
195
Backend/src/routes/contact_routes.py
Normal file
195
Backend/src/routes/contact_routes.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..models.user import User
|
||||
from ..models.role import Role
|
||||
from ..models.system_settings import SystemSettings
|
||||
from ..utils.mailer import send_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/contact", tags=["contact"])
|
||||
|
||||
|
||||
class ContactForm(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
subject: str
|
||||
message: str
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
def get_admin_email(db: Session) -> str:
|
||||
"""Get admin email from system settings or find admin user"""
|
||||
# First, try to get from system settings
|
||||
admin_email_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "admin_email"
|
||||
).first()
|
||||
|
||||
if admin_email_setting and admin_email_setting.value:
|
||||
return admin_email_setting.value
|
||||
|
||||
# If not found in settings, find the first admin user
|
||||
admin_role = db.query(Role).filter(Role.name == "admin").first()
|
||||
if admin_role:
|
||||
admin_user = db.query(User).filter(
|
||||
User.role_id == admin_role.id,
|
||||
User.is_active == True
|
||||
).first()
|
||||
|
||||
if admin_user:
|
||||
return admin_user.email
|
||||
|
||||
# Fallback to SMTP_FROM_EMAIL if configured
|
||||
from ..config.settings import settings
|
||||
if settings.SMTP_FROM_EMAIL:
|
||||
return settings.SMTP_FROM_EMAIL
|
||||
|
||||
# Last resort: raise error
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Admin email not configured. Please set admin_email in system settings or ensure an admin user exists."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/submit")
|
||||
async def submit_contact_form(
|
||||
contact_data: ContactForm,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Submit contact form and send email to admin"""
|
||||
try:
|
||||
# Get admin email
|
||||
admin_email = get_admin_email(db)
|
||||
|
||||
# Create email subject
|
||||
subject = f"Contact Form: {contact_data.subject}"
|
||||
|
||||
# Create email body (HTML)
|
||||
html_body = 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;
|
||||
background-color: #f9f9f9;
|
||||
}}
|
||||
.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-radius: 0 0 8px 8px;
|
||||
}}
|
||||
.field {{
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.label {{
|
||||
font-weight: bold;
|
||||
color: #d4af37;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}}
|
||||
.value {{
|
||||
color: #333;
|
||||
padding: 10px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.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">
|
||||
<h2>New Contact Form Submission</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="field">
|
||||
<span class="label">Name:</span>
|
||||
<div class="value">{contact_data.name}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Email:</span>
|
||||
<div class="value">{contact_data.email}</div>
|
||||
</div>
|
||||
{f'<div class="field"><span class="label">Phone:</span><div class="value">{contact_data.phone}</div></div>' if contact_data.phone else ''}
|
||||
<div class="field">
|
||||
<span class="label">Subject:</span>
|
||||
<div class="value">{contact_data.subject}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Message:</span>
|
||||
<div class="value" style="white-space: pre-wrap;">{contact_data.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This email was sent from the hotel booking contact form.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Create plain text version
|
||||
text_body = f"""
|
||||
New Contact Form Submission
|
||||
|
||||
Name: {contact_data.name}
|
||||
Email: {contact_data.email}
|
||||
{f'Phone: {contact_data.phone}' if contact_data.phone else ''}
|
||||
Subject: {contact_data.subject}
|
||||
|
||||
Message:
|
||||
{contact_data.message}
|
||||
"""
|
||||
|
||||
# Send email to admin
|
||||
await send_email(
|
||||
to=admin_email,
|
||||
subject=subject,
|
||||
html=html_body,
|
||||
text=text_body
|
||||
)
|
||||
|
||||
logger.info(f"Contact form submitted successfully. Email sent to {admin_email}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Thank you for contacting us! We will get back to you soon."
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to submit contact form: {type(e).__name__}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to submit contact form. Please try again later."
|
||||
)
|
||||
|
||||
@@ -729,6 +729,59 @@ async def delete_room_images(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}/booked-dates")
|
||||
async def get_room_booked_dates(
|
||||
id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all booked dates for a specific room"""
|
||||
try:
|
||||
# Check if room exists
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Get all non-cancelled bookings for this room
|
||||
bookings = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.room_id == id,
|
||||
Booking.status != BookingStatus.cancelled
|
||||
)
|
||||
).all()
|
||||
|
||||
# Generate list of all booked dates
|
||||
booked_dates = []
|
||||
for booking in bookings:
|
||||
# Parse dates
|
||||
check_in = booking.check_in_date
|
||||
check_out = booking.check_out_date
|
||||
|
||||
# Generate all dates between check-in and check-out (exclusive of check-out)
|
||||
current_date = check_in.date()
|
||||
end_date = check_out.date()
|
||||
|
||||
while current_date < end_date:
|
||||
booked_dates.append(current_date.isoformat())
|
||||
# Move to next day
|
||||
from datetime import timedelta
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Remove duplicates and sort
|
||||
booked_dates = sorted(list(set(booked_dates)))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"room_id": id,
|
||||
"booked_dates": booked_dates
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}/reviews")
|
||||
async def get_room_reviews_route(
|
||||
id: int,
|
||||
|
||||
419
Backend/src/routes/service_booking_routes.py
Normal file
419
Backend/src/routes/service_booking_routes.py
Normal file
@@ -0,0 +1,419 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import random
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user
|
||||
from ..models.user import User
|
||||
from ..models.service import Service
|
||||
from ..models.service_booking import (
|
||||
ServiceBooking,
|
||||
ServiceBookingItem,
|
||||
ServicePayment,
|
||||
ServiceBookingStatus,
|
||||
ServicePaymentStatus,
|
||||
ServicePaymentMethod
|
||||
)
|
||||
from ..services.stripe_service import StripeService, get_stripe_secret_key, get_stripe_publishable_key
|
||||
from ..config.settings import settings
|
||||
|
||||
router = APIRouter(prefix="/service-bookings", tags=["service-bookings"])
|
||||
|
||||
|
||||
def generate_service_booking_number() -> str:
|
||||
"""Generate unique service booking number"""
|
||||
prefix = "SB"
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d")
|
||||
random_suffix = random.randint(1000, 9999)
|
||||
return f"{prefix}{timestamp}{random_suffix}"
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_service_booking(
|
||||
booking_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new service booking"""
|
||||
try:
|
||||
services = booking_data.get("services", [])
|
||||
total_amount = float(booking_data.get("total_amount", 0))
|
||||
notes = booking_data.get("notes")
|
||||
|
||||
if not services or len(services) == 0:
|
||||
raise HTTPException(status_code=400, detail="At least one service is required")
|
||||
|
||||
if total_amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="Total amount must be greater than 0")
|
||||
|
||||
# Validate services and calculate total
|
||||
calculated_total = 0
|
||||
service_items_data = []
|
||||
|
||||
for service_item in services:
|
||||
service_id = service_item.get("service_id")
|
||||
quantity = service_item.get("quantity", 1)
|
||||
|
||||
if not service_id:
|
||||
raise HTTPException(status_code=400, detail="Service ID is required for each item")
|
||||
|
||||
# Check if service exists and is active
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail=f"Service with ID {service_id} not found")
|
||||
|
||||
if not service.is_active:
|
||||
raise HTTPException(status_code=400, detail=f"Service {service.name} is not active")
|
||||
|
||||
unit_price = float(service.price)
|
||||
item_total = unit_price * quantity
|
||||
calculated_total += item_total
|
||||
|
||||
service_items_data.append({
|
||||
"service": service,
|
||||
"quantity": quantity,
|
||||
"unit_price": unit_price,
|
||||
"total_price": item_total
|
||||
})
|
||||
|
||||
# Verify calculated total matches provided total (with small tolerance for floating point)
|
||||
if abs(calculated_total - total_amount) > 0.01:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Total amount mismatch. Calculated: {calculated_total}, Provided: {total_amount}"
|
||||
)
|
||||
|
||||
# Generate booking number
|
||||
booking_number = generate_service_booking_number()
|
||||
|
||||
# Create service booking
|
||||
service_booking = ServiceBooking(
|
||||
booking_number=booking_number,
|
||||
user_id=current_user.id,
|
||||
total_amount=total_amount,
|
||||
status=ServiceBookingStatus.pending,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
db.add(service_booking)
|
||||
db.flush() # Flush to get the ID
|
||||
|
||||
# Create service booking items
|
||||
for item_data in service_items_data:
|
||||
booking_item = ServiceBookingItem(
|
||||
service_booking_id=service_booking.id,
|
||||
service_id=item_data["service"].id,
|
||||
quantity=item_data["quantity"],
|
||||
unit_price=item_data["unit_price"],
|
||||
total_price=item_data["total_price"]
|
||||
)
|
||||
db.add(booking_item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(service_booking)
|
||||
|
||||
# Load relationships
|
||||
service_booking = db.query(ServiceBooking).options(
|
||||
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
|
||||
).filter(ServiceBooking.id == service_booking.id).first()
|
||||
|
||||
# Format response
|
||||
booking_dict = {
|
||||
"id": service_booking.id,
|
||||
"booking_number": service_booking.booking_number,
|
||||
"user_id": service_booking.user_id,
|
||||
"total_amount": float(service_booking.total_amount),
|
||||
"status": service_booking.status.value,
|
||||
"notes": service_booking.notes,
|
||||
"created_at": service_booking.created_at.isoformat() if service_booking.created_at else None,
|
||||
"service_items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"service_id": item.service_id,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"total_price": float(item.total_price),
|
||||
"service": {
|
||||
"id": item.service.id,
|
||||
"name": item.service.name,
|
||||
"description": item.service.description,
|
||||
"price": float(item.service.price),
|
||||
}
|
||||
}
|
||||
for item in service_booking.service_items
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Service booking created successfully",
|
||||
"data": {"service_booking": booking_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_service_bookings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all service bookings for current user"""
|
||||
try:
|
||||
bookings = db.query(ServiceBooking).options(
|
||||
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
|
||||
).filter(ServiceBooking.user_id == current_user.id).order_by(ServiceBooking.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for booking in bookings:
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"total_amount": float(booking.total_amount),
|
||||
"status": booking.status.value,
|
||||
"notes": booking.notes,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"service_items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"service_id": item.service_id,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"total_price": float(item.total_price),
|
||||
"service": {
|
||||
"id": item.service.id,
|
||||
"name": item.service.name,
|
||||
"description": item.service.description,
|
||||
"price": float(item.service.price),
|
||||
}
|
||||
}
|
||||
for item in booking.service_items
|
||||
]
|
||||
}
|
||||
result.append(booking_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"service_bookings": result}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_service_booking_by_id(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get service booking by ID"""
|
||||
try:
|
||||
booking = db.query(ServiceBooking).options(
|
||||
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
|
||||
).filter(ServiceBooking.id == id).first()
|
||||
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Service booking not found")
|
||||
|
||||
# Check access
|
||||
if booking.user_id != current_user.id and current_user.role_id != 1:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_id,
|
||||
"total_amount": float(booking.total_amount),
|
||||
"status": booking.status.value,
|
||||
"notes": booking.notes,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"service_items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"service_id": item.service_id,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"total_price": float(item.total_price),
|
||||
"service": {
|
||||
"id": item.service.id,
|
||||
"name": item.service.name,
|
||||
"description": item.service.description,
|
||||
"price": float(item.service.price),
|
||||
}
|
||||
}
|
||||
for item in booking.service_items
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"service_booking": booking_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{id}/payment/stripe/create-intent")
|
||||
async def create_service_stripe_payment_intent(
|
||||
id: int,
|
||||
intent_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create Stripe payment intent for service booking"""
|
||||
try:
|
||||
# Check if Stripe is configured
|
||||
secret_key = get_stripe_secret_key(db)
|
||||
if not secret_key:
|
||||
secret_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
if not secret_key:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Stripe is not configured. Please configure Stripe settings in Admin Panel."
|
||||
)
|
||||
|
||||
amount = float(intent_data.get("amount", 0))
|
||||
currency = intent_data.get("currency", "usd")
|
||||
|
||||
if amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="Amount must be greater than 0")
|
||||
|
||||
# Verify service booking exists and user has access
|
||||
booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Service booking not found")
|
||||
|
||||
if booking.user_id != current_user.id and current_user.role_id != 1:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Verify amount matches booking total
|
||||
if abs(float(booking.total_amount) - amount) > 0.01:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Amount mismatch. Booking total: {booking.total_amount}, Provided: {amount}"
|
||||
)
|
||||
|
||||
# Create payment intent
|
||||
intent = StripeService.create_payment_intent(
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
description=f"Service Booking #{booking.booking_number}",
|
||||
db=db
|
||||
)
|
||||
|
||||
# Get publishable key
|
||||
publishable_key = get_stripe_publishable_key(db)
|
||||
if not publishable_key:
|
||||
publishable_key = settings.STRIPE_PUBLISHABLE_KEY
|
||||
|
||||
if not publishable_key:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Stripe publishable key is not configured."
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"client_secret": intent["client_secret"],
|
||||
"payment_intent_id": intent["id"],
|
||||
"publishable_key": publishable_key
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{id}/payment/stripe/confirm")
|
||||
async def confirm_service_stripe_payment(
|
||||
id: int,
|
||||
payment_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Confirm Stripe payment for service booking"""
|
||||
try:
|
||||
payment_intent_id = payment_data.get("payment_intent_id")
|
||||
|
||||
if not payment_intent_id:
|
||||
raise HTTPException(status_code=400, detail="payment_intent_id is required")
|
||||
|
||||
# Verify service booking exists and user has access
|
||||
booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Service booking not found")
|
||||
|
||||
if booking.user_id != current_user.id and current_user.role_id != 1:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Retrieve and verify payment intent
|
||||
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
|
||||
|
||||
if intent_data["status"] != "succeeded":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Payment intent status is {intent_data['status']}, expected 'succeeded'"
|
||||
)
|
||||
|
||||
# Verify amount matches
|
||||
amount_paid = intent_data["amount"] / 100 # Convert from cents
|
||||
if abs(float(booking.total_amount) - amount_paid) > 0.01:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Payment amount does not match booking total"
|
||||
)
|
||||
|
||||
# Create payment record
|
||||
payment = ServicePayment(
|
||||
service_booking_id=booking.id,
|
||||
amount=booking.total_amount,
|
||||
payment_method=ServicePaymentMethod.stripe,
|
||||
payment_status=ServicePaymentStatus.completed,
|
||||
transaction_id=payment_intent_id,
|
||||
payment_date=datetime.utcnow(),
|
||||
notes=f"Stripe payment - Intent: {payment_intent_id}"
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
|
||||
# Update booking status
|
||||
booking.status = ServiceBookingStatus.confirmed
|
||||
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
db.refresh(booking)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Payment confirmed successfully",
|
||||
"data": {
|
||||
"payment": {
|
||||
"id": payment.id,
|
||||
"amount": float(payment.amount),
|
||||
"payment_method": payment.payment_method.value,
|
||||
"payment_status": payment.payment_status.value,
|
||||
"transaction_id": payment.transaction_id,
|
||||
},
|
||||
"service_booking": {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"status": booking.status.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Binary file not shown.
@@ -62,7 +62,12 @@ class InvoiceService:
|
||||
Returns:
|
||||
Invoice dictionary
|
||||
"""
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
booking = db.query(Booking).options(
|
||||
selectinload(Booking.service_usages).selectinload("service"),
|
||||
selectinload(Booking.room).selectinload("room_type")
|
||||
).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise ValueError("Booking not found")
|
||||
|
||||
@@ -73,10 +78,9 @@ class InvoiceService:
|
||||
# Generate invoice number
|
||||
invoice_number = generate_invoice_number(db)
|
||||
|
||||
# Calculate amounts
|
||||
# Calculate amounts - subtotal will be recalculated after adding items
|
||||
# Initial subtotal is booking total (room + services)
|
||||
subtotal = float(booking.total_price)
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
|
||||
# Calculate amount paid from completed payments
|
||||
amount_paid = sum(
|
||||
@@ -132,15 +136,26 @@ class InvoiceService:
|
||||
db.add(invoice)
|
||||
|
||||
# Create invoice items from booking
|
||||
# Calculate room price (total_price includes services, so subtract services)
|
||||
services_total = sum(
|
||||
float(su.total_price) for su in booking.service_usages
|
||||
)
|
||||
room_price = float(booking.total_price) - services_total
|
||||
|
||||
# Calculate number of nights
|
||||
nights = (booking.check_out_date - booking.check_in_date).days
|
||||
if nights <= 0:
|
||||
nights = 1
|
||||
|
||||
# Room item
|
||||
room_item = InvoiceItem(
|
||||
invoice_id=invoice.id,
|
||||
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'}",
|
||||
quantity=1,
|
||||
unit_price=float(booking.total_price),
|
||||
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 ''})",
|
||||
quantity=nights,
|
||||
unit_price=room_price / nights if nights > 0 else room_price,
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=0.0,
|
||||
line_total=float(booking.total_price),
|
||||
line_total=room_price,
|
||||
room_id=booking.room_id,
|
||||
)
|
||||
db.add(room_item)
|
||||
@@ -151,25 +166,27 @@ class InvoiceService:
|
||||
invoice_id=invoice.id,
|
||||
description=f"Service: {service_usage.service.name}",
|
||||
quantity=float(service_usage.quantity),
|
||||
unit_price=float(service_usage.service.price),
|
||||
unit_price=float(service_usage.unit_price),
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=0.0,
|
||||
line_total=float(service_usage.quantity) * float(service_usage.service.price),
|
||||
line_total=float(service_usage.total_price),
|
||||
service_id=service_usage.service_id,
|
||||
)
|
||||
db.add(service_item)
|
||||
subtotal += float(service_usage.quantity) * float(service_usage.service.price)
|
||||
|
||||
# Recalculate totals if services were added
|
||||
if booking.service_usages:
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
balance_due = total_amount - amount_paid
|
||||
|
||||
invoice.subtotal = subtotal
|
||||
invoice.tax_amount = tax_amount
|
||||
invoice.total_amount = total_amount
|
||||
invoice.balance_due = balance_due
|
||||
# Recalculate subtotal from items (room + services)
|
||||
subtotal = room_price + services_total
|
||||
|
||||
# Recalculate tax and total amounts
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
balance_due = total_amount - amount_paid
|
||||
|
||||
# Update invoice with correct amounts
|
||||
invoice.subtotal = subtotal
|
||||
invoice.tax_amount = tax_amount
|
||||
invoice.total_amount = total_amount
|
||||
invoice.balance_due = balance_due
|
||||
|
||||
db.commit()
|
||||
db.refresh(invoice)
|
||||
|
||||
@@ -47,6 +47,7 @@ const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'
|
||||
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
|
||||
const AboutPage = lazy(() => import('./pages/AboutPage'));
|
||||
const ContactPage = lazy(() => import('./pages/ContactPage'));
|
||||
const LoginPage = lazy(() => import('./pages/auth/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('./pages/auth/RegisterPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage'));
|
||||
@@ -182,6 +183,10 @@ function App() {
|
||||
path="about"
|
||||
element={<AboutPage />}
|
||||
/>
|
||||
<Route
|
||||
path="contact"
|
||||
element={<ContactPage />}
|
||||
/>
|
||||
|
||||
{/* Protected Routes - Requires login */}
|
||||
<Route
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Phone,
|
||||
Mail,
|
||||
Calendar,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
|
||||
@@ -131,6 +132,15 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<span className="relative z-10">About</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="text-white/90 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light px-4 py-2
|
||||
relative group tracking-wide"
|
||||
>
|
||||
<span className="relative z-10">Contact</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Auth Section */}
|
||||
@@ -330,6 +340,17 @@ const Header: React.FC<HeaderProps> = ({
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
Contact
|
||||
</Link>
|
||||
|
||||
<div className="border-t border-[#d4af37]/20
|
||||
pt-3 mt-3"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Tv,
|
||||
Wind,
|
||||
ArrowRight,
|
||||
Crown,
|
||||
} from 'lucide-react';
|
||||
import type { Room } from '../../services/api/roomService';
|
||||
import FavoriteButton from './FavoriteButton';
|
||||
@@ -15,9 +16,10 @@ import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
|
||||
interface RoomCardProps {
|
||||
room: Room;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
const roomType = room.room_type;
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
@@ -70,13 +72,17 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="luxury-card overflow-hidden group
|
||||
border-t-2 border-transparent hover:border-[#d4af37]
|
||||
hover:shadow-luxury-gold"
|
||||
className={`luxury-card overflow-hidden group h-full flex flex-col
|
||||
border-t-2 transition-all duration-300
|
||||
${room.featured
|
||||
? 'border-[#d4af37] shadow-lg shadow-[#d4af37]/20 hover:border-[#d4af37] hover:shadow-luxury-gold'
|
||||
: 'border-transparent hover:border-[#d4af37] hover:shadow-luxury-gold'
|
||||
}`}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-52 overflow-hidden
|
||||
bg-gradient-to-br from-gray-200 to-gray-300"
|
||||
<div className={`relative overflow-hidden
|
||||
bg-gradient-to-br from-gray-200 to-gray-300
|
||||
${compact ? 'h-32 sm:h-36' : 'h-40 sm:h-44 md:h-48 lg:h-52'}`}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
@@ -102,16 +108,18 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
<FavoriteButton roomId={room.id} size="md" />
|
||||
</div>
|
||||
|
||||
{/* Featured Badge */}
|
||||
{/* Featured Badge with Crown */}
|
||||
{room.featured && (
|
||||
<div
|
||||
className="absolute top-3 left-3
|
||||
className="absolute top-3 left-3 z-20
|
||||
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-3 py-1.5
|
||||
rounded-sm text-xs font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 backdrop-blur-sm"
|
||||
shadow-lg shadow-[#d4af37]/30 backdrop-blur-sm
|
||||
flex items-center gap-1.5"
|
||||
>
|
||||
Featured
|
||||
<Crown className="w-4 h-4 fill-[#0f0f0f] text-[#0f0f0f]" />
|
||||
<span>Featured</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -137,36 +145,45 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className={`flex-1 flex flex-col ${compact ? 'p-3' : 'p-4 sm:p-5'}`}>
|
||||
{/* Room Type Name */}
|
||||
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-2 tracking-tight">
|
||||
{roomType.name}
|
||||
<h3 className={`font-serif font-semibold text-gray-900 mb-1.5 tracking-tight
|
||||
${compact ? 'text-base' : 'text-lg sm:text-xl'}
|
||||
flex items-center gap-2`}>
|
||||
{room.featured && (
|
||||
<Crown
|
||||
className={`${compact ? 'w-4 h-4' : 'w-5 h-5'}
|
||||
text-[#d4af37] fill-[#d4af37]
|
||||
drop-shadow-lg animate-pulse`}
|
||||
/>
|
||||
)}
|
||||
<span>{roomType.name}</span>
|
||||
</h3>
|
||||
|
||||
{/* Room Number & Floor */}
|
||||
<div
|
||||
className="flex items-center text-sm
|
||||
text-gray-600 mb-3 font-light tracking-wide"
|
||||
className={`flex items-center text-gray-600 font-light tracking-wide
|
||||
${compact ? 'text-xs mb-1.5' : 'text-xs sm:text-sm mb-2'}`}
|
||||
>
|
||||
<MapPin className="w-4 h-4 mr-1.5 text-[#d4af37]" />
|
||||
<MapPin className={`${compact ? 'w-3 h-3' : 'w-4 h-4'} mr-1.5 text-[#d4af37]`} />
|
||||
<span>
|
||||
Room {room.room_number} - Floor {room.floor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description (truncated) - Show room-specific description first */}
|
||||
{(room.description || roomType.description) && (
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2
|
||||
{(room.description || roomType.description) && !compact && (
|
||||
<p className="text-gray-600 text-xs sm:text-sm mb-3 line-clamp-2
|
||||
leading-relaxed font-light">
|
||||
{room.description || roomType.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Capacity & Rating */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`flex items-center justify-between ${compact ? 'mb-1.5' : 'mb-3'}`}>
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Users className="w-4 h-4 mr-1.5 text-[#d4af37]" />
|
||||
<span className="text-sm font-light tracking-wide">
|
||||
<Users className={`${compact ? 'w-3 h-3' : 'w-4 h-4'} mr-1.5 text-[#d4af37]`} />
|
||||
<span className={`font-light tracking-wide ${compact ? 'text-xs' : 'text-sm'}`}>
|
||||
{room.capacity || roomType.capacity} guests
|
||||
</span>
|
||||
</div>
|
||||
@@ -174,29 +191,31 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
{room.average_rating != null && (
|
||||
<div className="flex items-center">
|
||||
<Star
|
||||
className="w-4 h-4 text-[#d4af37] mr-1"
|
||||
className={`${compact ? 'w-3 h-3' : 'w-4 h-4'} text-[#d4af37] mr-1`}
|
||||
fill="#d4af37"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
<span className={`font-semibold text-gray-900 ${compact ? 'text-xs' : 'text-sm'}`}>
|
||||
{Number(room.average_rating).toFixed(1)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 ml-1 font-light">
|
||||
({Number(room.total_reviews || 0)})
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="text-xs text-gray-500 ml-1 font-light">
|
||||
({Number(room.total_reviews || 0)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
{amenities.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
{amenities.length > 0 && !compact && (
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 mb-3 sm:mb-4 flex-wrap">
|
||||
{amenities.map((amenity, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1
|
||||
text-gray-700 text-xs bg-[#d4af37]/10
|
||||
border border-[#d4af37]/20
|
||||
px-2.5 py-1.5 rounded-sm
|
||||
px-2 sm:px-2.5 py-1 sm:py-1.5 rounded-sm
|
||||
font-light tracking-wide"
|
||||
title={amenity}
|
||||
>
|
||||
@@ -204,31 +223,38 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
|
||||
{amenityIcons[amenity.toLowerCase()] ||
|
||||
<span>•</span>}
|
||||
</span>
|
||||
<span className="capitalize">{amenity}</span>
|
||||
<span className="capitalize hidden sm:inline">{amenity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price & Action */}
|
||||
<div className="flex items-center justify-between pt-4
|
||||
border-t border-gray-200">
|
||||
<div className={`flex flex-col sm:flex-row items-start sm:items-center justify-between mt-auto
|
||||
border-t border-gray-200
|
||||
${compact ? 'gap-2 pt-2' : 'gap-2 sm:gap-3 pt-3'}`}>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-light tracking-wide mb-0.5">From</p>
|
||||
<p className="text-2xl font-serif font-semibold
|
||||
text-gradient-luxury tracking-tight">
|
||||
{!compact && (
|
||||
<p className="text-xs text-gray-500 font-light tracking-wide mb-0.5">From</p>
|
||||
)}
|
||||
<p className={`font-serif font-semibold text-gradient-luxury tracking-tight
|
||||
${compact ? 'text-lg' : 'text-lg sm:text-xl'}`}>
|
||||
{formattedPrice}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 font-light tracking-wide mt-0.5">/ night</p>
|
||||
{!compact && (
|
||||
<p className="text-xs text-gray-500 font-light tracking-wide mt-0.5">/ night</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/rooms/${room.room_number}`}
|
||||
className="btn-luxury-primary flex items-center gap-2
|
||||
text-sm px-5 py-2.5 relative"
|
||||
className={`btn-luxury-primary flex items-center gap-1.5 sm:gap-2 relative justify-center
|
||||
${compact
|
||||
? 'text-xs px-3 py-1.5 w-full sm:w-auto'
|
||||
: 'text-xs px-3 sm:px-4 py-1.5 sm:py-2 w-full sm:w-auto'}`}
|
||||
>
|
||||
<span className="relative z-10">View Details</span>
|
||||
<ArrowRight className="w-4 h-4 relative z-10" />
|
||||
<ArrowRight className={`relative z-10 ${compact ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,46 +8,46 @@ const RoomCardSkeleton: React.FC = () => {
|
||||
overflow-hidden animate-pulse shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
{/* Image Skeleton */}
|
||||
<div className="h-52 bg-gradient-to-br from-gray-800 to-gray-900" />
|
||||
<div className="h-40 sm:h-44 md:h-48 lg:h-52 bg-gradient-to-br from-gray-800 to-gray-900" />
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-5">
|
||||
{/* Title */}
|
||||
<div className="h-6 bg-gray-800 rounded w-3/4 mb-3" />
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-3/4 mb-2" />
|
||||
|
||||
{/* Room Number */}
|
||||
<div className="h-4 bg-gray-800 rounded w-1/2 mb-4" />
|
||||
<div className="h-3 sm:h-4 bg-gray-800 rounded w-1/2 mb-2" />
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="h-3 bg-gray-800 rounded w-full" />
|
||||
<div className="h-3 bg-gray-800 rounded w-5/6" />
|
||||
</div>
|
||||
|
||||
{/* Capacity & Rating */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="h-4 bg-gray-800 rounded w-20" />
|
||||
<div className="h-4 bg-gray-800 rounded w-16" />
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-3 sm:h-4 bg-gray-800 rounded w-20" />
|
||||
<div className="h-3 sm:h-4 bg-gray-800 rounded w-16" />
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
<div className="flex gap-2 mb-5">
|
||||
<div className="h-6 bg-gray-800 rounded w-16" />
|
||||
<div className="h-6 bg-gray-800 rounded w-16" />
|
||||
<div className="h-6 bg-gray-800 rounded w-16" />
|
||||
<div className="flex gap-1.5 sm:gap-2 mb-3 sm:mb-4">
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
|
||||
</div>
|
||||
|
||||
{/* Price & Button */}
|
||||
<div
|
||||
className="flex items-center justify-between
|
||||
pt-4 border-t border-[#d4af37]/20"
|
||||
pt-3 border-t border-[#d4af37]/20"
|
||||
>
|
||||
<div>
|
||||
<div className="h-3 bg-gray-800 rounded w-12 mb-2" />
|
||||
<div className="h-7 bg-gray-800 rounded w-24 mb-2" />
|
||||
<div className="h-3 bg-gray-800 rounded w-12 mb-1" />
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-20 sm:w-24 mb-1" />
|
||||
<div className="h-3 bg-gray-800 rounded w-10" />
|
||||
</div>
|
||||
<div className="h-10 bg-gray-800 rounded w-28" />
|
||||
<div className="h-8 sm:h-9 bg-gray-800 rounded w-24 sm:w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
216
Frontend/src/components/rooms/RoomCarousel.tsx
Normal file
216
Frontend/src/components/rooms/RoomCarousel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { Room } from '../../services/api/roomService';
|
||||
import RoomCard from './RoomCard';
|
||||
|
||||
interface RoomCarouselProps {
|
||||
rooms: Room[];
|
||||
autoSlideInterval?: number; // in milliseconds, default 4000
|
||||
showNavigation?: boolean;
|
||||
}
|
||||
|
||||
const RoomCarousel: React.FC<RoomCarouselProps> = ({
|
||||
rooms,
|
||||
autoSlideInterval = 4000,
|
||||
showNavigation = true,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Auto-slide functionality
|
||||
useEffect(() => {
|
||||
if (rooms.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev === rooms.length - 1 ? 0 : prev + 1));
|
||||
}, autoSlideInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [rooms.length, autoSlideInterval]);
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (isAnimating || rooms.length <= 1) return;
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex((prev) => (prev === 0 ? rooms.length - 1 : prev - 1));
|
||||
setTimeout(() => setIsAnimating(false), 500);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (isAnimating || rooms.length <= 1) return;
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex((prev) => (prev === rooms.length - 1 ? 0 : prev + 1));
|
||||
setTimeout(() => setIsAnimating(false), 500);
|
||||
};
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
if (isAnimating || index === currentIndex) return;
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex(index);
|
||||
setTimeout(() => setIsAnimating(false), 500);
|
||||
};
|
||||
|
||||
if (rooms.length === 0) {
|
||||
return (
|
||||
<div className="luxury-card p-12 text-center animate-fade-in">
|
||||
<p className="text-gray-600 text-lg font-light tracking-wide">
|
||||
No rooms available
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate transform for responsive carousel
|
||||
// Mobile: show 1 card (100% width), Tablet: show 2 cards (50% width), Desktop: show 3 cards (33.33% width)
|
||||
const getTransform = () => {
|
||||
if (rooms.length === 1) {
|
||||
return 'translateX(0)';
|
||||
}
|
||||
|
||||
// For desktop (3 cards): use 33.33% per card
|
||||
// For tablet (2 cards): use 50% per card
|
||||
// For mobile (1 card): use 100% per card
|
||||
|
||||
// We calculate for desktop (3 cards) as base, CSS will handle responsive widths
|
||||
let offset = 0;
|
||||
if (rooms.length <= 3) {
|
||||
offset = 0;
|
||||
} else if (currentIndex === 0) {
|
||||
offset = 0; // Show first cards
|
||||
} else if (currentIndex === rooms.length - 1) {
|
||||
offset = (rooms.length - 3) * 33.33; // Show last 3 cards
|
||||
} else {
|
||||
offset = (currentIndex - 1) * 33.33; // Center the current card
|
||||
}
|
||||
|
||||
return `translateX(-${offset}%)`;
|
||||
};
|
||||
|
||||
// Determine which card should be highlighted as center (for desktop 3-card view)
|
||||
const getCenterIndex = () => {
|
||||
if (rooms.length === 1) {
|
||||
return 0;
|
||||
}
|
||||
if (rooms.length === 2) {
|
||||
return currentIndex === 0 ? 0 : 1;
|
||||
}
|
||||
if (rooms.length === 3) {
|
||||
return 1; // Always highlight middle card when showing all 3
|
||||
}
|
||||
if (currentIndex === 0) {
|
||||
return 1; // Second card when showing first 3
|
||||
}
|
||||
if (currentIndex === rooms.length - 1) {
|
||||
return rooms.length - 2; // Second to last when showing last 3
|
||||
}
|
||||
return currentIndex; // Current card is center
|
||||
};
|
||||
|
||||
const centerIndex = getCenterIndex();
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-6xl mx-auto">
|
||||
{/* Carousel Container */}
|
||||
<div className="relative overflow-hidden px-2 sm:px-3 md:px-4">
|
||||
{/* Room Cards Container */}
|
||||
<div
|
||||
className="flex transition-transform duration-500 ease-in-out"
|
||||
style={{
|
||||
transform: getTransform(),
|
||||
}}
|
||||
>
|
||||
{rooms.map((room, index) => {
|
||||
// For mobile: all cards are "center", for tablet/desktop: use centerIndex
|
||||
const isCenter = index === centerIndex || rooms.length <= 2;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className="flex-shrink-0 px-1 sm:px-2 w-full md:w-1/2 lg:w-1/3"
|
||||
>
|
||||
<div
|
||||
className={`mx-auto transition-all duration-300 ${
|
||||
isCenter
|
||||
? 'scale-100 opacity-100 z-10'
|
||||
: 'scale-90 opacity-70'
|
||||
}`}
|
||||
>
|
||||
<RoomCard room={room} compact={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{showNavigation && rooms.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
disabled={isAnimating || currentIndex === 0}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 sm:-translate-x-4
|
||||
z-10 w-8 h-8 sm:w-9 sm:h-9
|
||||
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-white rounded-full
|
||||
flex items-center justify-center
|
||||
shadow-lg shadow-[#d4af37]/40
|
||||
hover:shadow-xl hover:shadow-[#d4af37]/50
|
||||
hover:scale-110
|
||||
active:scale-95
|
||||
transition-all duration-300
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
group backdrop-blur-sm"
|
||||
aria-label="Previous room"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={isAnimating || currentIndex >= rooms.length - 1}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 sm:translate-x-4
|
||||
z-10 w-8 h-8 sm:w-9 sm:h-9
|
||||
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-white rounded-full
|
||||
flex items-center justify-center
|
||||
shadow-lg shadow-[#d4af37]/40
|
||||
hover:shadow-xl hover:shadow-[#d4af37]/50
|
||||
hover:scale-110
|
||||
active:scale-95
|
||||
transition-all duration-300
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
group backdrop-blur-sm"
|
||||
aria-label="Next room"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dots Indicator */}
|
||||
{rooms.length > 1 && (
|
||||
<div className="flex justify-center items-center gap-1.5 sm:gap-2 mt-3 sm:mt-4">
|
||||
{rooms.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToSlide(index)}
|
||||
disabled={isAnimating}
|
||||
className={`transition-all duration-300 rounded-full
|
||||
${
|
||||
index === currentIndex
|
||||
? 'w-6 h-1.5 sm:w-7 sm:h-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'w-1.5 h-1.5 sm:w-2 sm:h-2 bg-gray-300 hover:bg-[#d4af37]/50'
|
||||
}
|
||||
disabled:cursor-not-allowed
|
||||
`}
|
||||
aria-label={`Go to room ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomCarousel;
|
||||
|
||||
@@ -253,20 +253,20 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-xl border border-[#d4af37]/30
|
||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/10
|
||||
p-6"
|
||||
p-4 sm:p-5 md:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-4 sm:mb-5 md:mb-6">
|
||||
<div className="p-1.5 sm:p-2 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<svg className="w-5 h-5 text-[#d4af37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-serif font-semibold mb-0 text-white tracking-tight">
|
||||
<h2 className="text-lg sm:text-xl font-serif font-semibold mb-0 text-white tracking-tight">
|
||||
Room Filters
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* Room Type */}
|
||||
<div>
|
||||
<label
|
||||
@@ -570,14 +570,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] py-3 px-4 rounded-sm font-medium tracking-wide
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
text-[#0f0f0f] py-2.5 sm:py-3 px-4 rounded-sm font-medium tracking-wide text-sm sm:text-base
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
|
||||
transition-all duration-300 shadow-lg shadow-[#d4af37]/30
|
||||
relative overflow-hidden group"
|
||||
relative overflow-hidden group touch-manipulation min-h-[44px]"
|
||||
>
|
||||
<span className="relative z-10">Apply</span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
@@ -586,9 +586,10 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex-1 bg-[#0a0a0a] backdrop-blur-sm text-gray-300
|
||||
py-3 px-4 rounded-sm border border-[#d4af37]/30
|
||||
hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37]
|
||||
transition-all font-medium tracking-wide"
|
||||
py-2.5 sm:py-3 px-4 rounded-sm border border-[#d4af37]/30
|
||||
hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37] active:scale-95
|
||||
transition-all font-medium tracking-wide text-sm sm:text-base
|
||||
touch-manipulation min-h-[44px]"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as RoomCard } from './RoomCard';
|
||||
export { default as RoomCardSkeleton } from './RoomCardSkeleton';
|
||||
export { default as RoomCarousel } from './RoomCarousel';
|
||||
export { default as BannerCarousel } from './BannerCarousel';
|
||||
export { default as BannerSkeleton } from './BannerSkeleton';
|
||||
export { default as RoomFilter } from './RoomFilter';
|
||||
|
||||
@@ -4,6 +4,7 @@ import App from './App.tsx';
|
||||
import ErrorBoundary from
|
||||
'./components/common/ErrorBoundary.tsx';
|
||||
import './styles/index.css';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import './styles/datepicker.css';
|
||||
|
||||
ReactDOM.createRoot(
|
||||
|
||||
405
Frontend/src/pages/ContactPage.tsx
Normal file
405
Frontend/src/pages/ContactPage.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Mail, Phone, MapPin, Send, User, MessageSquare } from 'lucide-react';
|
||||
import { submitContactForm } from '../services/api/contactService';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const ContactPage: React.FC = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
if (!formData.subject.trim()) {
|
||||
newErrors.subject = 'Subject is required';
|
||||
}
|
||||
|
||||
if (!formData.message.trim()) {
|
||||
newErrors.message = 'Message is required';
|
||||
} else if (formData.message.trim().length < 10) {
|
||||
newErrors.message = 'Message must be at least 10 characters long';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await submitContactForm(formData);
|
||||
toast.success('Thank you for contacting us! We will get back to you soon.');
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
setErrors({});
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Full-width hero section */}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8 overflow-hidden relative">
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 sm:w-48 h-32 sm:h-48 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4 sm:py-5 md:py-6 relative z-10">
|
||||
<div className="max-w-2xl mx-auto text-center px-2">
|
||||
<div className="flex justify-center mb-2 sm:mb-3 md:mb-4">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#c9a227] rounded-xl blur-lg opacity-40 group-hover:opacity-60 transition-opacity duration-500"></div>
|
||||
<div className="relative p-2 sm:p-2.5 md:p-3 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border-2 border-[#d4af37]/40 backdrop-blur-sm shadow-xl shadow-[#d4af37]/20 group-hover:border-[#d4af37]/60 transition-all duration-300">
|
||||
<Mail className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2">
|
||||
<span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent">
|
||||
Contact Us
|
||||
</span>
|
||||
</h1>
|
||||
<div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div>
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4">
|
||||
Experience the pinnacle of hospitality. We're here to make your stay extraordinary.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Full-width content area */}
|
||||
<div className="w-full py-4 sm:py-6 md:py-8 lg:py-10 xl:py-12">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5 md:gap-6 lg:gap-7 xl:gap-8 2xl:gap-10 max-w-7xl mx-auto">
|
||||
{/* Contact Info Section */}
|
||||
<div className="lg:col-span-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a]
|
||||
rounded-xl sm:rounded-2xl border-2 border-[#d4af37]/30 p-5 sm:p-6 md:p-8 lg:p-10
|
||||
shadow-2xl shadow-[#d4af37]/10 backdrop-blur-xl
|
||||
relative overflow-hidden h-full group hover:border-[#d4af37]/50 transition-all duration-500">
|
||||
{/* Subtle background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-6 sm:mb-7 md:mb-8">
|
||||
<div className="w-0.5 sm:w-1 h-6 sm:h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227] rounded-full"></div>
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-serif font-semibold
|
||||
text-white tracking-tight">
|
||||
Get in Touch
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 sm:space-y-6 md:space-y-7">
|
||||
<div className="flex items-start gap-3 sm:gap-4 md:gap-5 group/item hover:translate-x-1 transition-transform duration-300">
|
||||
<div className="p-2.5 sm:p-3 md:p-4 bg-gradient-to-br from-[#d4af37]/20 to-[#d4af37]/10 rounded-lg sm:rounded-xl border border-[#d4af37]/40 flex-shrink-0 group-hover/item:bg-gradient-to-br group-hover/item:from-[#d4af37]/30 group-hover/item:to-[#d4af37]/20 group-hover/item:border-[#d4af37]/60 transition-all duration-300 shadow-lg shadow-[#d4af37]/10">
|
||||
<Mail className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Email</h3>
|
||||
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
|
||||
We'll respond within 24 hours
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 sm:gap-4 md:gap-5 group/item hover:translate-x-1 transition-transform duration-300">
|
||||
<div className="p-2.5 sm:p-3 md:p-4 bg-gradient-to-br from-[#d4af37]/20 to-[#d4af37]/10 rounded-lg sm:rounded-xl border border-[#d4af37]/40 flex-shrink-0 group-hover/item:bg-gradient-to-br group-hover/item:from-[#d4af37]/30 group-hover/item:to-[#d4af37]/20 group-hover/item:border-[#d4af37]/60 transition-all duration-300 shadow-lg shadow-[#d4af37]/10">
|
||||
<Phone className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Phone</h3>
|
||||
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
|
||||
Available 24/7 for your convenience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 sm:gap-4 md:gap-5 group/item hover:translate-x-1 transition-transform duration-300">
|
||||
<div className="p-2.5 sm:p-3 md:p-4 bg-gradient-to-br from-[#d4af37]/20 to-[#d4af37]/10 rounded-lg sm:rounded-xl border border-[#d4af37]/40 flex-shrink-0 group-hover/item:bg-gradient-to-br group-hover/item:from-[#d4af37]/30 group-hover/item:to-[#d4af37]/20 group-hover/item:border-[#d4af37]/60 transition-all duration-300 shadow-lg shadow-[#d4af37]/10">
|
||||
<MapPin className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Location</h3>
|
||||
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
|
||||
Visit us at our hotel reception
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Maps */}
|
||||
<div className="mt-6 sm:mt-7 md:mt-8 pt-6 sm:pt-7 md:pt-8 border-t border-[#d4af37]/30">
|
||||
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-3 sm:mb-4 tracking-wide">
|
||||
Find Us
|
||||
</h3>
|
||||
<div className="relative rounded-lg overflow-hidden border-2 border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/10 group hover:border-[#d4af37]/50 transition-all duration-300">
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3022.1841582344433!2d-73.98784668436963!3d40.75889597932664!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x89c25855c6480299%3A0x55194ec5a1ae072e!2sTimes%20Square!5e0!3m2!1sen!2sus!4v1234567890123!5m2!1sen!2sus"
|
||||
width="100%"
|
||||
height="200"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="w-full h-40 sm:h-44 md:h-48 rounded-lg"
|
||||
title="Hotel Location"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-6 md:mt-7 pt-5 sm:pt-6 md:pt-7 border-t border-[#d4af37]/30">
|
||||
<p className="text-gray-400 font-light text-xs sm:text-sm leading-relaxed tracking-wide">
|
||||
Our team is here to help you with any questions about your stay,
|
||||
bookings, or special requests. We're committed to exceeding your expectations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form Section */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a]
|
||||
rounded-xl sm:rounded-2xl border-2 border-[#d4af37]/30 p-5 sm:p-6 md:p-8 lg:p-10
|
||||
shadow-2xl shadow-[#d4af37]/10 backdrop-blur-xl
|
||||
relative overflow-hidden">
|
||||
{/* Subtle background pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-0 right-0 w-48 sm:w-64 md:w-96 h-48 sm:h-64 md:h-96 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-6 sm:mb-7 md:mb-8">
|
||||
<div className="w-0.5 sm:w-1 h-6 sm:h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227] rounded-full"></div>
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-serif font-semibold
|
||||
text-white tracking-tight">
|
||||
Send Us a Message
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6 md:space-y-7">
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
|
||||
Full Name <span className="text-[#d4af37] font-semibold">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
|
||||
text-white text-sm sm:text-base placeholder-gray-500/60
|
||||
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
|
||||
transition-all duration-300 hover:border-[#d4af37]/40
|
||||
${errors.name ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email and Phone Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5 md:gap-6 lg:gap-7">
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
|
||||
Email <span className="text-[#d4af37] font-semibold">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
|
||||
text-white text-sm sm:text-base placeholder-gray-500/60
|
||||
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
|
||||
transition-all duration-300 hover:border-[#d4af37]/40
|
||||
${errors.email ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
|
||||
Phone <span className="text-gray-500 text-xs">(Optional)</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 border-[#d4af37]/30 rounded-lg
|
||||
text-white text-sm sm:text-base placeholder-gray-500/60
|
||||
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
|
||||
transition-all duration-300 hover:border-[#d4af37]/40"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject Field */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
|
||||
Subject <span className="text-[#d4af37] font-semibold">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
|
||||
text-white text-sm sm:text-base placeholder-gray-500/60
|
||||
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
|
||||
transition-all duration-300 hover:border-[#d4af37]/40
|
||||
${errors.subject ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
|
||||
placeholder="What is this regarding?"
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.subject}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Field */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
|
||||
Message <span className="text-[#d4af37] font-semibold">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
rows={6}
|
||||
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
|
||||
text-white text-sm sm:text-base placeholder-gray-500/60 resize-none
|
||||
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
|
||||
transition-all duration-300 hover:border-[#d4af37]/40
|
||||
${errors.message ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
|
||||
placeholder="Tell us more about your inquiry..."
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2 sm:pt-3 md:pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group w-full sm:w-auto inline-flex items-center justify-center gap-2 sm:gap-3
|
||||
bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]
|
||||
text-[#0f0f0f] font-semibold
|
||||
active:scale-[0.98]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-500
|
||||
tracking-wide text-sm sm:text-base
|
||||
px-6 sm:px-8 md:px-10 py-3 sm:py-3.5 md:py-4 rounded-lg
|
||||
shadow-2xl shadow-[#d4af37]/40 hover:shadow-[#d4af37]/60 hover:scale-[1.02]
|
||||
relative overflow-hidden
|
||||
touch-manipulation min-h-[44px] sm:min-h-[48px] md:min-h-[52px]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/30 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 sm:w-5 sm:h-5 border-2 border-[#0f0f0f] border-t-transparent rounded-full animate-spin relative z-10" />
|
||||
<span className="relative z-10">Sending...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 sm:w-5 sm:h-5 relative z-10 group-hover:translate-x-1 transition-transform duration-300" />
|
||||
<span className="relative z-10">Send Message</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactPage;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BannerSkeleton,
|
||||
RoomCard,
|
||||
RoomCardSkeleton,
|
||||
RoomCarousel,
|
||||
SearchRoomForm,
|
||||
} from '../components/rooms';
|
||||
import {
|
||||
@@ -28,6 +29,25 @@ const HomePage: React.FC = () => {
|
||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Combine featured and newest rooms, removing duplicates
|
||||
const combinedRooms = useMemo(() => {
|
||||
const roomMap = new Map<number, Room>();
|
||||
|
||||
// Add featured rooms first (they take priority)
|
||||
featuredRooms.forEach(room => {
|
||||
roomMap.set(room.id, room);
|
||||
});
|
||||
|
||||
// Add newest rooms that aren't already in the map
|
||||
newestRooms.forEach(room => {
|
||||
if (!roomMap.has(room.id)) {
|
||||
roomMap.set(room.id, room);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(roomMap.values());
|
||||
}, [featuredRooms, newestRooms]);
|
||||
|
||||
// Fetch banners
|
||||
useEffect(() => {
|
||||
const fetchBanners = async () => {
|
||||
@@ -159,45 +179,40 @@ const HomePage: React.FC = () => {
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100/50 to-gray-50">
|
||||
|
||||
{/* Featured Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
{/* Section Header */}
|
||||
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2 className="luxury-section-title">
|
||||
Featured Rooms
|
||||
</h2>
|
||||
<p className="luxury-section-subtitle">
|
||||
Discover our most popular accommodations
|
||||
</p>
|
||||
</div>
|
||||
{/* Featured & Newest Rooms Section - Combined Carousel */}
|
||||
<section className="container mx-auto px-4 py-6 md:py-8">
|
||||
{/* Section Header - Centered */}
|
||||
<div className="text-center animate-fade-in mb-6 md:mb-8">
|
||||
<h2 className="luxury-section-title text-center">
|
||||
Featured & Newest Rooms
|
||||
</h2>
|
||||
<p className="luxury-section-subtitle text-center max-w-2xl mx-auto mt-2">
|
||||
Discover our most popular accommodations and latest additions
|
||||
</p>
|
||||
|
||||
{/* View All Rooms Button - Golden, Centered */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="btn-luxury-primary inline-flex items-center gap-2 px-6 py-3 rounded-sm font-medium tracking-wide"
|
||||
>
|
||||
<span className="relative z-10">View All Rooms</span>
|
||||
<ArrowRight className="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
btn-luxury-secondary group text-white"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoadingRooms && (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
{(isLoadingRooms || isLoadingNewest) && (
|
||||
<div className="flex justify-center">
|
||||
<div className="max-w-md w-full">
|
||||
<RoomCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoadingRooms && (
|
||||
{error && !isLoadingRooms && !isLoadingNewest && (
|
||||
<div
|
||||
className="luxury-card p-8 text-center animate-fade-in
|
||||
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
|
||||
@@ -224,115 +239,25 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rooms Grid */}
|
||||
{!isLoadingRooms && !error && (
|
||||
{/* Combined Rooms Carousel */}
|
||||
{!isLoadingRooms && !isLoadingNewest && (
|
||||
<>
|
||||
{featuredRooms.length > 0 ? (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{featuredRooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
{combinedRooms.length > 0 ? (
|
||||
<RoomCarousel
|
||||
rooms={combinedRooms}
|
||||
autoSlideInterval={4000}
|
||||
showNavigation={true}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="luxury-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<p className="text-gray-600 text-lg font-light tracking-wide">
|
||||
No featured rooms available
|
||||
No rooms available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{featuredRooms.length > 0 && (
|
||||
<div className="mt-10 text-center md:hidden animate-slide-up">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="btn-luxury-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<span className="relative z-10">View All Rooms</span>
|
||||
<ArrowRight className="w-5 h-5 relative z-10" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Newest Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
{/* Section Header */}
|
||||
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2 className="luxury-section-title">
|
||||
Newest Rooms
|
||||
</h2>
|
||||
<p className="luxury-section-subtitle">
|
||||
Explore our latest additions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
btn-luxury-secondary group text-white"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoadingNewest && (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rooms Grid */}
|
||||
{!isLoadingNewest && (
|
||||
<>
|
||||
{newestRooms.length > 0 ? (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{newestRooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="luxury-card p-12 text-center animate-fade-in"
|
||||
>
|
||||
<p className="text-gray-600 text-lg font-light tracking-wide">
|
||||
No new rooms available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{newestRooms.length > 0 && (
|
||||
<div className="mt-10 text-center md:hidden animate-slide-up">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="btn-luxury-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<span className="relative z-10">View All Rooms</span>
|
||||
<ArrowRight className="w-5 h-5 relative z-10" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
|
||||
const BookingManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -206,10 +207,10 @@ const BookingManagementPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
{new Date(booking.check_in_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
{parseDateLocal(booking.check_in_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
→ {new Date(booking.check_out_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
→ {parseDateLocal(booking.check_out_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
@@ -355,11 +356,11 @@ const BookingManagementPage: React.FC = () => {
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-in Date</label>
|
||||
<p className="text-base font-semibold text-slate-900">{new Date(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
|
||||
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-out Date</label>
|
||||
<p className="text-base font-semibold text-slate-900">{new Date(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
|
||||
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { bookingService, Booking } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
|
||||
interface GuestInfo {
|
||||
name: string;
|
||||
@@ -186,11 +187,11 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-in:</span>
|
||||
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
<span>{booking.check_in_date ? parseDateLocal(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-out:</span>
|
||||
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
<span>{booking.check_out_date ? parseDateLocal(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Number of Guests:</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import CurrencyIcon from '../../components/common/CurrencyIcon';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
|
||||
interface ServiceItem {
|
||||
service_name: string;
|
||||
@@ -197,17 +198,17 @@ const CheckOutPage: React.FC = () => {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-in:</span>
|
||||
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
<span>{booking.check_in_date ? parseDateLocal(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-out:</span>
|
||||
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
<span>{booking.check_out_date ? parseDateLocal(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Nights:</span>
|
||||
<span>
|
||||
{booking.check_in_date && booking.check_out_date
|
||||
? Math.ceil((new Date(booking.check_out_date).getTime() - new Date(booking.check_in_date).getTime()) / (1000 * 60 * 60 * 24))
|
||||
? Math.ceil((parseDateLocal(booking.check_out_date).getTime() - parseDateLocal(booking.check_in_date).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 0} night(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import Loading from '../../components/common/Loading';
|
||||
import PaymentStatusBadge from
|
||||
'../../components/common/PaymentStatusBadge';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
|
||||
const BookingDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -78,7 +79,12 @@ const BookingDetailPage: React.FC = () => {
|
||||
response.success &&
|
||||
response.data?.booking
|
||||
) {
|
||||
setBooking(response.data.booking);
|
||||
const bookingData = response.data.booking;
|
||||
// Debug: Log to see what we're receiving
|
||||
console.log('Booking data:', bookingData);
|
||||
console.log('Service usages:', (bookingData as any).service_usages);
|
||||
console.log('Total price:', bookingData.total_price);
|
||||
setBooking(bookingData);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unable to load booking information'
|
||||
@@ -162,7 +168,9 @@ const BookingDetailPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
// Use parseDateLocal to handle date strings correctly
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -172,6 +180,64 @@ const BookingDetailPage: React.FC = () => {
|
||||
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
|
||||
// Calculate number of nights
|
||||
const calculateNights = () => {
|
||||
if (!booking) return 1;
|
||||
const checkIn = parseDateLocal(booking.check_in_date);
|
||||
const checkOut = parseDateLocal(booking.check_out_date);
|
||||
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return nights > 0 ? nights : 1;
|
||||
};
|
||||
|
||||
// Calculate services total
|
||||
const calculateServicesTotal = () => {
|
||||
if (!booking) return 0;
|
||||
// Check both service_usages and services (for backwards compatibility)
|
||||
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
|
||||
if (Array.isArray(serviceUsages) && serviceUsages.length > 0) {
|
||||
return serviceUsages.reduce((sum: number, su: any) => {
|
||||
return sum + (su.total_price || 0);
|
||||
}, 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Get service usages for display
|
||||
const getServiceUsages = () => {
|
||||
if (!booking) return [];
|
||||
// Check both service_usages and services (for backwards compatibility)
|
||||
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
|
||||
return Array.isArray(serviceUsages) ? serviceUsages : [];
|
||||
};
|
||||
|
||||
// Calculate the actual room price per night from the booking
|
||||
// This shows the price that was actually paid, not the current room price
|
||||
const calculateRoomPricePerNight = () => {
|
||||
if (!booking) return 0;
|
||||
|
||||
const nights = calculateNights();
|
||||
const servicesTotal = calculateServicesTotal();
|
||||
|
||||
// Debug logging
|
||||
console.log('Calculating room price:', {
|
||||
total_price: booking.total_price,
|
||||
servicesTotal,
|
||||
nights,
|
||||
roomTotal: booking.total_price - servicesTotal
|
||||
});
|
||||
|
||||
// Calculate room total by subtracting services from total_price
|
||||
const roomTotal = booking.total_price - servicesTotal;
|
||||
|
||||
// Return room price per night (the actual price paid for the room)
|
||||
return nights > 0 ? roomTotal / nights : roomTotal;
|
||||
};
|
||||
|
||||
// Calculate room total (price per night × nights)
|
||||
const calculateRoomTotal = () => {
|
||||
return calculateRoomPricePerNight() * calculateNights();
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
@@ -369,7 +435,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
Room Price
|
||||
</p>
|
||||
<p className="font-medium text-indigo-600">
|
||||
{formatPrice(room?.price || roomType.base_price)}/night
|
||||
{formatPrice(calculateRoomPricePerNight())}/night
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -461,21 +527,67 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Price */}
|
||||
{/* Price Breakdown */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between
|
||||
items-center"
|
||||
>
|
||||
<span className="text-lg font-semibold
|
||||
text-gray-900"
|
||||
>
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold
|
||||
text-indigo-600"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Price Breakdown
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Room Price */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Room ({calculateNights()} night{calculateNights() !== 1 ? 's' : ''})
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatPrice(calculateRoomPricePerNight())} per night
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-base font-semibold text-gray-900">
|
||||
{formatPrice(calculateRoomTotal())}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
{(() => {
|
||||
const services = getServiceUsages();
|
||||
console.log('Services to display:', services);
|
||||
if (services.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{services.map((serviceUsage: any, index: number) => (
|
||||
<div key={serviceUsage.id || index} className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{serviceUsage.service_name || serviceUsage.name || 'Service'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatPrice(serviceUsage.unit_price || serviceUsage.price || 0)} × {serviceUsage.quantity || 1}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-base font-semibold text-gray-900">
|
||||
{formatPrice(serviceUsage.total_price || (serviceUsage.unit_price || serviceUsage.price || 0) * (serviceUsage.quantity || 1))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Total */}
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-indigo-600">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
@@ -21,15 +22,18 @@ import {
|
||||
Sparkles,
|
||||
Star,
|
||||
MapPin,
|
||||
Plus,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getRoomById, type Room } from
|
||||
import { getRoomById, getRoomBookedDates, type Room } from
|
||||
'../../services/api/roomService';
|
||||
import {
|
||||
createBooking,
|
||||
checkRoomAvailability,
|
||||
type BookingData,
|
||||
} from '../../services/api/bookingService';
|
||||
import { serviceService, Service } from '../../services/api';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
bookingValidationSchema,
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
} from '../../validators/bookingValidator';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { formatDateLocal } from '../../utils/format';
|
||||
|
||||
const BookingPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -48,6 +53,9 @@ const BookingPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [selectedServices, setSelectedServices] = useState<Array<{ service: Service; quantity: number }>>([]);
|
||||
const [bookedDates, setBookedDates] = useState<Date[]>([]);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
@@ -61,13 +69,82 @@ const BookingPage: React.FC = () => {
|
||||
}
|
||||
}, [isAuthenticated, navigate, id]);
|
||||
|
||||
// Fetch room details
|
||||
// Fetch room details and services
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchRoomDetails(Number(id));
|
||||
fetchServices();
|
||||
fetchBookedDates(Number(id));
|
||||
}
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
const fetchBookedDates = async (roomId: number) => {
|
||||
try {
|
||||
const response = await getRoomBookedDates(roomId);
|
||||
// Check for both 'success' boolean and 'status: "success"' string
|
||||
const isSuccess = response.success === true || (response as any).status === 'success';
|
||||
|
||||
if (isSuccess && response.data?.booked_dates) {
|
||||
// Convert date strings to Date objects (normalized to midnight)
|
||||
const dates = response.data.booked_dates.map((dateStr: string) => {
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
// Normalize to midnight to avoid timezone issues
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setMinutes(0, 0, 0);
|
||||
return date;
|
||||
});
|
||||
setBookedDates(dates);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching booked dates:', err);
|
||||
// Don't show error, just log it - booked dates are not critical
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if a date is booked
|
||||
const isDateBooked = (date: Date): boolean => {
|
||||
if (!date || bookedDates.length === 0) return false;
|
||||
const normalizedDate = new Date(date);
|
||||
normalizedDate.setHours(0, 0, 0, 0);
|
||||
return bookedDates.some(bookedDate => {
|
||||
const normalizedBooked = new Date(bookedDate);
|
||||
normalizedBooked.setHours(0, 0, 0, 0);
|
||||
return normalizedDate.getTime() === normalizedBooked.getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to check if a date range includes any booked dates
|
||||
const doesRangeIncludeBookedDates = (startDate: Date, endDate: Date): boolean => {
|
||||
if (!startDate || !endDate || bookedDates.length === 0) return false;
|
||||
const start = new Date(startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
let currentDate = new Date(start);
|
||||
while (currentDate < end) {
|
||||
if (isDateBooked(currentDate)) {
|
||||
return true;
|
||||
}
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 100,
|
||||
});
|
||||
setServices(response.data.services || []);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching services:', err);
|
||||
// Don't show error for services, just log it
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRoomDetails = async (roomId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -136,7 +213,14 @@ const BookingPage: React.FC = () => {
|
||||
(room?.price && room.price > 0)
|
||||
? room.price
|
||||
: (room?.room_type?.base_price || 0);
|
||||
const totalPrice = numberOfNights * roomPrice;
|
||||
const roomTotal = numberOfNights * roomPrice;
|
||||
|
||||
// Calculate services total
|
||||
const servicesTotal = selectedServices.reduce((sum, item) => {
|
||||
return sum + (item.service.price * item.quantity);
|
||||
}, 0);
|
||||
|
||||
const totalPrice = roomTotal + servicesTotal;
|
||||
|
||||
// Format price using currency context
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
@@ -148,12 +232,35 @@ const BookingPage: React.FC = () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
const checkInDateStr = data.checkInDate
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const checkOutDateStr = data.checkOutDate
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
// Format dates in local timezone to avoid timezone conversion issues
|
||||
const checkInDateStr = formatDateLocal(data.checkInDate);
|
||||
const checkOutDateStr = formatDateLocal(data.checkOutDate);
|
||||
|
||||
// Validate that selected dates are not booked
|
||||
const checkIn = new Date(data.checkInDate);
|
||||
checkIn.setHours(0, 0, 0, 0);
|
||||
const checkOut = new Date(data.checkOutDate);
|
||||
checkOut.setHours(0, 0, 0, 0);
|
||||
|
||||
// Check if check-in date is booked
|
||||
if (isDateBooked(checkIn)) {
|
||||
toast.error('Check-in date is already booked. Please select another date.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if check-out date is booked (check-out date itself is not included in booking, but we should still validate)
|
||||
// Actually, check-out date is not part of the booking, so we don't need to check it
|
||||
// But we should check if any date in the range is booked
|
||||
const selectedDates: Date[] = [];
|
||||
let currentDate = new Date(checkIn);
|
||||
while (currentDate < checkOut) {
|
||||
if (isDateBooked(currentDate)) {
|
||||
toast.error('The selected date range includes booked dates. Please select different dates.');
|
||||
return;
|
||||
}
|
||||
selectedDates.push(new Date(currentDate));
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
// Step 1: Check room availability
|
||||
const availability = await checkRoomAvailability(
|
||||
@@ -184,6 +291,10 @@ const BookingPage: React.FC = () => {
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
},
|
||||
services: selectedServices.map(item => ({
|
||||
service_id: item.service.id,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
};
|
||||
|
||||
// Step 3: Create booking
|
||||
@@ -463,15 +574,70 @@ const BookingPage: React.FC = () => {
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
selected={field.value}
|
||||
onChange={(date) =>
|
||||
field.onChange(date)
|
||||
}
|
||||
minDate={new Date()}
|
||||
selectsStart
|
||||
startDate={checkInDate}
|
||||
endDate={checkOutDate}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
placeholderText="Select check-in date"
|
||||
excludeDates={bookedDates}
|
||||
highlightDates={bookedDates.length > 0 ? bookedDates.map(date => {
|
||||
// Ensure date is properly formatted for react-datepicker
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
return normalized;
|
||||
}) : []}
|
||||
dayClassName={(date) => {
|
||||
// Add custom class for booked dates to ensure they're highlighted in red
|
||||
if (!date) return '';
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
if (isDateBooked(normalized)) {
|
||||
return 'react-datepicker__day--booked';
|
||||
}
|
||||
return '';
|
||||
}}
|
||||
filterDate={(date) => {
|
||||
// Prevent selection of booked dates
|
||||
if (isDateBooked(date)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If check-out is already selected, prevent selecting check-in that would create invalid range
|
||||
if (checkOutDate) {
|
||||
const testCheckIn = date;
|
||||
const testCheckOut = checkOutDate;
|
||||
if (testCheckIn < testCheckOut && doesRangeIncludeBookedDates(testCheckIn, testCheckOut)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
onChange={(date) => {
|
||||
// Prevent setting booked dates
|
||||
if (date && isDateBooked(date)) {
|
||||
toast.error('This date is already booked. Please select another date.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If check-out date is already selected, validate the entire range
|
||||
if (date && checkOutDate) {
|
||||
const newCheckIn = date;
|
||||
const newCheckOut = checkOutDate;
|
||||
|
||||
if (newCheckIn < newCheckOut) {
|
||||
if (doesRangeIncludeBookedDates(newCheckIn, newCheckOut)) {
|
||||
toast.error('The selected date range includes booked dates. Please select different dates.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
field.onChange(date);
|
||||
}}
|
||||
className="w-full px-4 py-3
|
||||
bg-[#0a0a0a] border border-[#d4af37]/20
|
||||
rounded-lg text-white placeholder-gray-500
|
||||
@@ -509,9 +675,6 @@ const BookingPage: React.FC = () => {
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
selected={field.value}
|
||||
onChange={(date) =>
|
||||
field.onChange(date)
|
||||
}
|
||||
minDate={
|
||||
checkInDate || new Date()
|
||||
}
|
||||
@@ -520,6 +683,64 @@ const BookingPage: React.FC = () => {
|
||||
endDate={checkOutDate}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
placeholderText="Select check-out date"
|
||||
excludeDates={bookedDates}
|
||||
highlightDates={bookedDates.length > 0 ? bookedDates.map(date => {
|
||||
// Ensure date is properly formatted for react-datepicker
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
return normalized;
|
||||
}) : []}
|
||||
dayClassName={(date) => {
|
||||
// Add custom class for booked dates to ensure they're highlighted in red
|
||||
if (!date) return '';
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
if (isDateBooked(normalized)) {
|
||||
return 'react-datepicker__day--booked';
|
||||
}
|
||||
return '';
|
||||
}}
|
||||
filterDate={(date) => {
|
||||
// Prevent selection of booked dates
|
||||
if (isDateBooked(date)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If check-in is already selected, prevent selecting check-out that would create invalid range
|
||||
if (checkInDate) {
|
||||
const testCheckIn = checkInDate;
|
||||
const testCheckOut = date;
|
||||
if (testCheckIn < testCheckOut && doesRangeIncludeBookedDates(testCheckIn, testCheckOut)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
onChange={(date) => {
|
||||
// Prevent setting booked dates
|
||||
if (date && isDateBooked(date)) {
|
||||
toast.error('This date is already booked. Please select another date.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If check-in date is already selected, validate the entire range
|
||||
if (date && checkInDate) {
|
||||
const newCheckIn = checkInDate;
|
||||
const newCheckOut = date;
|
||||
|
||||
if (newCheckIn < newCheckOut) {
|
||||
if (doesRangeIncludeBookedDates(newCheckIn, newCheckOut)) {
|
||||
toast.error('The selected date range includes booked dates. Please select different dates.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
field.onChange(date);
|
||||
}}
|
||||
className="w-full px-4 py-3
|
||||
bg-[#0a0a0a] border border-[#d4af37]/20
|
||||
rounded-lg text-white placeholder-gray-500
|
||||
@@ -606,6 +827,137 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Selection */}
|
||||
{services.length > 0 && (
|
||||
<div className="border-t border-[#d4af37]/20 pt-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<Sparkles className="w-5 h-5 text-[#d4af37]" />
|
||||
</div>
|
||||
<h2
|
||||
className="text-2xl font-serif font-semibold
|
||||
text-white tracking-wide"
|
||||
>
|
||||
Additional Services
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-6 font-light tracking-wide">
|
||||
Enhance your stay with our premium services (optional)
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{services.map((service) => {
|
||||
const selectedItem = selectedServices.find(
|
||||
item => item.service.id === service.id
|
||||
);
|
||||
const quantity = selectedItem?.quantity || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service.id}
|
||||
className="bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
|
||||
border border-[#d4af37]/20 rounded-lg p-5
|
||||
hover:border-[#d4af37]/40 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-semibold mb-1">
|
||||
{service.name}
|
||||
</h3>
|
||||
{service.description && (
|
||||
<p className="text-gray-400 text-sm mb-2">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[#d4af37] font-bold">
|
||||
{formatPrice(service.price)} / {service.unit || 'unit'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (quantity > 0) {
|
||||
if (quantity === 1) {
|
||||
setSelectedServices(prev =>
|
||||
prev.filter(item => item.service.id !== service.id)
|
||||
);
|
||||
} else {
|
||||
setSelectedServices(prev =>
|
||||
prev.map(item =>
|
||||
item.service.id === service.id
|
||||
? { ...item, quantity: item.quantity - 1 }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={quantity === 0}
|
||||
className="w-8 h-8 flex items-center justify-center
|
||||
bg-[#1a1a1a] border border-[#d4af37]/20
|
||||
rounded text-[#d4af37] hover:bg-[#d4af37]/10
|
||||
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-white font-semibold w-8 text-center">
|
||||
{quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (quantity === 0) {
|
||||
setSelectedServices(prev => [
|
||||
...prev,
|
||||
{ service, quantity: 1 }
|
||||
]);
|
||||
} else {
|
||||
setSelectedServices(prev =>
|
||||
prev.map(item =>
|
||||
item.service.id === service.id
|
||||
? { ...item, quantity: item.quantity + 1 }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-8 h-8 flex items-center justify-center
|
||||
bg-[#1a1a1a] border border-[#d4af37]/20
|
||||
rounded text-[#d4af37] hover:bg-[#d4af37]/10
|
||||
transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{quantity > 0 && (
|
||||
<p className="text-[#d4af37] font-bold">
|
||||
{formatPrice(service.price * quantity)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedServices.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-[#d4af37]/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 font-light">Services Total:</span>
|
||||
<span className="text-xl font-bold text-[#d4af37]">
|
||||
{formatPrice(servicesTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="border-t border-[#d4af37]/20 pt-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -869,6 +1221,45 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{numberOfNights > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400 font-light tracking-wide text-sm">
|
||||
Room Total
|
||||
</span>
|
||||
<span className="font-light text-white">
|
||||
{formatPrice(roomTotal)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedServices.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-[#d4af37]/20 pt-3 mt-3">
|
||||
<p className="text-gray-400 font-light tracking-wide text-sm mb-2">
|
||||
Services:
|
||||
</p>
|
||||
{selectedServices.map((item) => (
|
||||
<div key={item.service.id} className="flex justify-between items-center mb-1">
|
||||
<span className="text-gray-500 font-light tracking-wide text-xs">
|
||||
{item.service.name} (×{item.quantity})
|
||||
</span>
|
||||
<span className="font-light text-white text-xs">
|
||||
{formatPrice(item.service.price * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center mt-2 pt-2 border-t border-[#d4af37]/10">
|
||||
<span className="text-gray-400 font-light tracking-wide text-sm">
|
||||
Services Total
|
||||
</span>
|
||||
<span className="font-light text-white">
|
||||
{formatPrice(servicesTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="border-t border-[#d4af37]/20 pt-4 flex
|
||||
justify-between items-center"
|
||||
|
||||
@@ -32,6 +32,7 @@ import { confirmBankTransfer } from
|
||||
'../../services/api/paymentService';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
|
||||
const BookingSuccessPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -115,7 +116,9 @@ const BookingSuccessPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
// Use parseDateLocal to handle date strings correctly
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../services/api/paymentService';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
import StripePaymentWrapper from '../../components/payments/StripePaymentWrapper';
|
||||
|
||||
const FullPaymentPage: React.FC = () => {
|
||||
@@ -407,14 +408,14 @@ const FullPaymentPage: React.FC = () => {
|
||||
<div>
|
||||
<span className="text-gray-400 font-light">Check-in</span>
|
||||
<p className="text-white font-medium">
|
||||
{new Date(booking.check_in_date).toLocaleDateString('en-US')}
|
||||
{parseDateLocal(booking.check_in_date).toLocaleDateString('en-US')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-light">Check-out</span>
|
||||
<p className="text-white font-medium">
|
||||
{new Date(booking.check_out_date).toLocaleDateString('en-US')}
|
||||
{parseDateLocal(booking.check_out_date).toLocaleDateString('en-US')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import useAuthStore from '../../store/useAuthStore';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import EmptyState from '../../components/common/EmptyState';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
|
||||
const MyBookingsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -168,7 +169,9 @@ const MyBookingsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
// Use parseDateLocal to handle date strings correctly
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { getRooms } from '../../services/api/roomService';
|
||||
import type { Room } from '../../services/api/roomService';
|
||||
@@ -6,13 +6,15 @@ import RoomFilter from '../../components/rooms/RoomFilter';
|
||||
import RoomCard from '../../components/rooms/RoomCard';
|
||||
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
|
||||
import Pagination from '../../components/rooms/Pagination';
|
||||
import { ArrowLeft, Sparkles, Hotel } from 'lucide-react';
|
||||
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const RoomListPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
page: 1,
|
||||
@@ -20,6 +22,15 @@ const RoomListPage: React.FC = () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Scroll to filter when opened on mobile
|
||||
useEffect(() => {
|
||||
if (isFilterOpen && filterRef.current && window.innerWidth < 1280) {
|
||||
setTimeout(() => {
|
||||
filterRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
}, [isFilterOpen]);
|
||||
|
||||
// Fetch rooms based on URL params
|
||||
useEffect(() => {
|
||||
const fetchRooms = async () => {
|
||||
@@ -66,51 +77,97 @@ const RoomListPage: React.FC = () => {
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2
|
||||
text-[#d4af37]/80 hover:text-[#d4af37]
|
||||
mb-10 transition-all duration-300
|
||||
group font-light tracking-wide"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="inline-flex items-center justify-center gap-3 mb-6">
|
||||
<div className="p-3 bg-[#d4af37]/10 rounded-xl border border-[#d4af37]/30">
|
||||
<Hotel className="w-8 h-8 text-[#d4af37]" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl font-serif font-semibold
|
||||
text-white mb-4 tracking-tight leading-tight
|
||||
bg-gradient-to-r from-white via-[#d4af37] to-white
|
||||
bg-clip-text text-transparent"
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Full-width hero section */}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-6 sm:py-7 md:py-8 lg:py-10">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2
|
||||
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
active:scale-95
|
||||
mb-4 sm:mb-5 md:mb-6 transition-all duration-300
|
||||
group font-medium tracking-wide text-sm
|
||||
px-4 py-2 rounded-sm
|
||||
shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40
|
||||
touch-manipulation"
|
||||
>
|
||||
Our Rooms & Suites
|
||||
</h1>
|
||||
<p className="text-gray-400 font-light tracking-wide text-lg max-w-2xl mx-auto">
|
||||
Discover our collection of luxurious accommodations,
|
||||
each designed to provide an exceptional stay
|
||||
</p>
|
||||
</div>
|
||||
<ArrowLeft className="w-4 h-4 sm:w-4 sm:h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-10">
|
||||
<aside className="lg:col-span-1">
|
||||
<div className="sticky top-6">
|
||||
<RoomFilter />
|
||||
{/* Page Header */}
|
||||
<div className="text-center max-w-3xl mx-auto px-2">
|
||||
<div className="inline-flex items-center justify-center gap-2 mb-3 sm:mb-4">
|
||||
<div className="p-2 sm:p-2.5 bg-[#d4af37]/10 rounded-lg border border-[#d4af37]/30 backdrop-blur-sm">
|
||||
<Hotel className="w-5 h-5 sm:w-5 sm:h-5 text-[#d4af37]" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold
|
||||
text-white mb-2 sm:mb-3 tracking-tight leading-tight px-2
|
||||
bg-gradient-to-r from-white via-[#d4af37] to-white
|
||||
bg-clip-text text-transparent"
|
||||
>
|
||||
Our Rooms & Suites
|
||||
</h1>
|
||||
<p className="text-gray-400 font-light tracking-wide text-xs sm:text-sm md:text-base max-w-xl mx-auto px-2 sm:px-4 leading-relaxed">
|
||||
Discover our collection of luxurious accommodations,
|
||||
each designed to provide an exceptional stay
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-width content area */}
|
||||
<div className="w-full py-4 sm:py-5 md:py-6 lg:py-8">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-3 sm:gap-4 md:gap-5 lg:gap-6 xl:gap-7">
|
||||
{/* Mobile Filter Toggle Button */}
|
||||
<div className="xl:hidden order-1 mb-4">
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className="w-full bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
border border-[#d4af37]/30 rounded-xl p-4
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/10
|
||||
flex items-center justify-between gap-3
|
||||
hover:border-[#d4af37]/50 hover:shadow-xl hover:shadow-[#d4af37]/20
|
||||
transition-all duration-300 touch-manipulation"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg border border-[#d4af37]/30">
|
||||
<Filter className="w-5 h-5 text-[#d4af37]" />
|
||||
</div>
|
||||
<span className="text-white font-medium tracking-wide text-base">
|
||||
Filters
|
||||
</span>
|
||||
</div>
|
||||
{isFilterOpen ? (
|
||||
<ChevronUp className="w-5 h-5 text-[#d4af37]" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-[#d4af37]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Sidebar - Collapsible on mobile, sidebar on desktop */}
|
||||
<aside
|
||||
ref={filterRef}
|
||||
className={`xl:col-span-3 order-2 xl:order-1 mb-4 sm:mb-5 md:mb-6 xl:mb-0 transition-all duration-300 ${
|
||||
isFilterOpen ? 'block' : 'hidden xl:block'
|
||||
}`}
|
||||
>
|
||||
<div className="xl:sticky xl:top-4 xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto">
|
||||
<RoomFilter />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-3">
|
||||
{/* Main Content - Full width on mobile, 9 columns on desktop */}
|
||||
<main className="xl:col-span-9 order-3 xl:order-2">
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
xl:grid-cols-2 gap-8"
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
|
||||
gap-3 sm:gap-4 md:gap-5 lg:gap-6"
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
@@ -120,14 +177,14 @@ const RoomListPage: React.FC = () => {
|
||||
|
||||
{error && !loading && (
|
||||
<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-6 sm:p-8 md:p-12 text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-red-500/10"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-20 h-20
|
||||
bg-red-500/20 rounded-full mb-6 border border-red-500/30"
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 sm:w-16 sm:h-16 md:w-20 md:h-20
|
||||
bg-red-500/20 rounded-full mb-4 sm:mb-5 md:mb-6 border border-red-500/30"
|
||||
>
|
||||
<svg
|
||||
className="w-10 h-10 text-red-400"
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 md:w-10 md:h-10 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -141,13 +198,14 @@ const RoomListPage: React.FC = () => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-red-300 font-light text-lg mb-6 tracking-wide">{error}</p>
|
||||
<p className="text-red-300 font-light text-sm sm:text-base md:text-lg mb-4 sm:mb-5 md:mb-6 tracking-wide px-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] rounded-sm font-medium tracking-wide
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 shadow-lg shadow-[#d4af37]/30"
|
||||
className="px-5 sm:px-6 md:px-8 py-2.5 sm:py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
|
||||
transition-all duration-300 shadow-lg shadow-[#d4af37]/30
|
||||
touch-manipulation min-h-[44px]"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
@@ -156,28 +214,29 @@ const RoomListPage: React.FC = () => {
|
||||
|
||||
{!loading && !error && rooms.length === 0 && (
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
border border-[#d4af37]/20 rounded-xl p-16 text-center
|
||||
border border-[#d4af37]/20 rounded-xl p-8 sm:p-10 md:p-12 lg:p-16 text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-24 h-24
|
||||
bg-[#d4af37]/10 rounded-2xl mb-8 border border-[#d4af37]/30"
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24
|
||||
bg-[#d4af37]/10 rounded-2xl mb-4 sm:mb-5 md:mb-6 lg:mb-8 border border-[#d4af37]/30"
|
||||
>
|
||||
<Hotel className="w-12 h-12 text-[#d4af37]" />
|
||||
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 text-[#d4af37]" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-serif font-semibold
|
||||
text-white mb-4 tracking-wide"
|
||||
<h3 className="text-lg sm:text-xl md:text-2xl font-serif font-semibold
|
||||
text-white mb-3 sm:mb-4 tracking-wide px-2"
|
||||
>
|
||||
No matching rooms found
|
||||
</h3>
|
||||
<p className="text-gray-400 font-light tracking-wide mb-8 text-lg">
|
||||
<p className="text-gray-400 font-light tracking-wide mb-5 sm:mb-6 md:mb-8 text-sm sm:text-base md:text-lg px-2">
|
||||
Please try adjusting the filters or search differently
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/rooms'}
|
||||
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] rounded-sm font-medium tracking-wide
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 shadow-lg shadow-[#d4af37]/30"
|
||||
className="px-5 sm:px-6 md:px-8 py-2.5 sm:py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
|
||||
transition-all duration-300 shadow-lg shadow-[#d4af37]/30
|
||||
touch-manipulation min-h-[44px]"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
@@ -187,15 +246,16 @@ const RoomListPage: React.FC = () => {
|
||||
{!loading && !error && rooms.length > 0 && (
|
||||
<>
|
||||
{/* Results Count */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<p className="text-gray-400 font-light tracking-wide">
|
||||
<div className="mb-3 sm:mb-4 md:mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-3">
|
||||
<p className="text-gray-400 font-light tracking-wide text-xs sm:text-sm md:text-base">
|
||||
Showing <span className="text-[#d4af37] font-medium">{rooms.length}</span> of{' '}
|
||||
<span className="text-[#d4af37] font-medium">{pagination.total}</span> rooms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
xl:grid-cols-2 gap-8 mb-10"
|
||||
{/* Responsive grid: 1 col mobile, 2 cols tablet, 3 cols desktop */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
|
||||
gap-3 sm:gap-4 md:gap-5 lg:gap-6 mb-4 sm:mb-5 md:mb-6"
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
@@ -203,17 +263,18 @@ const RoomListPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="mt-10 pt-8 border-t border-[#d4af37]/20">
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
<div className="mt-4 sm:mt-5 md:mt-6 pt-3 sm:pt-4 border-t border-[#d4af37]/20">
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { searchAvailableRooms } from
|
||||
'../../services/api/roomService';
|
||||
import type { Room } from '../../services/api/roomService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
|
||||
const SearchResultsPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -109,7 +110,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface BookingData {
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
services?: Array<{
|
||||
service_id: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
|
||||
29
Frontend/src/services/api/contactService.ts
Normal file
29
Frontend/src/services/api/contactService.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import apiClient from './apiClient';
|
||||
|
||||
/**
|
||||
* Contact API Service
|
||||
*/
|
||||
|
||||
export interface ContactFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface ContactResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit contact form
|
||||
*/
|
||||
export const submitContactForm = async (
|
||||
formData: ContactFormData
|
||||
): Promise<ContactResponse> => {
|
||||
const response = await apiClient.post('/contact/submit', formData);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -100,6 +100,16 @@ export const getRoomById = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get booked dates for a specific room
|
||||
*/
|
||||
export const getRoomBookedDates = async (
|
||||
roomId: number
|
||||
): Promise<{ success: boolean; data: { room_id: number; booked_dates: string[] } }> => {
|
||||
const response = await apiClient.get(`/rooms/${roomId}/booked-dates`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get room by room number
|
||||
*/
|
||||
|
||||
@@ -68,14 +68,31 @@
|
||||
}
|
||||
|
||||
.react-datepicker__day--disabled {
|
||||
color: #525252;
|
||||
color: #ef4444 !important;
|
||||
background-color: rgba(239, 68, 68, 0.2) !important;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
opacity: 1 !important;
|
||||
font-weight: 600 !important;
|
||||
border: 2px solid rgba(239, 68, 68, 0.5) !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-datepicker__day--disabled:hover {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
background-color: rgba(239, 68, 68, 0.3) !important;
|
||||
border: 2px solid rgba(239, 68, 68, 0.7) !important;
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day--disabled::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
width: 85%;
|
||||
height: 2px;
|
||||
background-color: rgba(239, 68, 68, 0.8);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.react-datepicker__navigation {
|
||||
@@ -110,3 +127,71 @@
|
||||
background-color: rgba(212, 175, 55, 0.25);
|
||||
border-color: rgba(212, 175, 55, 0.6);
|
||||
}
|
||||
|
||||
/* Booked dates styling - red color to indicate unavailable dates */
|
||||
/* Using maximum specificity to ensure booked dates are always visible and prominent */
|
||||
.react-datepicker__day.react-datepicker__day--booked,
|
||||
.react-datepicker__day.react-datepicker__day--booked.react-datepicker__day--disabled,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--booked,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--booked.react-datepicker__day--disabled,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--disabled,
|
||||
.react-datepicker__day.react-datepicker__day--highlighted.react-datepicker__day--disabled {
|
||||
color: #ffffff !important;
|
||||
background-color: rgba(239, 68, 68, 0.5) !important;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.6), rgba(220, 38, 38, 0.5)) !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 700 !important;
|
||||
border: 2px solid rgba(239, 68, 68, 0.9) !important;
|
||||
position: relative;
|
||||
text-decoration: none !important;
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.4) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day.react-datepicker__day--booked:hover,
|
||||
.react-datepicker__day.react-datepicker__day--booked.react-datepicker__day--disabled:hover,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--booked:hover,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--booked.react-datepicker__day--disabled:hover,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--disabled:hover,
|
||||
.react-datepicker__day.react-datepicker__day--highlighted.react-datepicker__day--disabled:hover {
|
||||
background-color: rgba(239, 68, 68, 0.6) !important;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.7), rgba(220, 38, 38, 0.6)) !important;
|
||||
border: 2px solid rgba(239, 68, 68, 1) !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 3px 6px rgba(239, 68, 68, 0.5) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day.react-datepicker__day--booked::after,
|
||||
.react-datepicker__day.react-datepicker__day--booked.react-datepicker__day--disabled::after,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--booked::after,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--booked.react-datepicker__day--disabled::after,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--disabled::after,
|
||||
.react-datepicker__day.react-datepicker__day--highlighted.react-datepicker__day--disabled::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
width: 90%;
|
||||
height: 3px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Additional styling to make booked dates stand out even more */
|
||||
.react-datepicker__day.react-datepicker__day--booked::before,
|
||||
.react-datepicker__day.react-datepicker__day--booked.react-datepicker__day--disabled::before,
|
||||
.react-datepicker__day--highlighted.react-datepicker__day--disabled::before,
|
||||
.react-datepicker__day.react-datepicker__day--highlighted.react-datepicker__day--disabled::before {
|
||||
content: '✕';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: bold;
|
||||
z-index: 11;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -115,13 +115,50 @@ export const formatCurrency = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date
|
||||
* Format date as YYYY-MM-DD in local timezone (without timezone conversion)
|
||||
* This is useful for sending dates to the backend where we want the date as the user selected it
|
||||
*/
|
||||
export const formatDateLocal = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse date string and return Date object in local timezone
|
||||
* Handles both "YYYY-MM-DD" and ISO format strings
|
||||
*/
|
||||
export const parseDateLocal = (dateString: string): Date => {
|
||||
// If it's already in YYYY-MM-DD format, parse it as local date
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
// Otherwise, parse as ISO and adjust to local timezone
|
||||
const date = new Date(dateString);
|
||||
// If the date string doesn't include timezone info, treat it as local
|
||||
if (!dateString.includes('T') && !dateString.includes('Z') && !dateString.includes('+')) {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export const formatDate = (
|
||||
date: string | Date,
|
||||
format: 'short' | 'medium' | 'long' | 'full' = 'medium'
|
||||
): string => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
// Parse date string in local timezone if it's a date-only string
|
||||
let dateObj: Date;
|
||||
if (typeof date === 'string') {
|
||||
dateObj = parseDateLocal(date);
|
||||
} else {
|
||||
dateObj = date;
|
||||
}
|
||||
|
||||
if (isNaN(dateObj.getTime())) return 'Invalid Date';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user