diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index 214e3ce3..bc96cf66 100644 Binary files a/Backend/src/__pycache__/main.cpython-312.pyc and b/Backend/src/__pycache__/main.cpython-312.pyc differ diff --git a/Backend/src/main.py b/Backend/src/main.py index ecfda6d7..be697f93 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -194,9 +194,9 @@ app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX) # Import and include other routes from .routes import ( room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, - favorite_routes, service_routes, promotion_routes, report_routes, + favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, - system_settings_routes + system_settings_routes, contact_routes ) # Legacy routes (maintain backward compatibility) @@ -207,6 +207,7 @@ app.include_router(invoice_routes.router, prefix="/api") app.include_router(banner_routes.router, prefix="/api") app.include_router(favorite_routes.router, prefix="/api") app.include_router(service_routes.router, prefix="/api") +app.include_router(service_booking_routes.router, prefix="/api") app.include_router(promotion_routes.router, prefix="/api") app.include_router(report_routes.router, prefix="/api") app.include_router(review_routes.router, prefix="/api") @@ -214,6 +215,7 @@ app.include_router(user_routes.router, prefix="/api") app.include_router(audit_routes.router, prefix="/api") app.include_router(admin_privacy_routes.router, prefix="/api") app.include_router(system_settings_routes.router, prefix="/api") +app.include_router(contact_routes.router, prefix="/api") # Versioned routes (v1) app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) @@ -223,6 +225,7 @@ app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(service_booking_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX) @@ -230,6 +233,7 @@ app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX) logger.info("All routes registered successfully") diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index 20e9bdbe..edb1f882 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -8,6 +8,7 @@ from .booking import Booking from .payment import Payment from .service import Service from .service_usage import ServiceUsage +from .service_booking import ServiceBooking, ServiceBookingItem, ServicePayment, ServiceBookingStatus, ServicePaymentStatus, ServicePaymentMethod from .promotion import Promotion from .checkin_checkout import CheckInCheckOut from .banner import Banner @@ -30,6 +31,12 @@ __all__ = [ "Payment", "Service", "ServiceUsage", + "ServiceBooking", + "ServiceBookingItem", + "ServicePayment", + "ServiceBookingStatus", + "ServicePaymentStatus", + "ServicePaymentMethod", "Promotion", "CheckInCheckOut", "Banner", diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index bad4ebeb..b8a9853d 100644 Binary files a/Backend/src/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/service_booking.cpython-312.pyc b/Backend/src/models/__pycache__/service_booking.cpython-312.pyc new file mode 100644 index 00000000..80c3f8a3 Binary files /dev/null and b/Backend/src/models/__pycache__/service_booking.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/user.cpython-312.pyc b/Backend/src/models/__pycache__/user.cpython-312.pyc index fdbcb33e..bb54f889 100644 Binary files a/Backend/src/models/__pycache__/user.cpython-312.pyc and b/Backend/src/models/__pycache__/user.cpython-312.pyc differ diff --git a/Backend/src/models/service_booking.py b/Backend/src/models/service_booking.py new file mode 100644 index 00000000..31d09155 --- /dev/null +++ b/Backend/src/models/service_booking.py @@ -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") + diff --git a/Backend/src/models/user.py b/Backend/src/models/user.py index 445e5772..ef7babf3 100644 --- a/Backend/src/models/user.py +++ b/Backend/src/models/user.py @@ -28,4 +28,5 @@ class User(Base): checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by") reviews = relationship("Review", back_populates="user") favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") + service_bookings = relationship("ServiceBooking", back_populates="user") diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index dae85e6d..ec9b477c 100644 Binary files a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc new file mode 100644 index 00000000..7f221679 Binary files /dev/null and b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc index 8132d57a..18c8c867 100644 Binary files a/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc new file mode 100644 index 00000000..4940abc2 Binary files /dev/null and b/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 13a9110a..5bd28582 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -14,6 +14,7 @@ from ..models.booking import Booking, BookingStatus from ..models.room import Room, RoomStatus from ..models.room_type import RoomType from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus +from ..models.service_usage import ServiceUsage from ..services.room_service import normalize_images, get_base_url from fastapi import Request from ..utils.mailer import send_email @@ -83,8 +84,8 @@ async def get_all_bookings( "booking_number": booking.booking_number, "user_id": booking.user_id, "room_id": booking.room_id, - "check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, - "check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "num_guests": booking.num_guests, "total_price": float(booking.total_price) if booking.total_price else 0.0, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, @@ -148,8 +149,8 @@ async def get_my_bookings( "id": booking.id, "booking_number": booking.booking_number, "room_id": booking.room_id, - "check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, - "check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "num_guests": booking.num_guests, "total_price": float(booking.total_price) if booking.total_price else 0.0, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, @@ -217,8 +218,18 @@ async def create_booking( if not room: raise HTTPException(status_code=404, detail="Room not found") - check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) - check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) + # Parse dates as date-only strings (YYYY-MM-DD) - treat as naive datetime + if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date: + check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) + else: + # Date-only format (YYYY-MM-DD) - parse as naive datetime + check_in = datetime.strptime(check_in_date, '%Y-%m-%d') + + if 'T' in check_out_date or 'Z' in check_out_date or '+' in check_out_date: + check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) + else: + # Date-only format (YYYY-MM-DD) - parse as naive datetime + check_out = datetime.strptime(check_out_date, '%Y-%m-%d') # Check for overlapping bookings overlapping = db.query(Booking).filter( @@ -286,12 +297,72 @@ async def create_booking( # Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record # The payment will be created when the customer pays at check-in + # Add services to booking if provided + services = booking_data.get("services", []) + if services: + from ..models.service import Service + from ..models.service_usage import ServiceUsage + + for service_item in services: + service_id = service_item.get("service_id") + quantity = service_item.get("quantity", 1) + + if not service_id: + continue + + # Check if service exists and is active + service = db.query(Service).filter(Service.id == service_id).first() + if not service or not service.is_active: + continue + + # Calculate total price for this service + unit_price = float(service.price) + total_price = unit_price * quantity + + # Create service usage + service_usage = ServiceUsage( + booking_id=booking.id, + service_id=service_id, + quantity=quantity, + unit_price=unit_price, + total_price=total_price, + ) + db.add(service_usage) + db.commit() db.refresh(booking) - # Fetch with relations for proper serialization (eager load payments) - from sqlalchemy.orm import joinedload - booking = db.query(Booking).options(joinedload(Booking.payments)).filter(Booking.id == booking.id).first() + # Automatically create invoice for the booking + try: + from ..services.invoice_service import InvoiceService + from sqlalchemy.orm import joinedload, selectinload + + # Reload booking with service_usages for invoice creation + booking = db.query(Booking).options( + selectinload(Booking.service_usages).selectinload(ServiceUsage.service) + ).filter(Booking.id == booking.id).first() + + # Create invoice automatically + invoice = InvoiceService.create_invoice_from_booking( + booking_id=booking.id, + db=db, + created_by_id=current_user.id, + tax_rate=0.0, # Default no tax, can be configured + discount_amount=0.0, + due_days=30, + ) + except Exception as e: + # Log error but don't fail booking creation if invoice creation fails + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to create invoice for booking {booking.id}: {str(e)}") + + # Fetch with relations for proper serialization (eager load payments and service_usages) + from sqlalchemy.orm import joinedload, selectinload + booking = db.query(Booking).options( + joinedload(Booking.payments), + selectinload(Booking.service_usages).selectinload(ServiceUsage.service) + ).filter(Booking.id == booking.id).first() # Determine payment_method and payment_status from payments payment_method_from_payments = None @@ -310,8 +381,8 @@ async def create_booking( "booking_number": booking.booking_number, "user_id": booking.user_id, "room_id": booking.room_id, - "check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, - "check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "guest_count": booking.num_guests, "total_price": float(booking.total_price) if booking.total_price else 0.0, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, @@ -349,6 +420,31 @@ async def create_booking( for p in booking.payments ] + # Add service usages if they exist + service_usages = getattr(booking, 'service_usages', None) + import logging + logger = logging.getLogger(__name__) + logger.info(f"Booking {booking.id} - service_usages: {service_usages}, type: {type(service_usages)}") + + if service_usages and len(service_usages) > 0: + logger.info(f"Booking {booking.id} - Found {len(service_usages)} service usages") + booking_dict["service_usages"] = [ + { + "id": su.id, + "service_id": su.service_id, + "service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service", + "quantity": su.quantity, + "unit_price": float(su.unit_price) if su.unit_price else 0.0, + "total_price": float(su.total_price) if su.total_price else 0.0, + } + for su in service_usages + ] + logger.info(f"Booking {booking.id} - Serialized service_usages: {booking_dict['service_usages']}") + else: + # Initialize empty array if no service_usages + logger.info(f"Booking {booking.id} - No service_usages found, initializing empty array") + booking_dict["service_usages"] = [] + # Add room info if available if booking.room: booking_dict["room"] = { @@ -414,9 +510,11 @@ async def get_booking_by_id( try: # Eager load all relationships to avoid N+1 queries # Using selectinload for better performance with multiple relationships + from sqlalchemy.orm import selectinload booking = db.query(Booking)\ .options( selectinload(Booking.payments), + selectinload(Booking.service_usages).selectinload(ServiceUsage.service), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type) )\ @@ -448,8 +546,8 @@ async def get_booking_by_id( "booking_number": booking.booking_number, "user_id": booking.user_id, "room_id": booking.room_id, - "check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, - "check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "guest_count": booking.num_guests, # Frontend expects guest_count "total_price": float(booking.total_price) if booking.total_price else 0.0, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, @@ -513,6 +611,32 @@ async def get_booking_by_id( for p in booking.payments ] + # Add service usages if they exist + # Use getattr to safely access service_usages in case relationship isn't loaded + service_usages = getattr(booking, 'service_usages', None) + import logging + logger = logging.getLogger(__name__) + logger.info(f"Get booking {id} - service_usages: {service_usages}, type: {type(service_usages)}") + + if service_usages and len(service_usages) > 0: + logger.info(f"Get booking {id} - Found {len(service_usages)} service usages") + booking_dict["service_usages"] = [ + { + "id": su.id, + "service_id": su.service_id, + "service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service", + "quantity": su.quantity, + "unit_price": float(su.unit_price) if su.unit_price else 0.0, + "total_price": float(su.total_price) if su.total_price else 0.0, + } + for su in service_usages + ] + logger.info(f"Get booking {id} - Serialized service_usages: {booking_dict['service_usages']}") + else: + # Initialize empty array if no service_usages + logger.info(f"Get booking {id} - No service_usages found, initializing empty array") + booking_dict["service_usages"] = [] + return { "success": True, "data": {"booking": booking_dict} @@ -657,8 +781,8 @@ async def check_booking_by_number( "id": booking.id, "booking_number": booking.booking_number, "room_id": booking.room_id, - "check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, - "check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, } diff --git a/Backend/src/routes/contact_routes.py b/Backend/src/routes/contact_routes.py new file mode 100644 index 00000000..0c8b7db5 --- /dev/null +++ b/Backend/src/routes/contact_routes.py @@ -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""" + + + + + + + +
+
+

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}
+
+
+ +
+ + + """ + + # 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." + ) + diff --git a/Backend/src/routes/room_routes.py b/Backend/src/routes/room_routes.py index b59173ae..968e9696 100644 --- a/Backend/src/routes/room_routes.py +++ b/Backend/src/routes/room_routes.py @@ -729,6 +729,59 @@ async def delete_room_images( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/{id}/booked-dates") +async def get_room_booked_dates( + id: int, + db: Session = Depends(get_db) +): + """Get all booked dates for a specific room""" + try: + # Check if room exists + room = db.query(Room).filter(Room.id == id).first() + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + # Get all non-cancelled bookings for this room + bookings = db.query(Booking).filter( + and_( + Booking.room_id == id, + Booking.status != BookingStatus.cancelled + ) + ).all() + + # Generate list of all booked dates + booked_dates = [] + for booking in bookings: + # Parse dates + check_in = booking.check_in_date + check_out = booking.check_out_date + + # Generate all dates between check-in and check-out (exclusive of check-out) + current_date = check_in.date() + end_date = check_out.date() + + while current_date < end_date: + booked_dates.append(current_date.isoformat()) + # Move to next day + from datetime import timedelta + current_date += timedelta(days=1) + + # Remove duplicates and sort + booked_dates = sorted(list(set(booked_dates))) + + return { + "status": "success", + "data": { + "room_id": id, + "booked_dates": booked_dates + } + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/{id}/reviews") async def get_room_reviews_route( id: int, diff --git a/Backend/src/routes/service_booking_routes.py b/Backend/src/routes/service_booking_routes.py new file mode 100644 index 00000000..c50f8d68 --- /dev/null +++ b/Backend/src/routes/service_booking_routes.py @@ -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)) + diff --git a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc index 652abef1..31adb076 100644 Binary files a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc and b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc differ diff --git a/Backend/src/services/invoice_service.py b/Backend/src/services/invoice_service.py index 2e4665a7..b2fc492c 100644 --- a/Backend/src/services/invoice_service.py +++ b/Backend/src/services/invoice_service.py @@ -62,7 +62,12 @@ class InvoiceService: Returns: Invoice dictionary """ - booking = db.query(Booking).filter(Booking.id == booking_id).first() + from sqlalchemy.orm import selectinload + + booking = db.query(Booking).options( + selectinload(Booking.service_usages).selectinload("service"), + selectinload(Booking.room).selectinload("room_type") + ).filter(Booking.id == booking_id).first() if not booking: raise ValueError("Booking not found") @@ -73,10 +78,9 @@ class InvoiceService: # Generate invoice number invoice_number = generate_invoice_number(db) - # Calculate amounts + # Calculate amounts - subtotal will be recalculated after adding items + # Initial subtotal is booking total (room + services) subtotal = float(booking.total_price) - tax_amount = (subtotal - discount_amount) * (tax_rate / 100) - total_amount = subtotal + tax_amount - discount_amount # Calculate amount paid from completed payments amount_paid = sum( @@ -132,15 +136,26 @@ class InvoiceService: db.add(invoice) # Create invoice items from booking + # Calculate room price (total_price includes services, so subtract services) + services_total = sum( + float(su.total_price) for su in booking.service_usages + ) + room_price = float(booking.total_price) - services_total + + # Calculate number of nights + nights = (booking.check_out_date - booking.check_in_date).days + if nights <= 0: + nights = 1 + # Room item room_item = InvoiceItem( invoice_id=invoice.id, - description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'}", - quantity=1, - unit_price=float(booking.total_price), + description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''})", + quantity=nights, + unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, discount_amount=0.0, - line_total=float(booking.total_price), + line_total=room_price, room_id=booking.room_id, ) db.add(room_item) @@ -151,25 +166,27 @@ class InvoiceService: invoice_id=invoice.id, description=f"Service: {service_usage.service.name}", quantity=float(service_usage.quantity), - unit_price=float(service_usage.service.price), + unit_price=float(service_usage.unit_price), tax_rate=tax_rate, discount_amount=0.0, - line_total=float(service_usage.quantity) * float(service_usage.service.price), + line_total=float(service_usage.total_price), service_id=service_usage.service_id, ) db.add(service_item) - subtotal += float(service_usage.quantity) * float(service_usage.service.price) - # Recalculate totals if services were added - if booking.service_usages: - tax_amount = (subtotal - discount_amount) * (tax_rate / 100) - total_amount = subtotal + tax_amount - discount_amount - balance_due = total_amount - amount_paid - - invoice.subtotal = subtotal - invoice.tax_amount = tax_amount - invoice.total_amount = total_amount - invoice.balance_due = balance_due + # Recalculate subtotal from items (room + services) + subtotal = room_price + services_total + + # Recalculate tax and total amounts + tax_amount = (subtotal - discount_amount) * (tax_rate / 100) + total_amount = subtotal + tax_amount - discount_amount + balance_due = total_amount - amount_paid + + # Update invoice with correct amounts + invoice.subtotal = subtotal + invoice.tax_amount = tax_amount + invoice.total_amount = total_amount + invoice.balance_due = balance_due db.commit() db.refresh(invoice) diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 57be6cef..b13797a4 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -47,6 +47,7 @@ const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage' const InvoicePage = lazy(() => import('./pages/customer/InvoicePage')); const ProfilePage = lazy(() => import('./pages/customer/ProfilePage')); const AboutPage = lazy(() => import('./pages/AboutPage')); +const ContactPage = lazy(() => import('./pages/ContactPage')); const LoginPage = lazy(() => import('./pages/auth/LoginPage')); const RegisterPage = lazy(() => import('./pages/auth/RegisterPage')); const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage')); @@ -182,6 +183,10 @@ function App() { path="about" element={} /> + } + /> {/* Protected Routes - Requires login */} = ({ About + + Contact + + {/* Desktop Auth Section */} @@ -330,6 +340,17 @@ const Header: React.FC = ({ > About + 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 +
= ({ room }) => { +const RoomCard: React.FC = ({ room, compact = false }) => { const roomType = room.room_type; const { formatCurrency } = useFormatCurrency(); @@ -70,13 +72,17 @@ const RoomCard: React.FC = ({ room }) => { return (
{/* Image */} -
= ({ room }) => {
- {/* Featured Badge */} + {/* Featured Badge with Crown */} {room.featured && (
- Featured + + Featured
)} @@ -137,36 +145,45 @@ const RoomCard: React.FC = ({ room }) => {
{/* Content */} -
+
{/* Room Type Name */} -

- {roomType.name} +

+ {room.featured && ( + + )} + {roomType.name}

{/* Room Number & Floor */}
- + Room {room.room_number} - Floor {room.floor}
{/* Description (truncated) - Show room-specific description first */} - {(room.description || roomType.description) && ( -

{room.description || roomType.description}

)} {/* Capacity & Rating */} -
+
- - + + {room.capacity || roomType.capacity} guests
@@ -174,29 +191,31 @@ const RoomCard: React.FC = ({ room }) => { {room.average_rating != null && (
- + {Number(room.average_rating).toFixed(1)} - - ({Number(room.total_reviews || 0)}) - + {!compact && ( + + ({Number(room.total_reviews || 0)}) + + )}
)}
{/* Amenities */} - {amenities.length > 0 && ( -
+ {amenities.length > 0 && !compact && ( +
{amenities.map((amenity, index) => (
@@ -204,31 +223,38 @@ const RoomCard: React.FC = ({ room }) => { {amenityIcons[amenity.toLowerCase()] || } - {amenity} + {amenity}
))}
)} {/* Price & Action */} -
+
-

From

-

+ {!compact && ( +

From

+ )} +

{formattedPrice}

-

/ night

+ {!compact && ( +

/ night

+ )}
View Details - +
diff --git a/Frontend/src/components/rooms/RoomCardSkeleton.tsx b/Frontend/src/components/rooms/RoomCardSkeleton.tsx index cefd4bf5..9da2493b 100644 --- a/Frontend/src/components/rooms/RoomCardSkeleton.tsx +++ b/Frontend/src/components/rooms/RoomCardSkeleton.tsx @@ -8,46 +8,46 @@ const RoomCardSkeleton: React.FC = () => { overflow-hidden animate-pulse shadow-lg shadow-[#d4af37]/5" > {/* Image Skeleton */} -
+
{/* Content Skeleton */} -
+
{/* Title */} -
+
{/* Room Number */} -
+
{/* Description */} -
+
{/* Capacity & Rating */} -
-
-
+
+
+
{/* Amenities */} -
-
-
-
+
+
+
+
{/* Price & Button */}
-
-
+
+
-
+
diff --git a/Frontend/src/components/rooms/RoomCarousel.tsx b/Frontend/src/components/rooms/RoomCarousel.tsx new file mode 100644 index 00000000..23e73a99 --- /dev/null +++ b/Frontend/src/components/rooms/RoomCarousel.tsx @@ -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 = ({ + 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 ( +
+

+ No rooms available +

+
+ ); + } + + // 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 ( +
+ {/* Carousel Container */} +
+ {/* Room Cards Container */} +
+ {rooms.map((room, index) => { + // For mobile: all cards are "center", for tablet/desktop: use centerIndex + const isCenter = index === centerIndex || rooms.length <= 2; + + return ( +
+
+ +
+
+ ); + })} +
+
+ + {/* Navigation Arrows */} + {showNavigation && rooms.length > 1 && ( + <> + + + + + )} + + {/* Dots Indicator */} + {rooms.length > 1 && ( +
+ {rooms.map((_, index) => ( +
+ )} +
+ ); +}; + +export default RoomCarousel; + diff --git a/Frontend/src/components/rooms/RoomFilter.tsx b/Frontend/src/components/rooms/RoomFilter.tsx index 8fe8da2f..0527e4cc 100644 --- a/Frontend/src/components/rooms/RoomFilter.tsx +++ b/Frontend/src/components/rooms/RoomFilter.tsx @@ -253,20 +253,20 @@ const RoomFilter: React.FC = ({ onFilterChange }) => {
-
-
+
- +
-

+

Room Filters

-
+ {/* Room Type */}
{/* Buttons */} -
+
diff --git a/Frontend/src/components/rooms/index.ts b/Frontend/src/components/rooms/index.ts index 04ea2374..f9e799f7 100644 --- a/Frontend/src/components/rooms/index.ts +++ b/Frontend/src/components/rooms/index.ts @@ -1,5 +1,6 @@ export { default as RoomCard } from './RoomCard'; export { default as RoomCardSkeleton } from './RoomCardSkeleton'; +export { default as RoomCarousel } from './RoomCarousel'; export { default as BannerCarousel } from './BannerCarousel'; export { default as BannerSkeleton } from './BannerSkeleton'; export { default as RoomFilter } from './RoomFilter'; diff --git a/Frontend/src/main.tsx b/Frontend/src/main.tsx index d9b0823b..13927345 100644 --- a/Frontend/src/main.tsx +++ b/Frontend/src/main.tsx @@ -4,6 +4,7 @@ import App from './App.tsx'; import ErrorBoundary from './components/common/ErrorBoundary.tsx'; import './styles/index.css'; +import 'react-datepicker/dist/react-datepicker.css'; import './styles/datepicker.css'; ReactDOM.createRoot( diff --git a/Frontend/src/pages/ContactPage.tsx b/Frontend/src/pages/ContactPage.tsx new file mode 100644 index 00000000..9b66bcbc --- /dev/null +++ b/Frontend/src/pages/ContactPage.tsx @@ -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>({}); + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + 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) => { + 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 ( +
+ {/* Full-width hero section */} +
+ {/* Decorative Elements */} +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+

+ + Contact Us + +

+
+

+ Experience the pinnacle of hospitality. We're here to make your stay extraordinary. +

+
+
+
+
+ + {/* Full-width content area */} +
+
+
+ {/* Contact Info Section */} +
+
+ {/* Subtle background gradient */} +
+ +
+
+
+

+ Get in Touch +

+
+ +
+
+
+ +
+
+

Email

+

+ We'll respond within 24 hours +

+
+
+ +
+
+ +
+
+

Phone

+

+ Available 24/7 for your convenience +

+
+
+ +
+
+ +
+
+

Location

+

+ Visit us at our hotel reception +

+
+
+
+ + {/* Google Maps */} +
+

+ Find Us +

+
+