This commit is contained in:
Iliyan Angelov
2025-11-17 23:50:14 +02:00
parent 0c59fe1173
commit a1bd576540
43 changed files with 2598 additions and 359 deletions

View File

@@ -194,9 +194,9 @@ app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX)
# Import and include other routes # Import and include other routes
from .routes import ( from .routes import (
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, 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, review_routes, user_routes, audit_routes, admin_privacy_routes,
system_settings_routes system_settings_routes, contact_routes
) )
# Legacy routes (maintain backward compatibility) # 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(banner_routes.router, prefix="/api")
app.include_router(favorite_routes.router, prefix="/api") app.include_router(favorite_routes.router, prefix="/api")
app.include_router(service_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(promotion_routes.router, prefix="/api")
app.include_router(report_routes.router, prefix="/api") app.include_router(report_routes.router, prefix="/api")
app.include_router(review_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(audit_routes.router, prefix="/api")
app.include_router(admin_privacy_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(system_settings_routes.router, prefix="/api")
app.include_router(contact_routes.router, prefix="/api")
# Versioned routes (v1) # Versioned routes (v1)
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) 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(banner_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(favorite_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_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(promotion_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(report_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) 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(audit_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(admin_privacy_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(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") logger.info("All routes registered successfully")

View File

@@ -8,6 +8,7 @@ from .booking import Booking
from .payment import Payment from .payment import Payment
from .service import Service from .service import Service
from .service_usage import ServiceUsage from .service_usage import ServiceUsage
from .service_booking import ServiceBooking, ServiceBookingItem, ServicePayment, ServiceBookingStatus, ServicePaymentStatus, ServicePaymentMethod
from .promotion import Promotion from .promotion import Promotion
from .checkin_checkout import CheckInCheckOut from .checkin_checkout import CheckInCheckOut
from .banner import Banner from .banner import Banner
@@ -30,6 +31,12 @@ __all__ = [
"Payment", "Payment",
"Service", "Service",
"ServiceUsage", "ServiceUsage",
"ServiceBooking",
"ServiceBookingItem",
"ServicePayment",
"ServiceBookingStatus",
"ServicePaymentStatus",
"ServicePaymentMethod",
"Promotion", "Promotion",
"CheckInCheckOut", "CheckInCheckOut",
"Banner", "Banner",

View 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")

View File

@@ -28,4 +28,5 @@ class User(Base):
checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by") checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by")
reviews = relationship("Review", back_populates="user") reviews = relationship("Review", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
service_bookings = relationship("ServiceBooking", back_populates="user")

View File

@@ -14,6 +14,7 @@ from ..models.booking import Booking, BookingStatus
from ..models.room import Room, RoomStatus from ..models.room import Room, RoomStatus
from ..models.room_type import RoomType from ..models.room_type import RoomType
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus 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 ..services.room_service import normalize_images, get_base_url
from fastapi import Request from fastapi import Request
from ..utils.mailer import send_email from ..utils.mailer import send_email
@@ -83,8 +84,8 @@ async def get_all_bookings(
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
"user_id": booking.user_id, "user_id": booking.user_id,
"room_id": booking.room_id, "room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"num_guests": booking.num_guests, "num_guests": booking.num_guests,
"total_price": float(booking.total_price) if booking.total_price else 0.0, "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, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
@@ -148,8 +149,8 @@ async def get_my_bookings(
"id": booking.id, "id": booking.id,
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
"room_id": booking.room_id, "room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"num_guests": booking.num_guests, "num_guests": booking.num_guests,
"total_price": float(booking.total_price) if booking.total_price else 0.0, "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, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
@@ -217,8 +218,18 @@ async def create_booking(
if not room: if not room:
raise HTTPException(status_code=404, detail="Room not found") raise HTTPException(status_code=404, detail="Room not found")
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) # Parse dates as date-only strings (YYYY-MM-DD) - treat as naive datetime
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) 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 # Check for overlapping bookings
overlapping = db.query(Booking).filter( 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 # 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 # 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.commit()
db.refresh(booking) db.refresh(booking)
# Fetch with relations for proper serialization (eager load payments) # Automatically create invoice for the booking
from sqlalchemy.orm import joinedload try:
booking = db.query(Booking).options(joinedload(Booking.payments)).filter(Booking.id == booking.id).first() 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 # Determine payment_method and payment_status from payments
payment_method_from_payments = None payment_method_from_payments = None
@@ -310,8 +381,8 @@ async def create_booking(
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
"user_id": booking.user_id, "user_id": booking.user_id,
"room_id": booking.room_id, "room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"guest_count": booking.num_guests, "guest_count": booking.num_guests,
"total_price": float(booking.total_price) if booking.total_price else 0.0, "total_price": float(booking.total_price) if booking.total_price else 0.0,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, "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 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 # Add room info if available
if booking.room: if booking.room:
booking_dict["room"] = { booking_dict["room"] = {
@@ -414,9 +510,11 @@ async def get_booking_by_id(
try: try:
# Eager load all relationships to avoid N+1 queries # Eager load all relationships to avoid N+1 queries
# Using selectinload for better performance with multiple relationships # Using selectinload for better performance with multiple relationships
from sqlalchemy.orm import selectinload
booking = db.query(Booking)\ booking = db.query(Booking)\
.options( .options(
selectinload(Booking.payments), selectinload(Booking.payments),
selectinload(Booking.service_usages).selectinload(ServiceUsage.service),
joinedload(Booking.user), joinedload(Booking.user),
joinedload(Booking.room).joinedload(Room.room_type) joinedload(Booking.room).joinedload(Room.room_type)
)\ )\
@@ -448,8 +546,8 @@ async def get_booking_by_id(
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
"user_id": booking.user_id, "user_id": booking.user_id,
"room_id": booking.room_id, "room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"guest_count": booking.num_guests, # Frontend expects guest_count "guest_count": booking.num_guests, # Frontend expects guest_count
"total_price": float(booking.total_price) if booking.total_price else 0.0, "total_price": float(booking.total_price) if booking.total_price else 0.0,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, "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 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 { return {
"success": True, "success": True,
"data": {"booking": booking_dict} "data": {"booking": booking_dict}
@@ -657,8 +781,8 @@ async def check_booking_by_number(
"id": booking.id, "id": booking.id,
"booking_number": booking.booking_number, "booking_number": booking.booking_number,
"room_id": booking.room_id, "room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
} }

View 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."
)

View File

@@ -729,6 +729,59 @@ async def delete_room_images(
raise HTTPException(status_code=500, detail=str(e)) 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") @router.get("/{id}/reviews")
async def get_room_reviews_route( async def get_room_reviews_route(
id: int, id: int,

View 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))

View File

@@ -62,7 +62,12 @@ class InvoiceService:
Returns: Returns:
Invoice dictionary 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: if not booking:
raise ValueError("Booking not found") raise ValueError("Booking not found")
@@ -73,10 +78,9 @@ class InvoiceService:
# Generate invoice number # Generate invoice number
invoice_number = generate_invoice_number(db) 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) 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 # Calculate amount paid from completed payments
amount_paid = sum( amount_paid = sum(
@@ -132,15 +136,26 @@ class InvoiceService:
db.add(invoice) db.add(invoice)
# Create invoice items from booking # 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
room_item = InvoiceItem( room_item = InvoiceItem(
invoice_id=invoice.id, invoice_id=invoice.id,
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'}", 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=1, quantity=nights,
unit_price=float(booking.total_price), unit_price=room_price / nights if nights > 0 else room_price,
tax_rate=tax_rate, tax_rate=tax_rate,
discount_amount=0.0, discount_amount=0.0,
line_total=float(booking.total_price), line_total=room_price,
room_id=booking.room_id, room_id=booking.room_id,
) )
db.add(room_item) db.add(room_item)
@@ -151,25 +166,27 @@ class InvoiceService:
invoice_id=invoice.id, invoice_id=invoice.id,
description=f"Service: {service_usage.service.name}", description=f"Service: {service_usage.service.name}",
quantity=float(service_usage.quantity), quantity=float(service_usage.quantity),
unit_price=float(service_usage.service.price), unit_price=float(service_usage.unit_price),
tax_rate=tax_rate, tax_rate=tax_rate,
discount_amount=0.0, 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, service_id=service_usage.service_id,
) )
db.add(service_item) db.add(service_item)
subtotal += float(service_usage.quantity) * float(service_usage.service.price)
# Recalculate totals if services were added # Recalculate subtotal from items (room + services)
if booking.service_usages: subtotal = room_price + services_total
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
balance_due = total_amount - amount_paid
invoice.subtotal = subtotal # Recalculate tax and total amounts
invoice.tax_amount = tax_amount tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
invoice.total_amount = total_amount total_amount = subtotal + tax_amount - discount_amount
invoice.balance_due = balance_due 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.commit()
db.refresh(invoice) db.refresh(invoice)

View File

@@ -47,6 +47,7 @@ const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage')); const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage')); const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const AboutPage = lazy(() => import('./pages/AboutPage')); const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));
const LoginPage = lazy(() => import('./pages/auth/LoginPage')); const LoginPage = lazy(() => import('./pages/auth/LoginPage'));
const RegisterPage = lazy(() => import('./pages/auth/RegisterPage')); const RegisterPage = lazy(() => import('./pages/auth/RegisterPage'));
const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage')); const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage'));
@@ -182,6 +183,10 @@ function App() {
path="about" path="about"
element={<AboutPage />} element={<AboutPage />}
/> />
<Route
path="contact"
element={<ContactPage />}
/>
{/* Protected Routes - Requires login */} {/* Protected Routes - Requires login */}
<Route <Route

View File

@@ -12,6 +12,7 @@ import {
Phone, Phone,
Mail, Mail,
Calendar, Calendar,
MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside'; import { useClickOutside } from '../../hooks/useClickOutside';
@@ -131,6 +132,15 @@ const Header: React.FC<HeaderProps> = ({
<span className="relative z-10">About</span> <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> <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>
<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> </nav>
{/* Desktop Auth Section */} {/* Desktop Auth Section */}
@@ -330,6 +340,17 @@ const Header: React.FC<HeaderProps> = ({
> >
About About
</Link> </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 <div className="border-t border-[#d4af37]/20
pt-3 mt-3" pt-3 mt-3"

View File

@@ -8,6 +8,7 @@ import {
Tv, Tv,
Wind, Wind,
ArrowRight, ArrowRight,
Crown,
} from 'lucide-react'; } from 'lucide-react';
import type { Room } from '../../services/api/roomService'; import type { Room } from '../../services/api/roomService';
import FavoriteButton from './FavoriteButton'; import FavoriteButton from './FavoriteButton';
@@ -15,9 +16,10 @@ import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface RoomCardProps { interface RoomCardProps {
room: Room; room: Room;
compact?: boolean;
} }
const RoomCard: React.FC<RoomCardProps> = ({ room }) => { const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
const roomType = room.room_type; const roomType = room.room_type;
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
@@ -70,13 +72,17 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
return ( return (
<div <div
className="luxury-card overflow-hidden group className={`luxury-card overflow-hidden group h-full flex flex-col
border-t-2 border-transparent hover:border-[#d4af37] border-t-2 transition-all duration-300
hover:shadow-luxury-gold" ${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 */} {/* Image */}
<div className="relative h-52 overflow-hidden <div className={`relative overflow-hidden
bg-gradient-to-br from-gray-200 to-gray-300" 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 <img
src={imageUrl} src={imageUrl}
@@ -102,16 +108,18 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
<FavoriteButton roomId={room.id} size="md" /> <FavoriteButton roomId={room.id} size="md" />
</div> </div>
{/* Featured Badge */} {/* Featured Badge with Crown */}
{room.featured && ( {room.featured && (
<div <div
className="absolute top-3 left-3 className="absolute top-3 left-3 z-20
bg-gradient-to-r from-[#d4af37] to-[#c9a227] bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-3 py-1.5 text-[#0f0f0f] px-3 py-1.5
rounded-sm text-xs font-medium tracking-wide 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> </div>
)} )}
@@ -137,36 +145,45 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className={`flex-1 flex flex-col ${compact ? 'p-3' : 'p-4 sm:p-5'}`}>
{/* Room Type Name */} {/* Room Type Name */}
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-2 tracking-tight"> <h3 className={`font-serif font-semibold text-gray-900 mb-1.5 tracking-tight
{roomType.name} ${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> </h3>
{/* Room Number & Floor */} {/* Room Number & Floor */}
<div <div
className="flex items-center text-sm className={`flex items-center text-gray-600 font-light tracking-wide
text-gray-600 mb-3 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> <span>
Room {room.room_number} - Floor {room.floor} Room {room.room_number} - Floor {room.floor}
</span> </span>
</div> </div>
{/* Description (truncated) - Show room-specific description first */} {/* Description (truncated) - Show room-specific description first */}
{(room.description || roomType.description) && ( {(room.description || roomType.description) && !compact && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2 <p className="text-gray-600 text-xs sm:text-sm mb-3 line-clamp-2
leading-relaxed font-light"> leading-relaxed font-light">
{room.description || roomType.description} {room.description || roomType.description}
</p> </p>
)} )}
{/* Capacity & Rating */} {/* 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"> <div className="flex items-center text-gray-700">
<Users className="w-4 h-4 mr-1.5 text-[#d4af37]" /> <Users className={`${compact ? 'w-3 h-3' : 'w-4 h-4'} mr-1.5 text-[#d4af37]`} />
<span className="text-sm font-light tracking-wide"> <span className={`font-light tracking-wide ${compact ? 'text-xs' : 'text-sm'}`}>
{room.capacity || roomType.capacity} guests {room.capacity || roomType.capacity} guests
</span> </span>
</div> </div>
@@ -174,29 +191,31 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
{room.average_rating != null && ( {room.average_rating != null && (
<div className="flex items-center"> <div className="flex items-center">
<Star <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" 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)} {Number(room.average_rating).toFixed(1)}
</span> </span>
<span className="text-xs text-gray-500 ml-1 font-light"> {!compact && (
({Number(room.total_reviews || 0)}) <span className="text-xs text-gray-500 ml-1 font-light">
</span> ({Number(room.total_reviews || 0)})
</span>
)}
</div> </div>
)} )}
</div> </div>
{/* Amenities */} {/* Amenities */}
{amenities.length > 0 && ( {amenities.length > 0 && !compact && (
<div className="flex items-center gap-2 mb-5"> <div className="flex items-center gap-1.5 sm:gap-2 mb-3 sm:mb-4 flex-wrap">
{amenities.map((amenity, index) => ( {amenities.map((amenity, index) => (
<div <div
key={index} key={index}
className="flex items-center gap-1 className="flex items-center gap-1
text-gray-700 text-xs bg-[#d4af37]/10 text-gray-700 text-xs bg-[#d4af37]/10
border border-[#d4af37]/20 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" font-light tracking-wide"
title={amenity} title={amenity}
> >
@@ -204,31 +223,38 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
{amenityIcons[amenity.toLowerCase()] || {amenityIcons[amenity.toLowerCase()] ||
<span></span>} <span></span>}
</span> </span>
<span className="capitalize">{amenity}</span> <span className="capitalize hidden sm:inline">{amenity}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Price & Action */} {/* Price & Action */}
<div className="flex items-center justify-between pt-4 <div className={`flex flex-col sm:flex-row items-start sm:items-center justify-between mt-auto
border-t border-gray-200"> border-t border-gray-200
${compact ? 'gap-2 pt-2' : 'gap-2 sm:gap-3 pt-3'}`}>
<div> <div>
<p className="text-xs text-gray-500 font-light tracking-wide mb-0.5">From</p> {!compact && (
<p className="text-2xl font-serif font-semibold <p className="text-xs text-gray-500 font-light tracking-wide mb-0.5">From</p>
text-gradient-luxury tracking-tight"> )}
<p className={`font-serif font-semibold text-gradient-luxury tracking-tight
${compact ? 'text-lg' : 'text-lg sm:text-xl'}`}>
{formattedPrice} {formattedPrice}
</p> </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> </div>
<Link <Link
to={`/rooms/${room.room_number}`} to={`/rooms/${room.room_number}`}
className="btn-luxury-primary flex items-center gap-2 className={`btn-luxury-primary flex items-center gap-1.5 sm:gap-2 relative justify-center
text-sm px-5 py-2.5 relative" ${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> <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> </Link>
</div> </div>
</div> </div>

View File

@@ -8,46 +8,46 @@ const RoomCardSkeleton: React.FC = () => {
overflow-hidden animate-pulse shadow-lg shadow-[#d4af37]/5" overflow-hidden animate-pulse shadow-lg shadow-[#d4af37]/5"
> >
{/* Image Skeleton */} {/* 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 */} {/* Content Skeleton */}
<div className="p-6"> <div className="p-4 sm:p-5">
{/* Title */} {/* 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 */} {/* 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 */} {/* 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-full" />
<div className="h-3 bg-gray-800 rounded w-5/6" /> <div className="h-3 bg-gray-800 rounded w-5/6" />
</div> </div>
{/* Capacity & Rating */} {/* Capacity & Rating */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-3">
<div className="h-4 bg-gray-800 rounded w-20" /> <div className="h-3 sm:h-4 bg-gray-800 rounded w-20" />
<div className="h-4 bg-gray-800 rounded w-16" /> <div className="h-3 sm:h-4 bg-gray-800 rounded w-16" />
</div> </div>
{/* Amenities */} {/* Amenities */}
<div className="flex gap-2 mb-5"> <div className="flex gap-1.5 sm:gap-2 mb-3 sm:mb-4">
<div className="h-6 bg-gray-800 rounded w-16" /> <div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
<div className="h-6 bg-gray-800 rounded w-16" /> <div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
<div className="h-6 bg-gray-800 rounded w-16" /> <div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
</div> </div>
{/* Price & Button */} {/* Price & Button */}
<div <div
className="flex items-center justify-between className="flex items-center justify-between
pt-4 border-t border-[#d4af37]/20" pt-3 border-t border-[#d4af37]/20"
> >
<div> <div>
<div className="h-3 bg-gray-800 rounded w-12 mb-2" /> <div className="h-3 bg-gray-800 rounded w-12 mb-1" />
<div className="h-7 bg-gray-800 rounded w-24 mb-2" /> <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 className="h-3 bg-gray-800 rounded w-10" />
</div> </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> </div>
</div> </div>

View 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;

View File

@@ -253,20 +253,20 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] <div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/30 rounded-xl border border-[#d4af37]/30
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/10 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="flex items-center gap-2 sm:gap-3 mb-4 sm:mb-5 md:mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg <div className="p-1.5 sm:p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30"> 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" /> <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> </svg>
</div> </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 Room Filters
</h2> </h2>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* Room Type */} {/* Room Type */}
<div> <div>
<label <label
@@ -570,14 +570,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
</div> </div>
{/* Buttons */} {/* Buttons */}
<div className="flex gap-3 pt-2"> <div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-2">
<button <button
type="submit" type="submit"
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227] className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] py-3 px-4 rounded-sm font-medium tracking-wide 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] hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
transition-all duration-300 shadow-lg shadow-[#d4af37]/30 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="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> <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" type="button"
onClick={handleReset} onClick={handleReset}
className="flex-1 bg-[#0a0a0a] backdrop-blur-sm text-gray-300 className="flex-1 bg-[#0a0a0a] backdrop-blur-sm text-gray-300
py-3 px-4 rounded-sm border border-[#d4af37]/30 py-2.5 sm:py-3 px-4 rounded-sm border border-[#d4af37]/30
hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37] hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37] active:scale-95
transition-all font-medium tracking-wide" transition-all font-medium tracking-wide text-sm sm:text-base
touch-manipulation min-h-[44px]"
> >
Reset Reset
</button> </button>

View File

@@ -1,5 +1,6 @@
export { default as RoomCard } from './RoomCard'; export { default as RoomCard } from './RoomCard';
export { default as RoomCardSkeleton } from './RoomCardSkeleton'; export { default as RoomCardSkeleton } from './RoomCardSkeleton';
export { default as RoomCarousel } from './RoomCarousel';
export { default as BannerCarousel } from './BannerCarousel'; export { default as BannerCarousel } from './BannerCarousel';
export { default as BannerSkeleton } from './BannerSkeleton'; export { default as BannerSkeleton } from './BannerSkeleton';
export { default as RoomFilter } from './RoomFilter'; export { default as RoomFilter } from './RoomFilter';

View File

@@ -4,6 +4,7 @@ import App from './App.tsx';
import ErrorBoundary from import ErrorBoundary from
'./components/common/ErrorBoundary.tsx'; './components/common/ErrorBoundary.tsx';
import './styles/index.css'; import './styles/index.css';
import 'react-datepicker/dist/react-datepicker.css';
import './styles/datepicker.css'; import './styles/datepicker.css';
ReactDOM.createRoot( ReactDOM.createRoot(

View 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;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
ArrowRight, ArrowRight,
@@ -9,6 +9,7 @@ import {
BannerSkeleton, BannerSkeleton,
RoomCard, RoomCard,
RoomCardSkeleton, RoomCardSkeleton,
RoomCarousel,
SearchRoomForm, SearchRoomForm,
} from '../components/rooms'; } from '../components/rooms';
import { import {
@@ -28,6 +29,25 @@ const HomePage: React.FC = () => {
const [isLoadingNewest, setIsLoadingNewest] = useState(true); const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [error, setError] = useState<string | null>(null); 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 // Fetch banners
useEffect(() => { useEffect(() => {
const fetchBanners = async () => { 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"> <div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100/50 to-gray-50">
{/* Featured Rooms Section */} {/* Featured & Newest Rooms Section - Combined Carousel */}
<section className="container mx-auto px-4 py-16"> <section className="container mx-auto px-4 py-6 md:py-8">
{/* Section Header */} {/* Section Header - Centered */}
<div className="luxury-section-header flex items-center justify-between animate-fade-in"> <div className="text-center animate-fade-in mb-6 md:mb-8">
<div className="flex items-center gap-3"> <h2 className="luxury-section-title text-center">
<div> Featured & Newest Rooms
<h2 className="luxury-section-title"> </h2>
Featured Rooms <p className="luxury-section-subtitle text-center max-w-2xl mx-auto mt-2">
</h2> Discover our most popular accommodations and latest additions
<p className="luxury-section-subtitle"> </p>
Discover our most popular accommodations
</p>
</div>
</div>
<Link {/* View All Rooms Button - Golden, Centered */}
to="/rooms" <div className="mt-6 flex justify-center">
className="hidden md:flex items-center gap-2 <Link
btn-luxury-secondary group text-white" to="/rooms"
> className="btn-luxury-primary inline-flex items-center gap-2 px-6 py-3 rounded-sm font-medium tracking-wide"
View All Rooms >
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <span className="relative z-10">View All Rooms</span>
</Link> <ArrowRight className="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
</div> </div>
{/* Loading State */} {/* Loading State */}
{isLoadingRooms && ( {(isLoadingRooms || isLoadingNewest) && (
<div <div className="flex justify-center">
className="grid grid-cols-1 md:grid-cols-2 <div className="max-w-md w-full">
lg:grid-cols-3 gap-6" <RoomCardSkeleton />
> </div>
{[...Array(6)].map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div> </div>
)} )}
{/* Error State */} {/* Error State */}
{error && !isLoadingRooms && ( {error && !isLoadingRooms && !isLoadingNewest && (
<div <div
className="luxury-card p-8 text-center animate-fade-in className="luxury-card p-8 text-center animate-fade-in
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50" border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
@@ -224,115 +239,25 @@ const HomePage: React.FC = () => {
</div> </div>
)} )}
{/* Rooms Grid */} {/* Combined Rooms Carousel */}
{!isLoadingRooms && !error && ( {!isLoadingRooms && !isLoadingNewest && (
<> <>
{featuredRooms.length > 0 ? ( {combinedRooms.length > 0 ? (
<div <RoomCarousel
className="grid grid-cols-1 md:grid-cols-2 rooms={combinedRooms}
lg:grid-cols-3 gap-6" autoSlideInterval={4000}
> showNavigation={true}
{featuredRooms.map((room) => ( />
<RoomCard key={room.id} room={room} />
))}
</div>
) : ( ) : (
<div <div
className="luxury-card p-12 text-center animate-fade-in" className="luxury-card p-12 text-center animate-fade-in"
> >
<p className="text-gray-600 text-lg font-light tracking-wide"> <p className="text-gray-600 text-lg font-light tracking-wide">
No featured rooms available No rooms available
</p> </p>
</div> </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> </section>

View File

@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination'; import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const BookingManagementPage: React.FC = () => { const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
@@ -206,10 +207,10 @@ const BookingManagementPage: React.FC = () => {
</td> </td>
<td className="px-8 py-5 whitespace-nowrap"> <td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900"> <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>
<div className="text-xs text-slate-500 mt-0.5"> <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> </div>
</td> </td>
<td className="px-8 py-5 whitespace-nowrap"> <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="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"> <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> <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>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200"> <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> <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>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
interface GuestInfo { interface GuestInfo {
name: string; name: string;
@@ -186,11 +187,11 @@ const CheckInPage: React.FC = () => {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Check-in:</span> <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>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Check-out:</span> <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>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Number of Guests:</span> <span className="text-gray-600">Number of Guests:</span>

View File

@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import CurrencyIcon from '../../components/common/CurrencyIcon'; import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
interface ServiceItem { interface ServiceItem {
service_name: string; service_name: string;
@@ -197,17 +198,17 @@ const CheckOutPage: React.FC = () => {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Check-in:</span> <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>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Check-out:</span> <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>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Nights:</span> <span className="text-gray-600">Nights:</span>
<span> <span>
{booking.check_in_date && booking.check_out_date {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) : 0} night(s)
</span> </span>
</div> </div>

View File

@@ -34,6 +34,7 @@ import Loading from '../../components/common/Loading';
import PaymentStatusBadge from import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge'; '../../components/common/PaymentStatusBadge';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const BookingDetailPage: React.FC = () => { const BookingDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -78,7 +79,12 @@ const BookingDetailPage: React.FC = () => {
response.success && response.success &&
response.data?.booking 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 { } else {
throw new Error( throw new Error(
'Unable to load booking information' 'Unable to load booking information'
@@ -162,7 +168,9 @@ const BookingDetailPage: React.FC = () => {
}; };
const formatDate = (dateString: string) => { 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', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -172,6 +180,64 @@ const BookingDetailPage: React.FC = () => {
const formatPrice = (price: number) => formatCurrency(price); 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) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'pending': case 'pending':
@@ -369,7 +435,7 @@ const BookingDetailPage: React.FC = () => {
Room Price Room Price
</p> </p>
<p className="font-medium text-indigo-600"> <p className="font-medium text-indigo-600">
{formatPrice(room?.price || roomType.base_price)}/night {formatPrice(calculateRoomPricePerNight())}/night
</p> </p>
</div> </div>
</div> </div>
@@ -461,21 +527,67 @@ const BookingDetailPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Total Price */} {/* Price Breakdown */}
<div className="border-t pt-4"> <div className="border-t pt-4">
<div className="flex justify-between <h3 className="text-lg font-semibold text-gray-900 mb-4">
items-center" Price Breakdown
> </h3>
<span className="text-lg font-semibold
text-gray-900" <div className="space-y-3">
> {/* Room Price */}
Total Payment <div className="flex justify-between items-center">
</span> <div>
<span className="text-2xl font-bold <p className="text-sm font-medium text-gray-900">
text-indigo-600" Room ({calculateNights()} night{calculateNights() !== 1 ? 's' : ''})
> </p>
{formatPrice(booking.total_price)} <p className="text-xs text-gray-500">
</span> {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> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import {
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { import {
Calendar, Calendar,
Users, Users,
@@ -21,15 +22,18 @@ import {
Sparkles, Sparkles,
Star, Star,
MapPin, MapPin,
Plus,
Minus,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { getRoomById, type Room } from import { getRoomById, getRoomBookedDates, type Room } from
'../../services/api/roomService'; '../../services/api/roomService';
import { import {
createBooking, createBooking,
checkRoomAvailability, checkRoomAvailability,
type BookingData, type BookingData,
} from '../../services/api/bookingService'; } from '../../services/api/bookingService';
import { serviceService, Service } from '../../services/api';
import useAuthStore from '../../store/useAuthStore'; import useAuthStore from '../../store/useAuthStore';
import { import {
bookingValidationSchema, bookingValidationSchema,
@@ -37,6 +41,7 @@ import {
} from '../../validators/bookingValidator'; } from '../../validators/bookingValidator';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDateLocal } from '../../utils/format';
const BookingPage: React.FC = () => { const BookingPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -48,6 +53,9 @@ const BookingPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); 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 // Redirect if not authenticated
useEffect(() => { useEffect(() => {
@@ -61,13 +69,82 @@ const BookingPage: React.FC = () => {
} }
}, [isAuthenticated, navigate, id]); }, [isAuthenticated, navigate, id]);
// Fetch room details // Fetch room details and services
useEffect(() => { useEffect(() => {
if (id && isAuthenticated) { if (id && isAuthenticated) {
fetchRoomDetails(Number(id)); fetchRoomDetails(Number(id));
fetchServices();
fetchBookedDates(Number(id));
} }
}, [id, isAuthenticated]); }, [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) => { const fetchRoomDetails = async (roomId: number) => {
try { try {
setLoading(true); setLoading(true);
@@ -136,7 +213,14 @@ const BookingPage: React.FC = () => {
(room?.price && room.price > 0) (room?.price && room.price > 0)
? room.price ? room.price
: (room?.room_type?.base_price || 0); : (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 // Format price using currency context
const formatPrice = (price: number) => formatCurrency(price); const formatPrice = (price: number) => formatCurrency(price);
@@ -148,12 +232,35 @@ const BookingPage: React.FC = () => {
try { try {
setSubmitting(true); setSubmitting(true);
const checkInDateStr = data.checkInDate // Format dates in local timezone to avoid timezone conversion issues
.toISOString() const checkInDateStr = formatDateLocal(data.checkInDate);
.split('T')[0]; const checkOutDateStr = formatDateLocal(data.checkOutDate);
const checkOutDateStr = data.checkOutDate
.toISOString() // Validate that selected dates are not booked
.split('T')[0]; 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 // Step 1: Check room availability
const availability = await checkRoomAvailability( const availability = await checkRoomAvailability(
@@ -184,6 +291,10 @@ const BookingPage: React.FC = () => {
email: data.email, email: data.email,
phone: data.phone, phone: data.phone,
}, },
services: selectedServices.map(item => ({
service_id: item.service.id,
quantity: item.quantity,
})),
}; };
// Step 3: Create booking // Step 3: Create booking
@@ -463,15 +574,70 @@ const BookingPage: React.FC = () => {
render={({ field }) => ( render={({ field }) => (
<DatePicker <DatePicker
selected={field.value} selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={new Date()} minDate={new Date()}
selectsStart selectsStart
startDate={checkInDate} startDate={checkInDate}
endDate={checkOutDate} endDate={checkOutDate}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
placeholderText="Select check-in date" 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 className="w-full px-4 py-3
bg-[#0a0a0a] border border-[#d4af37]/20 bg-[#0a0a0a] border border-[#d4af37]/20
rounded-lg text-white placeholder-gray-500 rounded-lg text-white placeholder-gray-500
@@ -509,9 +675,6 @@ const BookingPage: React.FC = () => {
render={({ field }) => ( render={({ field }) => (
<DatePicker <DatePicker
selected={field.value} selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={ minDate={
checkInDate || new Date() checkInDate || new Date()
} }
@@ -520,6 +683,64 @@ const BookingPage: React.FC = () => {
endDate={checkOutDate} endDate={checkOutDate}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
placeholderText="Select check-out date" 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 className="w-full px-4 py-3
bg-[#0a0a0a] border border-[#d4af37]/20 bg-[#0a0a0a] border border-[#d4af37]/20
rounded-lg text-white placeholder-gray-500 rounded-lg text-white placeholder-gray-500
@@ -606,6 +827,137 @@ const BookingPage: React.FC = () => {
</div> </div>
</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 */} {/* Payment Method */}
<div className="border-t border-[#d4af37]/20 pt-8"> <div className="border-t border-[#d4af37]/20 pt-8">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -869,6 +1221,45 @@ const BookingPage: React.FC = () => {
</div> </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 <div
className="border-t border-[#d4af37]/20 pt-4 flex className="border-t border-[#d4af37]/20 pt-4 flex
justify-between items-center" justify-between items-center"

View File

@@ -32,6 +32,7 @@ import { confirmBankTransfer } from
'../../services/api/paymentService'; '../../services/api/paymentService';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const BookingSuccessPage: React.FC = () => { const BookingSuccessPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -115,7 +116,9 @@ const BookingSuccessPage: React.FC = () => {
}; };
const formatDate = (dateString: string) => { 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', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',

View File

@@ -15,6 +15,7 @@ import {
} from '../../services/api/paymentService'; } from '../../services/api/paymentService';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import StripePaymentWrapper from '../../components/payments/StripePaymentWrapper'; import StripePaymentWrapper from '../../components/payments/StripePaymentWrapper';
const FullPaymentPage: React.FC = () => { const FullPaymentPage: React.FC = () => {
@@ -407,14 +408,14 @@ const FullPaymentPage: React.FC = () => {
<div> <div>
<span className="text-gray-400 font-light">Check-in</span> <span className="text-gray-400 font-light">Check-in</span>
<p className="text-white font-medium"> <p className="text-white font-medium">
{new Date(booking.check_in_date).toLocaleDateString('en-US')} {parseDateLocal(booking.check_in_date).toLocaleDateString('en-US')}
</p> </p>
</div> </div>
<div> <div>
<span className="text-gray-400 font-light">Check-out</span> <span className="text-gray-400 font-light">Check-out</span>
<p className="text-white font-medium"> <p className="text-white font-medium">
{new Date(booking.check_out_date).toLocaleDateString('en-US')} {parseDateLocal(booking.check_out_date).toLocaleDateString('en-US')}
</p> </p>
</div> </div>

View File

@@ -26,6 +26,7 @@ import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import EmptyState from '../../components/common/EmptyState'; import EmptyState from '../../components/common/EmptyState';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const MyBookingsPage: React.FC = () => { const MyBookingsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -168,7 +169,9 @@ const MyBookingsPage: React.FC = () => {
}; };
const formatDate = (dateString: string) => { 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', weekday: 'short',
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',

View File

@@ -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 { useSearchParams, Link } from 'react-router-dom';
import { getRooms } from '../../services/api/roomService'; import { getRooms } from '../../services/api/roomService';
import type { Room } 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 RoomCard from '../../components/rooms/RoomCard';
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton'; import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
import Pagination from '../../components/rooms/Pagination'; 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 RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterRef = useRef<HTMLDivElement>(null);
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
total: 0, total: 0,
page: 1, page: 1,
@@ -20,6 +22,15 @@ const RoomListPage: React.FC = () => {
totalPages: 1, 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 // Fetch rooms based on URL params
useEffect(() => { useEffect(() => {
const fetchRooms = async () => { const fetchRooms = async () => {
@@ -66,51 +77,97 @@ const RoomListPage: React.FC = () => {
}, [searchParams]); }, [searchParams]);
return ( return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]"> <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' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> {/* Full-width hero section */}
{/* Back Button */} <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">
<Link <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">
to="/" {/* Back Button */}
className="inline-flex items-center gap-2 <Link
text-[#d4af37]/80 hover:text-[#d4af37] to="/"
mb-10 transition-all duration-300 className="inline-flex items-center gap-2
group font-light tracking-wide" bg-gradient-to-r from-[#d4af37] to-[#c9a227]
> text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37]
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" /> active:scale-95
<span>Back to home</span> mb-4 sm:mb-5 md:mb-6 transition-all duration-300
</Link> group font-medium tracking-wide text-sm
px-4 py-2 rounded-sm
{/* Page Header */} shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40
<div className="mb-12 text-center"> touch-manipulation"
<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"
> >
Our Rooms & Suites <ArrowLeft className="w-4 h-4 sm:w-4 sm:h-4 group-hover:-translate-x-1 transition-transform" />
</h1> <span>Back to home</span>
<p className="text-gray-400 font-light tracking-wide text-lg max-w-2xl mx-auto"> </Link>
Discover our collection of luxurious accommodations,
each designed to provide an exceptional stay
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-10"> {/* Page Header */}
<aside className="lg:col-span-1"> <div className="text-center max-w-3xl mx-auto px-2">
<div className="sticky top-6"> <div className="inline-flex items-center justify-center gap-2 mb-3 sm:mb-4">
<RoomFilter /> <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> </div>
</aside> </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 && ( {loading && (
<div className="grid grid-cols-1 md:grid-cols-2 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
xl:grid-cols-2 gap-8" gap-3 sm:gap-4 md:gap-5 lg:gap-6"
> >
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<RoomCardSkeleton key={index} /> <RoomCardSkeleton key={index} />
@@ -120,14 +177,14 @@ const RoomListPage: React.FC = () => {
{error && !loading && ( {error && !loading && (
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10 <div className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-12 text-center border border-red-500/30 rounded-xl p-6 sm:p-8 md:p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10" backdrop-blur-xl shadow-2xl shadow-red-500/10"
> >
<div className="inline-flex items-center justify-center w-20 h-20 <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-6 border border-red-500/30" bg-red-500/20 rounded-full mb-4 sm:mb-5 md:mb-6 border border-red-500/30"
> >
<svg <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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -141,13 +198,14 @@ const RoomListPage: React.FC = () => {
/> />
</svg> </svg>
</div> </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 <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] 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-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[#f5d76e] hover:to-[#d4af37] hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
transition-all duration-300 shadow-lg shadow-[#d4af37]/30" transition-all duration-300 shadow-lg shadow-[#d4af37]/30
touch-manipulation min-h-[44px]"
> >
Try Again Try Again
</button> </button>
@@ -156,28 +214,29 @@ const RoomListPage: React.FC = () => {
{!loading && !error && rooms.length === 0 && ( {!loading && !error && rooms.length === 0 && (
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] <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" backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
> >
<div className="inline-flex items-center justify-center w-24 h-24 <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-8 border border-[#d4af37]/30" 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> </div>
<h3 className="text-2xl font-serif font-semibold <h3 className="text-lg sm:text-xl md:text-2xl font-serif font-semibold
text-white mb-4 tracking-wide" text-white mb-3 sm:mb-4 tracking-wide px-2"
> >
No matching rooms found No matching rooms found
</h3> </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 Please try adjusting the filters or search differently
</p> </p>
<button <button
onClick={() => window.location.href = '/rooms'} onClick={() => window.location.href = '/rooms'}
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] 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-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[#f5d76e] hover:to-[#d4af37] hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
transition-all duration-300 shadow-lg shadow-[#d4af37]/30" transition-all duration-300 shadow-lg shadow-[#d4af37]/30
touch-manipulation min-h-[44px]"
> >
Clear Filters Clear Filters
</button> </button>
@@ -187,15 +246,16 @@ const RoomListPage: React.FC = () => {
{!loading && !error && rooms.length > 0 && ( {!loading && !error && rooms.length > 0 && (
<> <>
{/* Results Count */} {/* Results Count */}
<div className="mb-6 flex items-center justify-between"> <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"> <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{' '} Showing <span className="text-[#d4af37] font-medium">{rooms.length}</span> of{' '}
<span className="text-[#d4af37] font-medium">{pagination.total}</span> rooms <span className="text-[#d4af37] font-medium">{pagination.total}</span> rooms
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 {/* Responsive grid: 1 col mobile, 2 cols tablet, 3 cols desktop */}
xl:grid-cols-2 gap-8 mb-10" <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) => ( {rooms.map((room) => (
<RoomCard key={room.id} room={room} /> <RoomCard key={room.id} room={room} />
@@ -203,17 +263,18 @@ const RoomListPage: React.FC = () => {
</div> </div>
{pagination.totalPages > 1 && ( {pagination.totalPages > 1 && (
<div className="mt-10 pt-8 border-t border-[#d4af37]/20"> <div className="mt-4 sm:mt-5 md:mt-6 pt-3 sm:pt-4 border-t border-[#d4af37]/20">
<Pagination <Pagination
currentPage={pagination.page} currentPage={pagination.page}
totalPages={pagination.totalPages} totalPages={pagination.totalPages}
/> />
</div> </div>
)} )}
</> </>
)} )}
</main> </main>
</div> </div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -21,6 +21,7 @@ import { searchAvailableRooms } from
'../../services/api/roomService'; '../../services/api/roomService';
import type { Room } from '../../services/api/roomService'; import type { Room } from '../../services/api/roomService';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { parseDateLocal } from '../../utils/format';
const SearchResultsPage: React.FC = () => { const SearchResultsPage: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -109,7 +110,7 @@ const SearchResultsPage: React.FC = () => {
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = parseDateLocal(dateString);
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',

View File

@@ -14,6 +14,10 @@ export interface BookingData {
email: string; email: string;
phone: string; phone: string;
}; };
services?: Array<{
service_id: number;
quantity: number;
}>;
} }
export interface Booking { export interface Booking {

View 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;
};

View File

@@ -100,6 +100,16 @@ export const getRoomById = async (
return response.data; 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 * Get room by room number
*/ */

View File

@@ -68,14 +68,31 @@
} }
.react-datepicker__day--disabled { .react-datepicker__day--disabled {
color: #525252; color: #ef4444 !important;
background-color: rgba(239, 68, 68, 0.2) !important;
cursor: not-allowed; 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 { .react-datepicker__day--disabled:hover {
background-color: transparent; background-color: rgba(239, 68, 68, 0.3) !important;
border: none; 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 { .react-datepicker__navigation {
@@ -110,3 +127,71 @@
background-color: rgba(212, 175, 55, 0.25); background-color: rgba(212, 175, 55, 0.25);
border-color: rgba(212, 175, 55, 0.6); 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;
}

View File

@@ -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 = ( export const formatDate = (
date: string | Date, date: string | Date,
format: 'short' | 'medium' | 'long' | 'full' = 'medium' format: 'short' | 'medium' | 'long' | 'full' = 'medium'
): string => { ): 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'; if (isNaN(dateObj.getTime())) return 'Invalid Date';