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

View File

@@ -0,0 +1,405 @@
import React, { useState } from 'react';
import { Mail, Phone, MapPin, Send, User, MessageSquare } from 'lucide-react';
import { submitContactForm } from '../services/api/contactService';
import { toast } from 'react-toastify';
const ContactPage: React.FC = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
subject: '',
message: '',
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Please enter a valid email address';
}
if (!formData.subject.trim()) {
newErrors.subject = 'Subject is required';
}
if (!formData.message.trim()) {
newErrors.message = 'Message is required';
} else if (formData.message.trim().length < 10) {
newErrors.message = 'Message must be at least 10 characters long';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
try {
await submitContactForm(formData);
toast.success('Thank you for contacting us! We will get back to you soon.');
// Reset form
setFormData({
name: '',
email: '',
phone: '',
subject: '',
message: '',
});
setErrors({});
} catch (error: any) {
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
{/* Full-width hero section */}
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8 overflow-hidden relative">
{/* Decorative Elements */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-10 left-10 w-32 sm:w-48 h-32 sm:h-48 bg-[#d4af37] rounded-full blur-3xl"></div>
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[#c9a227] rounded-full blur-3xl"></div>
</div>
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4 sm:py-5 md:py-6 relative z-10">
<div className="max-w-2xl mx-auto text-center px-2">
<div className="flex justify-center mb-2 sm:mb-3 md:mb-4">
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#c9a227] rounded-xl blur-lg opacity-40 group-hover:opacity-60 transition-opacity duration-500"></div>
<div className="relative p-2 sm:p-2.5 md:p-3 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border-2 border-[#d4af37]/40 backdrop-blur-sm shadow-xl shadow-[#d4af37]/20 group-hover:border-[#d4af37]/60 transition-all duration-300">
<Mail className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[#d4af37] drop-shadow-lg" />
</div>
</div>
</div>
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2">
<span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent">
Contact Us
</span>
</h1>
<div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div>
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4">
Experience the pinnacle of hospitality. We're here to make your stay extraordinary.
</p>
</div>
</div>
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
</div>
{/* Full-width content area */}
<div className="w-full py-4 sm:py-6 md:py-8 lg:py-10 xl:py-12">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5 md:gap-6 lg:gap-7 xl:gap-8 2xl:gap-10 max-w-7xl mx-auto">
{/* Contact Info Section */}
<div className="lg:col-span-4">
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a]
rounded-xl sm:rounded-2xl border-2 border-[#d4af37]/30 p-5 sm:p-6 md:p-8 lg:p-10
shadow-2xl shadow-[#d4af37]/10 backdrop-blur-xl
relative overflow-hidden h-full group hover:border-[#d4af37]/50 transition-all duration-500">
{/* Subtle background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative z-10">
<div className="flex items-center gap-2 sm:gap-3 mb-6 sm:mb-7 md:mb-8">
<div className="w-0.5 sm:w-1 h-6 sm:h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227] rounded-full"></div>
<h2 className="text-xl sm:text-2xl md:text-3xl font-serif font-semibold
text-white tracking-tight">
Get in Touch
</h2>
</div>
<div className="space-y-5 sm:space-y-6 md:space-y-7">
<div className="flex items-start gap-3 sm:gap-4 md:gap-5 group/item hover:translate-x-1 transition-transform duration-300">
<div className="p-2.5 sm:p-3 md:p-4 bg-gradient-to-br from-[#d4af37]/20 to-[#d4af37]/10 rounded-lg sm:rounded-xl border border-[#d4af37]/40 flex-shrink-0 group-hover/item:bg-gradient-to-br group-hover/item:from-[#d4af37]/30 group-hover/item:to-[#d4af37]/20 group-hover/item:border-[#d4af37]/60 transition-all duration-300 shadow-lg shadow-[#d4af37]/10">
<Mail className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37] drop-shadow-lg" />
</div>
<div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Email</h3>
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
We'll respond within 24 hours
</p>
</div>
</div>
<div className="flex items-start gap-3 sm:gap-4 md:gap-5 group/item hover:translate-x-1 transition-transform duration-300">
<div className="p-2.5 sm:p-3 md:p-4 bg-gradient-to-br from-[#d4af37]/20 to-[#d4af37]/10 rounded-lg sm:rounded-xl border border-[#d4af37]/40 flex-shrink-0 group-hover/item:bg-gradient-to-br group-hover/item:from-[#d4af37]/30 group-hover/item:to-[#d4af37]/20 group-hover/item:border-[#d4af37]/60 transition-all duration-300 shadow-lg shadow-[#d4af37]/10">
<Phone className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37] drop-shadow-lg" />
</div>
<div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Phone</h3>
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
Available 24/7 for your convenience
</p>
</div>
</div>
<div className="flex items-start gap-3 sm:gap-4 md:gap-5 group/item hover:translate-x-1 transition-transform duration-300">
<div className="p-2.5 sm:p-3 md:p-4 bg-gradient-to-br from-[#d4af37]/20 to-[#d4af37]/10 rounded-lg sm:rounded-xl border border-[#d4af37]/40 flex-shrink-0 group-hover/item:bg-gradient-to-br group-hover/item:from-[#d4af37]/30 group-hover/item:to-[#d4af37]/20 group-hover/item:border-[#d4af37]/60 transition-all duration-300 shadow-lg shadow-[#d4af37]/10">
<MapPin className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37] drop-shadow-lg" />
</div>
<div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Location</h3>
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
Visit us at our hotel reception
</p>
</div>
</div>
</div>
{/* Google Maps */}
<div className="mt-6 sm:mt-7 md:mt-8 pt-6 sm:pt-7 md:pt-8 border-t border-[#d4af37]/30">
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-3 sm:mb-4 tracking-wide">
Find Us
</h3>
<div className="relative rounded-lg overflow-hidden border-2 border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/10 group hover:border-[#d4af37]/50 transition-all duration-300">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3022.1841582344433!2d-73.98784668436963!3d40.75889597932664!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x89c25855c6480299%3A0x55194ec5a1ae072e!2sTimes%20Square!5e0!3m2!1sen!2sus!4v1234567890123!5m2!1sen!2sus"
width="100%"
height="200"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="w-full h-40 sm:h-44 md:h-48 rounded-lg"
title="Hotel Location"
/>
</div>
</div>
<div className="mt-5 sm:mt-6 md:mt-7 pt-5 sm:pt-6 md:pt-7 border-t border-[#d4af37]/30">
<p className="text-gray-400 font-light text-xs sm:text-sm leading-relaxed tracking-wide">
Our team is here to help you with any questions about your stay,
bookings, or special requests. We're committed to exceeding your expectations.
</p>
</div>
</div>
</div>
</div>
{/* Contact Form Section */}
<div className="lg:col-span-8">
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a]
rounded-xl sm:rounded-2xl border-2 border-[#d4af37]/30 p-5 sm:p-6 md:p-8 lg:p-10
shadow-2xl shadow-[#d4af37]/10 backdrop-blur-xl
relative overflow-hidden">
{/* Subtle background pattern */}
<div className="absolute inset-0 opacity-5">
<div className="absolute top-0 right-0 w-48 sm:w-64 md:w-96 h-48 sm:h-64 md:h-96 bg-[#d4af37] rounded-full blur-3xl"></div>
</div>
<div className="relative z-10">
<div className="flex items-center gap-2 sm:gap-3 mb-6 sm:mb-7 md:mb-8">
<div className="w-0.5 sm:w-1 h-6 sm:h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227] rounded-full"></div>
<h2 className="text-xl sm:text-2xl md:text-3xl font-serif font-semibold
text-white tracking-tight">
Send Us a Message
</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6 md:space-y-7">
{/* Name Field */}
<div>
<label htmlFor="name" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
<span className="flex items-center gap-1.5 sm:gap-2">
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
Full Name <span className="text-[#d4af37] font-semibold">*</span>
</span>
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
text-white text-sm sm:text-base placeholder-gray-500/60
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
transition-all duration-300 hover:border-[#d4af37]/40
${errors.name ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
placeholder="Enter your full name"
/>
{errors.name && (
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.name}</p>
)}
</div>
{/* Email and Phone Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5 md:gap-6 lg:gap-7">
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
<span className="flex items-center gap-1.5 sm:gap-2">
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
Email <span className="text-[#d4af37] font-semibold">*</span>
</span>
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
text-white text-sm sm:text-base placeholder-gray-500/60
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
transition-all duration-300 hover:border-[#d4af37]/40
${errors.email ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
placeholder="your.email@example.com"
/>
{errors.email && (
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.email}</p>
)}
</div>
{/* Phone Field */}
<div>
<label htmlFor="phone" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
<span className="flex items-center gap-1.5 sm:gap-2">
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
Phone <span className="text-gray-500 text-xs">(Optional)</span>
</span>
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 border-[#d4af37]/30 rounded-lg
text-white text-sm sm:text-base placeholder-gray-500/60
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
transition-all duration-300 hover:border-[#d4af37]/40"
placeholder="+1 (555) 123-4567"
/>
</div>
</div>
{/* Subject Field */}
<div>
<label htmlFor="subject" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
<span className="flex items-center gap-1.5 sm:gap-2">
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
Subject <span className="text-[#d4af37] font-semibold">*</span>
</span>
</label>
<input
type="text"
id="subject"
name="subject"
value={formData.subject}
onChange={handleChange}
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
text-white text-sm sm:text-base placeholder-gray-500/60
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
transition-all duration-300 hover:border-[#d4af37]/40
${errors.subject ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
placeholder="What is this regarding?"
/>
{errors.subject && (
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.subject}</p>
)}
</div>
{/* Message Field */}
<div>
<label htmlFor="message" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
<span className="flex items-center gap-1.5 sm:gap-2">
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-[#d4af37] drop-shadow-lg" />
Message <span className="text-[#d4af37] font-semibold">*</span>
</span>
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows={6}
className={`w-full px-4 sm:px-5 py-3 sm:py-4 bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] border-2 rounded-lg
text-white text-sm sm:text-base placeholder-gray-500/60 resize-none
focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] focus:shadow-lg focus:shadow-[#d4af37]/20
transition-all duration-300 hover:border-[#d4af37]/40
${errors.message ? 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' : 'border-[#d4af37]/30'}`}
placeholder="Tell us more about your inquiry..."
/>
{errors.message && (
<p className="mt-1.5 sm:mt-2 text-xs text-red-400 font-light">{errors.message}</p>
)}
</div>
{/* Submit Button */}
<div className="pt-2 sm:pt-3 md:pt-4">
<button
type="submit"
disabled={loading}
className="group w-full sm:w-auto inline-flex items-center justify-center gap-2 sm:gap-3
bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]
text-[#0f0f0f] font-semibold
active:scale-[0.98]
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-500
tracking-wide text-sm sm:text-base
px-6 sm:px-8 md:px-10 py-3 sm:py-3.5 md:py-4 rounded-lg
shadow-2xl shadow-[#d4af37]/40 hover:shadow-[#d4af37]/60 hover:scale-[1.02]
relative overflow-hidden
touch-manipulation min-h-[44px] sm:min-h-[48px] md:min-h-[52px]"
>
<div className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/30 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
{loading ? (
<>
<div className="w-4 h-4 sm:w-5 sm:h-5 border-2 border-[#0f0f0f] border-t-transparent rounded-full animate-spin relative z-10" />
<span className="relative z-10">Sending...</span>
</>
) : (
<>
<Send className="w-4 h-4 sm:w-5 sm:h-5 relative z-10 group-hover:translate-x-1 transition-transform duration-300" />
<span className="relative z-10">Send Message</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ContactPage;

View File

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

View File

@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -206,10 +207,10 @@ const BookingManagementPage: React.FC = () => {
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">
{new Date(booking.check_in_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
{parseDateLocal(booking.check_in_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{new Date(booking.check_out_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{parseDateLocal(booking.check_out_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
@@ -355,11 +356,11 @@ const BookingManagementPage: React.FC = () => {
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-in Date</label>
<p className="text-base font-semibold text-slate-900">{new Date(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-out Date</label>
<p className="text-base font-semibold text-slate-900">{new Date(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
</div>

View File

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

View File

@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
interface ServiceItem {
service_name: string;
@@ -197,17 +198,17 @@ const CheckOutPage: React.FC = () => {
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Check-in:</span>
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
<span>{booking.check_in_date ? parseDateLocal(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Check-out:</span>
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
<span>{booking.check_out_date ? parseDateLocal(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Nights:</span>
<span>
{booking.check_in_date && booking.check_out_date
? Math.ceil((new Date(booking.check_out_date).getTime() - new Date(booking.check_in_date).getTime()) / (1000 * 60 * 60 * 24))
? Math.ceil((parseDateLocal(booking.check_out_date).getTime() - parseDateLocal(booking.check_in_date).getTime()) / (1000 * 60 * 60 * 24))
: 0} night(s)
</span>
</div>

View File

@@ -34,6 +34,7 @@ import Loading from '../../components/common/Loading';
import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const BookingDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -78,7 +79,12 @@ const BookingDetailPage: React.FC = () => {
response.success &&
response.data?.booking
) {
setBooking(response.data.booking);
const bookingData = response.data.booking;
// Debug: Log to see what we're receiving
console.log('Booking data:', bookingData);
console.log('Service usages:', (bookingData as any).service_usages);
console.log('Total price:', bookingData.total_price);
setBooking(bookingData);
} else {
throw new Error(
'Unable to load booking information'
@@ -162,7 +168,9 @@ const BookingDetailPage: React.FC = () => {
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
// Use parseDateLocal to handle date strings correctly
const date = parseDateLocal(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -172,6 +180,64 @@ const BookingDetailPage: React.FC = () => {
const formatPrice = (price: number) => formatCurrency(price);
// Calculate number of nights
const calculateNights = () => {
if (!booking) return 1;
const checkIn = parseDateLocal(booking.check_in_date);
const checkOut = parseDateLocal(booking.check_out_date);
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
return nights > 0 ? nights : 1;
};
// Calculate services total
const calculateServicesTotal = () => {
if (!booking) return 0;
// Check both service_usages and services (for backwards compatibility)
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
if (Array.isArray(serviceUsages) && serviceUsages.length > 0) {
return serviceUsages.reduce((sum: number, su: any) => {
return sum + (su.total_price || 0);
}, 0);
}
return 0;
};
// Get service usages for display
const getServiceUsages = () => {
if (!booking) return [];
// Check both service_usages and services (for backwards compatibility)
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
return Array.isArray(serviceUsages) ? serviceUsages : [];
};
// Calculate the actual room price per night from the booking
// This shows the price that was actually paid, not the current room price
const calculateRoomPricePerNight = () => {
if (!booking) return 0;
const nights = calculateNights();
const servicesTotal = calculateServicesTotal();
// Debug logging
console.log('Calculating room price:', {
total_price: booking.total_price,
servicesTotal,
nights,
roomTotal: booking.total_price - servicesTotal
});
// Calculate room total by subtracting services from total_price
const roomTotal = booking.total_price - servicesTotal;
// Return room price per night (the actual price paid for the room)
return nights > 0 ? roomTotal / nights : roomTotal;
};
// Calculate room total (price per night × nights)
const calculateRoomTotal = () => {
return calculateRoomPricePerNight() * calculateNights();
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
@@ -369,7 +435,7 @@ const BookingDetailPage: React.FC = () => {
Room Price
</p>
<p className="font-medium text-indigo-600">
{formatPrice(room?.price || roomType.base_price)}/night
{formatPrice(calculateRoomPricePerNight())}/night
</p>
</div>
</div>
@@ -461,21 +527,67 @@ const BookingDetailPage: React.FC = () => {
</div>
</div>
{/* Total Price */}
{/* Price Breakdown */}
<div className="border-t pt-4">
<div className="flex justify-between
items-center"
>
<span className="text-lg font-semibold
text-gray-900"
>
Total Payment
</span>
<span className="text-2xl font-bold
text-indigo-600"
>
{formatPrice(booking.total_price)}
</span>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Price Breakdown
</h3>
<div className="space-y-3">
{/* Room Price */}
<div className="flex justify-between items-center">
<div>
<p className="text-sm font-medium text-gray-900">
Room ({calculateNights()} night{calculateNights() !== 1 ? 's' : ''})
</p>
<p className="text-xs text-gray-500">
{formatPrice(calculateRoomPricePerNight())} per night
</p>
</div>
<span className="text-base font-semibold text-gray-900">
{formatPrice(calculateRoomTotal())}
</span>
</div>
{/* Services */}
{(() => {
const services = getServiceUsages();
console.log('Services to display:', services);
if (services.length > 0) {
return (
<>
{services.map((serviceUsage: any, index: number) => (
<div key={serviceUsage.id || index} className="flex justify-between items-center">
<div>
<p className="text-sm font-medium text-gray-900">
{serviceUsage.service_name || serviceUsage.name || 'Service'}
</p>
<p className="text-xs text-gray-500">
{formatPrice(serviceUsage.unit_price || serviceUsage.price || 0)} × {serviceUsage.quantity || 1}
</p>
</div>
<span className="text-base font-semibold text-gray-900">
{formatPrice(serviceUsage.total_price || (serviceUsage.unit_price || serviceUsage.price || 0) * (serviceUsage.quantity || 1))}
</span>
</div>
))}
</>
);
}
return null;
})()}
{/* Total */}
<div className="border-t pt-3 mt-3">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">
Total Payment
</span>
<span className="text-2xl font-bold text-indigo-600">
{formatPrice(booking.total_price)}
</span>
</div>
</div>
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import {
Calendar,
Users,
@@ -21,15 +22,18 @@ import {
Sparkles,
Star,
MapPin,
Plus,
Minus,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getRoomById, type Room } from
import { getRoomById, getRoomBookedDates, type Room } from
'../../services/api/roomService';
import {
createBooking,
checkRoomAvailability,
type BookingData,
} from '../../services/api/bookingService';
import { serviceService, Service } from '../../services/api';
import useAuthStore from '../../store/useAuthStore';
import {
bookingValidationSchema,
@@ -37,6 +41,7 @@ import {
} from '../../validators/bookingValidator';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDateLocal } from '../../utils/format';
const BookingPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -48,6 +53,9 @@ const BookingPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [services, setServices] = useState<Service[]>([]);
const [selectedServices, setSelectedServices] = useState<Array<{ service: Service; quantity: number }>>([]);
const [bookedDates, setBookedDates] = useState<Date[]>([]);
// Redirect if not authenticated
useEffect(() => {
@@ -61,13 +69,82 @@ const BookingPage: React.FC = () => {
}
}, [isAuthenticated, navigate, id]);
// Fetch room details
// Fetch room details and services
useEffect(() => {
if (id && isAuthenticated) {
fetchRoomDetails(Number(id));
fetchServices();
fetchBookedDates(Number(id));
}
}, [id, isAuthenticated]);
const fetchBookedDates = async (roomId: number) => {
try {
const response = await getRoomBookedDates(roomId);
// Check for both 'success' boolean and 'status: "success"' string
const isSuccess = response.success === true || (response as any).status === 'success';
if (isSuccess && response.data?.booked_dates) {
// Convert date strings to Date objects (normalized to midnight)
const dates = response.data.booked_dates.map((dateStr: string) => {
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
// Normalize to midnight to avoid timezone issues
date.setHours(0, 0, 0, 0);
date.setMinutes(0, 0, 0);
return date;
});
setBookedDates(dates);
}
} catch (err) {
console.error('Error fetching booked dates:', err);
// Don't show error, just log it - booked dates are not critical
}
};
// Helper function to check if a date is booked
const isDateBooked = (date: Date): boolean => {
if (!date || bookedDates.length === 0) return false;
const normalizedDate = new Date(date);
normalizedDate.setHours(0, 0, 0, 0);
return bookedDates.some(bookedDate => {
const normalizedBooked = new Date(bookedDate);
normalizedBooked.setHours(0, 0, 0, 0);
return normalizedDate.getTime() === normalizedBooked.getTime();
});
};
// Helper function to check if a date range includes any booked dates
const doesRangeIncludeBookedDates = (startDate: Date, endDate: Date): boolean => {
if (!startDate || !endDate || bookedDates.length === 0) return false;
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(0, 0, 0, 0);
let currentDate = new Date(start);
while (currentDate < end) {
if (isDateBooked(currentDate)) {
return true;
}
currentDate.setDate(currentDate.getDate() + 1);
}
return false;
};
const fetchServices = async () => {
try {
const response = await serviceService.getServices({
status: 'active',
limit: 100,
});
setServices(response.data.services || []);
} catch (err: any) {
console.error('Error fetching services:', err);
// Don't show error for services, just log it
}
};
const fetchRoomDetails = async (roomId: number) => {
try {
setLoading(true);
@@ -136,7 +213,14 @@ const BookingPage: React.FC = () => {
(room?.price && room.price > 0)
? room.price
: (room?.room_type?.base_price || 0);
const totalPrice = numberOfNights * roomPrice;
const roomTotal = numberOfNights * roomPrice;
// Calculate services total
const servicesTotal = selectedServices.reduce((sum, item) => {
return sum + (item.service.price * item.quantity);
}, 0);
const totalPrice = roomTotal + servicesTotal;
// Format price using currency context
const formatPrice = (price: number) => formatCurrency(price);
@@ -148,12 +232,35 @@ const BookingPage: React.FC = () => {
try {
setSubmitting(true);
const checkInDateStr = data.checkInDate
.toISOString()
.split('T')[0];
const checkOutDateStr = data.checkOutDate
.toISOString()
.split('T')[0];
// Format dates in local timezone to avoid timezone conversion issues
const checkInDateStr = formatDateLocal(data.checkInDate);
const checkOutDateStr = formatDateLocal(data.checkOutDate);
// Validate that selected dates are not booked
const checkIn = new Date(data.checkInDate);
checkIn.setHours(0, 0, 0, 0);
const checkOut = new Date(data.checkOutDate);
checkOut.setHours(0, 0, 0, 0);
// Check if check-in date is booked
if (isDateBooked(checkIn)) {
toast.error('Check-in date is already booked. Please select another date.');
return;
}
// Check if check-out date is booked (check-out date itself is not included in booking, but we should still validate)
// Actually, check-out date is not part of the booking, so we don't need to check it
// But we should check if any date in the range is booked
const selectedDates: Date[] = [];
let currentDate = new Date(checkIn);
while (currentDate < checkOut) {
if (isDateBooked(currentDate)) {
toast.error('The selected date range includes booked dates. Please select different dates.');
return;
}
selectedDates.push(new Date(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
}
// Step 1: Check room availability
const availability = await checkRoomAvailability(
@@ -184,6 +291,10 @@ const BookingPage: React.FC = () => {
email: data.email,
phone: data.phone,
},
services: selectedServices.map(item => ({
service_id: item.service.id,
quantity: item.quantity,
})),
};
// Step 3: Create booking
@@ -463,15 +574,70 @@ const BookingPage: React.FC = () => {
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={new Date()}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-in date"
excludeDates={bookedDates}
highlightDates={bookedDates.length > 0 ? bookedDates.map(date => {
// Ensure date is properly formatted for react-datepicker
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
normalized.setMinutes(0, 0, 0);
return normalized;
}) : []}
dayClassName={(date) => {
// Add custom class for booked dates to ensure they're highlighted in red
if (!date) return '';
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
normalized.setMinutes(0, 0, 0);
if (isDateBooked(normalized)) {
return 'react-datepicker__day--booked';
}
return '';
}}
filterDate={(date) => {
// Prevent selection of booked dates
if (isDateBooked(date)) {
return false;
}
// If check-out is already selected, prevent selecting check-in that would create invalid range
if (checkOutDate) {
const testCheckIn = date;
const testCheckOut = checkOutDate;
if (testCheckIn < testCheckOut && doesRangeIncludeBookedDates(testCheckIn, testCheckOut)) {
return false;
}
}
return true;
}}
onChange={(date) => {
// Prevent setting booked dates
if (date && isDateBooked(date)) {
toast.error('This date is already booked. Please select another date.');
return;
}
// If check-out date is already selected, validate the entire range
if (date && checkOutDate) {
const newCheckIn = date;
const newCheckOut = checkOutDate;
if (newCheckIn < newCheckOut) {
if (doesRangeIncludeBookedDates(newCheckIn, newCheckOut)) {
toast.error('The selected date range includes booked dates. Please select different dates.');
return;
}
}
}
field.onChange(date);
}}
className="w-full px-4 py-3
bg-[#0a0a0a] border border-[#d4af37]/20
rounded-lg text-white placeholder-gray-500
@@ -509,9 +675,6 @@ const BookingPage: React.FC = () => {
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={(date) =>
field.onChange(date)
}
minDate={
checkInDate || new Date()
}
@@ -520,6 +683,64 @@ const BookingPage: React.FC = () => {
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-out date"
excludeDates={bookedDates}
highlightDates={bookedDates.length > 0 ? bookedDates.map(date => {
// Ensure date is properly formatted for react-datepicker
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
normalized.setMinutes(0, 0, 0);
return normalized;
}) : []}
dayClassName={(date) => {
// Add custom class for booked dates to ensure they're highlighted in red
if (!date) return '';
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
normalized.setMinutes(0, 0, 0);
if (isDateBooked(normalized)) {
return 'react-datepicker__day--booked';
}
return '';
}}
filterDate={(date) => {
// Prevent selection of booked dates
if (isDateBooked(date)) {
return false;
}
// If check-in is already selected, prevent selecting check-out that would create invalid range
if (checkInDate) {
const testCheckIn = checkInDate;
const testCheckOut = date;
if (testCheckIn < testCheckOut && doesRangeIncludeBookedDates(testCheckIn, testCheckOut)) {
return false;
}
}
return true;
}}
onChange={(date) => {
// Prevent setting booked dates
if (date && isDateBooked(date)) {
toast.error('This date is already booked. Please select another date.');
return;
}
// If check-in date is already selected, validate the entire range
if (date && checkInDate) {
const newCheckIn = checkInDate;
const newCheckOut = date;
if (newCheckIn < newCheckOut) {
if (doesRangeIncludeBookedDates(newCheckIn, newCheckOut)) {
toast.error('The selected date range includes booked dates. Please select different dates.');
return;
}
}
}
field.onChange(date);
}}
className="w-full px-4 py-3
bg-[#0a0a0a] border border-[#d4af37]/20
rounded-lg text-white placeholder-gray-500
@@ -606,6 +827,137 @@ const BookingPage: React.FC = () => {
</div>
</div>
{/* Services Selection */}
{services.length > 0 && (
<div className="border-t border-[#d4af37]/20 pt-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Sparkles className="w-5 h-5 text-[#d4af37]" />
</div>
<h2
className="text-2xl font-serif font-semibold
text-white tracking-wide"
>
Additional Services
</h2>
</div>
<p className="text-gray-400 text-sm mb-6 font-light tracking-wide">
Enhance your stay with our premium services (optional)
</p>
<div className="space-y-4">
{services.map((service) => {
const selectedItem = selectedServices.find(
item => item.service.id === service.id
);
const quantity = selectedItem?.quantity || 0;
return (
<div
key={service.id}
className="bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
border border-[#d4af37]/20 rounded-lg p-5
hover:border-[#d4af37]/40 transition-all duration-300"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="text-white font-semibold mb-1">
{service.name}
</h3>
{service.description && (
<p className="text-gray-400 text-sm mb-2">
{service.description}
</p>
)}
<p className="text-[#d4af37] font-bold">
{formatPrice(service.price)} / {service.unit || 'unit'}
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
if (quantity > 0) {
if (quantity === 1) {
setSelectedServices(prev =>
prev.filter(item => item.service.id !== service.id)
);
} else {
setSelectedServices(prev =>
prev.map(item =>
item.service.id === service.id
? { ...item, quantity: item.quantity - 1 }
: item
)
);
}
}
}}
disabled={quantity === 0}
className="w-8 h-8 flex items-center justify-center
bg-[#1a1a1a] border border-[#d4af37]/20
rounded text-[#d4af37] hover:bg-[#d4af37]/10
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Minus className="w-4 h-4" />
</button>
<span className="text-white font-semibold w-8 text-center">
{quantity}
</span>
<button
type="button"
onClick={() => {
if (quantity === 0) {
setSelectedServices(prev => [
...prev,
{ service, quantity: 1 }
]);
} else {
setSelectedServices(prev =>
prev.map(item =>
item.service.id === service.id
? { ...item, quantity: item.quantity + 1 }
: item
)
);
}
}}
className="w-8 h-8 flex items-center justify-center
bg-[#1a1a1a] border border-[#d4af37]/20
rounded text-[#d4af37] hover:bg-[#d4af37]/10
transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{quantity > 0 && (
<p className="text-[#d4af37] font-bold">
{formatPrice(service.price * quantity)}
</p>
)}
</div>
</div>
);
})}
</div>
{selectedServices.length > 0 && (
<div className="mt-6 pt-6 border-t border-[#d4af37]/20">
<div className="flex items-center justify-between">
<span className="text-gray-400 font-light">Services Total:</span>
<span className="text-xl font-bold text-[#d4af37]">
{formatPrice(servicesTotal)}
</span>
</div>
</div>
)}
</div>
)}
{/* Payment Method */}
<div className="border-t border-[#d4af37]/20 pt-8">
<div className="flex items-center gap-3 mb-6">
@@ -869,6 +1221,45 @@ const BookingPage: React.FC = () => {
</div>
)}
{numberOfNights > 0 && (
<div className="flex justify-between items-center">
<span className="text-gray-400 font-light tracking-wide text-sm">
Room Total
</span>
<span className="font-light text-white">
{formatPrice(roomTotal)}
</span>
</div>
)}
{selectedServices.length > 0 && (
<>
<div className="border-t border-[#d4af37]/20 pt-3 mt-3">
<p className="text-gray-400 font-light tracking-wide text-sm mb-2">
Services:
</p>
{selectedServices.map((item) => (
<div key={item.service.id} className="flex justify-between items-center mb-1">
<span className="text-gray-500 font-light tracking-wide text-xs">
{item.service.name} (×{item.quantity})
</span>
<span className="font-light text-white text-xs">
{formatPrice(item.service.price * item.quantity)}
</span>
</div>
))}
<div className="flex justify-between items-center mt-2 pt-2 border-t border-[#d4af37]/10">
<span className="text-gray-400 font-light tracking-wide text-sm">
Services Total
</span>
<span className="font-light text-white">
{formatPrice(servicesTotal)}
</span>
</div>
</div>
</>
)}
<div
className="border-t border-[#d4af37]/20 pt-4 flex
justify-between items-center"

View File

@@ -32,6 +32,7 @@ import { confirmBankTransfer } from
'../../services/api/paymentService';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const BookingSuccessPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -115,7 +116,9 @@ const BookingSuccessPage: React.FC = () => {
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
// Use parseDateLocal to handle date strings correctly
const date = parseDateLocal(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',

View File

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

View File

@@ -26,6 +26,7 @@ import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import EmptyState from '../../components/common/EmptyState';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
const MyBookingsPage: React.FC = () => {
const navigate = useNavigate();
@@ -168,7 +169,9 @@ const MyBookingsPage: React.FC = () => {
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
// Use parseDateLocal to handle date strings correctly
const date = parseDateLocal(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { getRooms } from '../../services/api/roomService';
import type { Room } from '../../services/api/roomService';
@@ -6,13 +6,15 @@ import RoomFilter from '../../components/rooms/RoomFilter';
import RoomCard from '../../components/rooms/RoomCard';
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
import Pagination from '../../components/rooms/Pagination';
import { ArrowLeft, Sparkles, Hotel } from 'lucide-react';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterRef = useRef<HTMLDivElement>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
@@ -20,6 +22,15 @@ const RoomListPage: React.FC = () => {
totalPages: 1,
});
// Scroll to filter when opened on mobile
useEffect(() => {
if (isFilterOpen && filterRef.current && window.innerWidth < 1280) {
setTimeout(() => {
filterRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}, [isFilterOpen]);
// Fetch rooms based on URL params
useEffect(() => {
const fetchRooms = async () => {
@@ -66,51 +77,97 @@ const RoomListPage: React.FC = () => {
}, [searchParams]);
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2
text-[#d4af37]/80 hover:text-[#d4af37]
mb-10 transition-all duration-300
group font-light tracking-wide"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span>Back to home</span>
</Link>
{/* Page Header */}
<div className="mb-12 text-center">
<div className="inline-flex items-center justify-center gap-3 mb-6">
<div className="p-3 bg-[#d4af37]/10 rounded-xl border border-[#d4af37]/30">
<Hotel className="w-8 h-8 text-[#d4af37]" />
</div>
</div>
<h1 className="text-5xl font-serif font-semibold
text-white mb-4 tracking-tight leading-tight
bg-gradient-to-r from-white via-[#d4af37] to-white
bg-clip-text text-transparent"
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
{/* Full-width hero section */}
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-6 sm:py-7 md:py-8 lg:py-10">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37]
active:scale-95
mb-4 sm:mb-5 md:mb-6 transition-all duration-300
group font-medium tracking-wide text-sm
px-4 py-2 rounded-sm
shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40
touch-manipulation"
>
Our Rooms & Suites
</h1>
<p className="text-gray-400 font-light tracking-wide text-lg max-w-2xl mx-auto">
Discover our collection of luxurious accommodations,
each designed to provide an exceptional stay
</p>
</div>
<ArrowLeft className="w-4 h-4 sm:w-4 sm:h-4 group-hover:-translate-x-1 transition-transform" />
<span>Back to home</span>
</Link>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-10">
<aside className="lg:col-span-1">
<div className="sticky top-6">
<RoomFilter />
{/* Page Header */}
<div className="text-center max-w-3xl mx-auto px-2">
<div className="inline-flex items-center justify-center gap-2 mb-3 sm:mb-4">
<div className="p-2 sm:p-2.5 bg-[#d4af37]/10 rounded-lg border border-[#d4af37]/30 backdrop-blur-sm">
<Hotel className="w-5 h-5 sm:w-5 sm:h-5 text-[#d4af37]" />
</div>
</div>
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold
text-white mb-2 sm:mb-3 tracking-tight leading-tight px-2
bg-gradient-to-r from-white via-[#d4af37] to-white
bg-clip-text text-transparent"
>
Our Rooms & Suites
</h1>
<p className="text-gray-400 font-light tracking-wide text-xs sm:text-sm md:text-base max-w-xl mx-auto px-2 sm:px-4 leading-relaxed">
Discover our collection of luxurious accommodations,
each designed to provide an exceptional stay
</p>
</div>
</div>
</div>
{/* Full-width content area */}
<div className="w-full py-4 sm:py-5 md:py-6 lg:py-8">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
<div className="grid grid-cols-1 xl:grid-cols-12 gap-3 sm:gap-4 md:gap-5 lg:gap-6 xl:gap-7">
{/* Mobile Filter Toggle Button */}
<div className="xl:hidden order-1 mb-4">
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className="w-full bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
border border-[#d4af37]/30 rounded-xl p-4
backdrop-blur-xl shadow-lg shadow-[#d4af37]/10
flex items-center justify-between gap-3
hover:border-[#d4af37]/50 hover:shadow-xl hover:shadow-[#d4af37]/20
transition-all duration-300 touch-manipulation"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-[#d4af37]/10 rounded-lg border border-[#d4af37]/30">
<Filter className="w-5 h-5 text-[#d4af37]" />
</div>
<span className="text-white font-medium tracking-wide text-base">
Filters
</span>
</div>
{isFilterOpen ? (
<ChevronUp className="w-5 h-5 text-[#d4af37]" />
) : (
<ChevronDown className="w-5 h-5 text-[#d4af37]" />
)}
</button>
</div>
{/* Filter Sidebar - Collapsible on mobile, sidebar on desktop */}
<aside
ref={filterRef}
className={`xl:col-span-3 order-2 xl:order-1 mb-4 sm:mb-5 md:mb-6 xl:mb-0 transition-all duration-300 ${
isFilterOpen ? 'block' : 'hidden xl:block'
}`}
>
<div className="xl:sticky xl:top-4 xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto">
<RoomFilter />
</div>
</aside>
<main className="lg:col-span-3">
{/* Main Content - Full width on mobile, 9 columns on desktop */}
<main className="xl:col-span-9 order-3 xl:order-2">
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-2 gap-8"
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
gap-3 sm:gap-4 md:gap-5 lg:gap-6"
>
{Array.from({ length: 6 }).map((_, index) => (
<RoomCardSkeleton key={index} />
@@ -120,14 +177,14 @@ const RoomListPage: React.FC = () => {
{error && !loading && (
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-12 text-center
border border-red-500/30 rounded-xl p-6 sm:p-8 md:p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10"
>
<div className="inline-flex items-center justify-center w-20 h-20
bg-red-500/20 rounded-full mb-6 border border-red-500/30"
<div className="inline-flex items-center justify-center w-14 h-14 sm:w-16 sm:h-16 md:w-20 md:h-20
bg-red-500/20 rounded-full mb-4 sm:mb-5 md:mb-6 border border-red-500/30"
>
<svg
className="w-10 h-10 text-red-400"
className="w-7 h-7 sm:w-8 sm:h-8 md:w-10 md:h-10 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -141,13 +198,14 @@ const RoomListPage: React.FC = () => {
/>
</svg>
</div>
<p className="text-red-300 font-light text-lg mb-6 tracking-wide">{error}</p>
<p className="text-red-300 font-light text-sm sm:text-base md:text-lg mb-4 sm:mb-5 md:mb-6 tracking-wide px-2">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 shadow-lg shadow-[#d4af37]/30"
className="px-5 sm:px-6 md:px-8 py-2.5 sm:py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
transition-all duration-300 shadow-lg shadow-[#d4af37]/30
touch-manipulation min-h-[44px]"
>
Try Again
</button>
@@ -156,28 +214,29 @@ const RoomListPage: React.FC = () => {
{!loading && !error && rooms.length === 0 && (
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
border border-[#d4af37]/20 rounded-xl p-16 text-center
border border-[#d4af37]/20 rounded-xl p-8 sm:p-10 md:p-12 lg:p-16 text-center
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
>
<div className="inline-flex items-center justify-center w-24 h-24
bg-[#d4af37]/10 rounded-2xl mb-8 border border-[#d4af37]/30"
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24
bg-[#d4af37]/10 rounded-2xl mb-4 sm:mb-5 md:mb-6 lg:mb-8 border border-[#d4af37]/30"
>
<Hotel className="w-12 h-12 text-[#d4af37]" />
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 text-[#d4af37]" />
</div>
<h3 className="text-2xl font-serif font-semibold
text-white mb-4 tracking-wide"
<h3 className="text-lg sm:text-xl md:text-2xl font-serif font-semibold
text-white mb-3 sm:mb-4 tracking-wide px-2"
>
No matching rooms found
</h3>
<p className="text-gray-400 font-light tracking-wide mb-8 text-lg">
<p className="text-gray-400 font-light tracking-wide mb-5 sm:mb-6 md:mb-8 text-sm sm:text-base md:text-lg px-2">
Please try adjusting the filters or search differently
</p>
<button
onClick={() => window.location.href = '/rooms'}
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 shadow-lg shadow-[#d4af37]/30"
className="px-5 sm:px-6 md:px-8 py-2.5 sm:py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide text-sm sm:text-base
hover:from-[#f5d76e] hover:to-[#d4af37] active:scale-95
transition-all duration-300 shadow-lg shadow-[#d4af37]/30
touch-manipulation min-h-[44px]"
>
Clear Filters
</button>
@@ -187,15 +246,16 @@ const RoomListPage: React.FC = () => {
{!loading && !error && rooms.length > 0 && (
<>
{/* Results Count */}
<div className="mb-6 flex items-center justify-between">
<p className="text-gray-400 font-light tracking-wide">
<div className="mb-3 sm:mb-4 md:mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-3">
<p className="text-gray-400 font-light tracking-wide text-xs sm:text-sm md:text-base">
Showing <span className="text-[#d4af37] font-medium">{rooms.length}</span> of{' '}
<span className="text-[#d4af37] font-medium">{pagination.total}</span> rooms
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-2 gap-8 mb-10"
{/* Responsive grid: 1 col mobile, 2 cols tablet, 3 cols desktop */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
gap-3 sm:gap-4 md:gap-5 lg:gap-6 mb-4 sm:mb-5 md:mb-6"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
@@ -203,17 +263,18 @@ const RoomListPage: React.FC = () => {
</div>
{pagination.totalPages > 1 && (
<div className="mt-10 pt-8 border-t border-[#d4af37]/20">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
<div className="mt-4 sm:mt-5 md:mt-6 pt-3 sm:pt-4 border-t border-[#d4af37]/20">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</div>
)}
</>
)}
</main>
</div>
</div>
</div>
</div>
);

View File

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