From a1bd576540370a6979ec3cd27d3a017062e3cee3 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Mon, 17 Nov 2025 23:50:14 +0200 Subject: [PATCH] updates --- Backend/src/__pycache__/main.cpython-312.pyc | Bin 13293 -> 13744 bytes Backend/src/main.py | 8 +- Backend/src/models/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 1227 -> 1468 bytes .../service_booking.cpython-312.pyc | Bin 0 -> 4377 bytes .../models/__pycache__/user.cpython-312.pyc | Bin 2171 -> 2239 bytes Backend/src/models/service_booking.py | 78 ++++ Backend/src/models/user.py | 1 + .../booking_routes.cpython-312.pyc | Bin 32254 -> 37523 bytes .../contact_routes.cpython-312.pyc | Bin 0 -> 7717 bytes .../__pycache__/room_routes.cpython-312.pyc | Bin 34072 -> 35892 bytes .../service_booking_routes.cpython-312.pyc | Bin 0 -> 16450 bytes Backend/src/routes/booking_routes.py | 154 ++++++- Backend/src/routes/contact_routes.py | 195 ++++++++ Backend/src/routes/room_routes.py | 53 +++ Backend/src/routes/service_booking_routes.py | 419 +++++++++++++++++ .../invoice_service.cpython-312.pyc | Bin 18200 -> 18819 bytes Backend/src/services/invoice_service.py | 59 ++- Frontend/src/App.tsx | 5 + Frontend/src/components/layout/Header.tsx | 21 + Frontend/src/components/rooms/RoomCard.tsx | 104 +++-- .../src/components/rooms/RoomCardSkeleton.tsx | 32 +- .../src/components/rooms/RoomCarousel.tsx | 216 +++++++++ Frontend/src/components/rooms/RoomFilter.tsx | 27 +- Frontend/src/components/rooms/index.ts | 1 + Frontend/src/main.tsx | 1 + Frontend/src/pages/ContactPage.tsx | 405 +++++++++++++++++ Frontend/src/pages/HomePage.tsx | 187 +++----- .../src/pages/admin/BookingManagementPage.tsx | 9 +- Frontend/src/pages/admin/CheckInPage.tsx | 5 +- Frontend/src/pages/admin/CheckOutPage.tsx | 7 +- .../src/pages/customer/BookingDetailPage.tsx | 146 +++++- Frontend/src/pages/customer/BookingPage.tsx | 421 +++++++++++++++++- .../src/pages/customer/BookingSuccessPage.tsx | 5 +- .../src/pages/customer/FullPaymentPage.tsx | 5 +- .../src/pages/customer/MyBookingsPage.tsx | 5 +- Frontend/src/pages/customer/RoomListPage.tsx | 201 ++++++--- .../src/pages/customer/SearchResultsPage.tsx | 3 +- Frontend/src/services/api/bookingService.ts | 4 + Frontend/src/services/api/contactService.ts | 29 ++ Frontend/src/services/api/roomService.ts | 10 + Frontend/src/styles/datepicker.css | 93 +++- Frontend/src/utils/format.ts | 41 +- 43 files changed, 2598 insertions(+), 359 deletions(-) create mode 100644 Backend/src/models/__pycache__/service_booking.cpython-312.pyc create mode 100644 Backend/src/models/service_booking.py create mode 100644 Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc create mode 100644 Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc create mode 100644 Backend/src/routes/contact_routes.py create mode 100644 Backend/src/routes/service_booking_routes.py create mode 100644 Frontend/src/components/rooms/RoomCarousel.tsx create mode 100644 Frontend/src/pages/ContactPage.tsx create mode 100644 Frontend/src/services/api/contactService.ts diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index 214e3ce3e1de04ca39020337387fb263a8475ac0..bc96cf665ab74a191e185482f5a1d024a52e2eba 100644 GIT binary patch delta 462 zcmaExz9F0MG%qg~0}!xHk6GwB4JS^H+~TO>+>)r0N!*f?*Yk)@Zs1bg zyqEJ66Jzn@2V9nnB_MM1dLBKN$@e+LfPx!%e1U=+c+4l?6;NS(J()>RleH|>FYEPW z13^vU^3;kH-)s#AhN3Ac3@N-hl~I+Ga|E4DtC$#4<5H_q{HnMa7*;caT*AN*RSgua z%=!W0A(JWosq$IBCh>|g{s9t``Gv&9{~^mm*b^AD7$^Uimzo?RB*|DkIZr5(nWHCT z^C_WHK1PAb4vKjKV#TRNWtqvT@k#ml*_nCilb0zraNgoe&d)1JOfK0huVl-_+5|N0 zq}0K z(|4Hs!H{!uoZ;Nb+l@ql>^qY$8p=;TW3-O((&TDmEynQ4%Z!ag)9e_Y*|DZ8F+XP( MNtb1QE(>A<0InU2xc~qF delta 383 zcmdmx{WhKNG%qg~0}vdyk!94WGNv{){<1etml(GgfwMKQ_E6(vo#nPiYBKpr10jHN0kFgd7 zPc2K$D=7k*QPey+Oec-&6p#t>L~+pOV>%WrlbH?oOulKjeDW#-hsigMmQ9{-d~tHW n$vVc1lkH5k82u+_ni`3w7&1IDWK9)ieqtt)D!}|y0K^6W5q4}J 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 bad4ebeb314bd3593fe2ddb9202f47f94fafd459..b8a9853dec24ddc996d4401b7c6f99ab0254c7f2 100644 GIT binary patch delta 610 zcmZut&2AGh7+f!*C28~1CQUX!P1Dk*O$aR<5O;1IfU4z!4_$3{O={KOI`TR}5r-%z z&eh(yAb19D@CJPW7K!9`>M)>(IarK&W7x&J zmanlr=;E&B>uetm@PXxJ)`LFoTV7#LV1Ty=P$sSM;expNn(|GRI9yx>s}sqC@f zkp4tpQ8Rt#(rw}C4T`#fhJmJmmVr$TZ+p+vU+%Y3F}ZE)pWoI^=WctZvLpQ#mz2k% zti!4lUXV~vZaM2HP51es9sNmPw^^f8!lK_wA2BeTIcqS9x4Xj`d# zX#Ph05oa`<+LEfi@MxU=d9cGw=0*IIGs%i@Jj`~$WTu`K`q delta 398 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 R3Q92gGIp4JWdPA&WdI8-G%)}G delta 117 zcmdll_*;PYG%qg~0}!acm&%;Jk(ZT`(PFbOqa_ndrM#xX<`m{hjLd$TikmrEjTtBV zu>~=DZ(hje!^qO;S>!bNKYM|cBT#RV6NqpI62CZXa`RJ4b5iY!+$VQ(s0xZR#xZu7 Kd}RR9U}XTvoE;1R 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 dae85e6de0acd01b0cac13c76d78e59321607df0..ec9b477c05d2d761cbe76037b62338a0ecaa8425 100644 GIT binary patch delta 15291 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�%EcGz~piD;$^%Mc}X{ z46y%OSTl`dzemoDgf{gPAZT7eWuC+2>li{loNCs(Mc;(1}D=OOzXVd;YNtO>;%~?P5 zR;Zo|FR+Xo92u$xqp7X|!t zs}}`3d0T)#8K7QW2BAM zlevc~Y|6OeA;pX>nSl%*s!Yrn>29#l183XxWlblg_?iW=xCg=^f)jC?Pv4L}%-WCS zIFbP*bg(Tm2b-FE6Z4fwsENFN)?pOVeb!Orup)69(B(s`p%v!dph|Jsh(m|P(aqxI z#~SFMiR0yfk{ZeNC(UwN@qa3*;d8t|A{Fsbb$H zB}W&lTCw$?ko%54%f3z6V~*wg-=d@(NZOV?1f}nN=>*h+1uBqKVk!NI5P-wiFkUR+ z`6|+L%$1mfxfo;8Dt>4L|85LJ2)_UcU209peHF=BAZf+$P*i|F9SigeJf21IxK)7D zJfF~ifX9%LmJg2zJkEF?op%8)9H26;i@Ta7aMU{z@-OA#uUg>)gkfaSLh_Htmdby} zv|OqCQa{@gRI9P@N;k%5jzHzh~r kHXN5IkpUfxV@gd~4XSIAMRnVhj7kR-#2Wv51-9@10G2A&F#rGn delta 10230 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 TM+}B2<3)(SpKmgGNQyrJE>v6e delta 169 zcmdlogK0(=6W?iGUM>b8h&Pqa;thy+J6W?ykMaHFh^8R6FF=7iOq(|}J!fQenLMNA72}7= z&8>G8S2BDC$}ki~1BqW8Ho5sJr8%i~MVyQb3_!$CoVU5H&4qO`Q;!}C2T%Y2YZWoR 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 0000000000000000000000000000000000000000..4940abc29cb85daca0cb21529ebb95e3014bf3f6 GIT binary patch literal 16450 zcmdseX>c1ymSzEk`#wm3I0%Bbz*C|Q>!9wFvPIF7dfKKB8bm=PAb?O8ik5^1-5$3i zhU!&>rRa{3w5 z^b~cIVkkzJqw=~*U0y$_&(o81-Y{v

loBm^V$D^5#i1zNd4RymiuwX+zGIw@unG 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 K>Jf?8!2ba_9b(G> literal 0 HcmV?d00001 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 652abef1fad6e8bad0d6cd3e2e2d5dfd3ae5ba05..31adb07604d08dbeb05d731bafd443136a214661 100644 GIT binary patch delta 4043 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 a(y`^mcnG~u+7Hf{&nS7_Hyj{>^#1`H2;aB> 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 +

+
+