ae6ea0ea46
- Remove Reserve button from rooms page - Calendar modal now supports direct reservation for guests - Message modal works for guests (asks for name, contact method, contact) - Add guest_name, guest_contact_method, guest_contact to reservations and messages tables - LEFT JOIN users in reservations GET to include guest reservations - Auto-fill form for logged-in users, show input fields for guests
909 lines
43 KiB
TypeScript
909 lines
43 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import { formatDisplayDate } from '@/lib/date';
|
|
import RoomCalendar from '@/components/RoomCalendar';
|
|
import OptimizedImage from '@/components/OptimizedImage';
|
|
|
|
interface Room {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
price: string;
|
|
photos: string[];
|
|
featured_photo: string | null;
|
|
active?: boolean;
|
|
amenities: Array<{ id: number; name: string; icon_svg: string }>;
|
|
}
|
|
|
|
interface RoomComment {
|
|
id: number;
|
|
room_id: number;
|
|
photo_url: string | null;
|
|
user_id: number;
|
|
first_name: string;
|
|
last_initial: string | null;
|
|
text: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface User {
|
|
id: number;
|
|
username: string;
|
|
role: 'admin' | 'user';
|
|
first_name: string | null;
|
|
last_name: string | null;
|
|
email?: string;
|
|
comments_disabled: boolean;
|
|
}
|
|
|
|
interface RoomLike {
|
|
room_id: number;
|
|
user_id: number;
|
|
created_at: string;
|
|
}
|
|
|
|
const gold = '#E8A849';
|
|
const navy = '#010D1E';
|
|
const cream = '#f5f0e8';
|
|
const warm = '#a6683c';
|
|
|
|
export default function RoomsPage() {
|
|
const [rooms, setRooms] = useState<Room[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [commentsByRoom, setCommentsByRoom] = useState<Record<number, RoomComment[]>>({});
|
|
const [likesByRoom, setLikesByRoom] = useState<Record<number, number>>({});
|
|
const [userLikesByRoom, setUserLikesByRoom] = useState<Record<number, boolean>>({});
|
|
const [expandedRoom, setExpandedRoom] = useState<number | null>(null);
|
|
const [activePhotoByRoom, setActivePhotoByRoom] = useState<Record<number, number>>({});
|
|
const [draft, setDraft] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({});
|
|
|
|
// Action modal states
|
|
const [activeModal, setActiveModal] = useState<'reserve' | 'message' | 'calendar' | 'review' | null>(null);
|
|
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
|
|
const [reservationDates, setReservationDates] = useState({ checkIn: '', checkOut: '' });
|
|
const [messageText, setMessageText] = useState('');
|
|
const [reviewText, setReviewText] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [guestForm, setGuestForm] = useState({ name: '', contactMethod: 'email', contact: '' });
|
|
|
|
useEffect(() => {
|
|
fetch('/api/rooms')
|
|
.then(async (res) => {
|
|
if (!res.ok) throw new Error('Failed to load rooms');
|
|
const json = await res.json();
|
|
setRooms(json.data || []);
|
|
json.data?.forEach((r: Room) => {
|
|
loadComments(r.id);
|
|
loadLikes(r.id);
|
|
});
|
|
})
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
|
|
fetch('/api/auth/me', { credentials: 'same-origin' })
|
|
.then((r) => r.json())
|
|
.then((j) => setUser(j.user || null))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const loadComments = async (roomId: number) => {
|
|
try {
|
|
setLoadingComments((prev) => ({ ...prev, [roomId]: true }));
|
|
const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' });
|
|
const json = await res.json().catch(() => ({ data: [] }));
|
|
setCommentsByRoom((prev) => ({ ...prev, [roomId]: json.data || [] }));
|
|
} catch { /* ignore */ }
|
|
finally { setLoadingComments((prev) => ({ ...prev, [roomId]: false })); }
|
|
};
|
|
|
|
const loadLikes = async (roomId: number) => {
|
|
try {
|
|
const res = await fetch(`/api/rooms/${roomId}/likes`, { credentials: 'same-origin' });
|
|
const json = await res.json().catch(() => ({ count: 0 }));
|
|
setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || 0 }));
|
|
setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !!json.user_liked }));
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
const postComment = async (roomId: number) => {
|
|
if (!draft.trim() || sending) return;
|
|
setSending(true);
|
|
const room = rooms.find(r => r.id === roomId);
|
|
if (!room) return;
|
|
const photo = getPhoto(room, activePhotoByRoom[roomId] || 0);
|
|
await fetch(`/api/rooms/${roomId}/comments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ text: draft.trim(), photo_url: photo }),
|
|
}).catch(() => {});
|
|
setDraft('');
|
|
await loadComments(roomId);
|
|
setSending(false);
|
|
};
|
|
|
|
const deleteComment = async (roomId: number, commentId: number) => {
|
|
await fetch(`/api/admin/comments/${commentId}`, { method: 'DELETE', credentials: 'same-origin' });
|
|
await loadComments(roomId);
|
|
};
|
|
|
|
const toggleLike = async (roomId: number) => {
|
|
if (!user) {
|
|
alert('Log in to like rooms');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/rooms/${roomId}/likes`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
});
|
|
if (res.ok) {
|
|
const json = await res.json().catch(() => ({}));
|
|
setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || (likesByRoom[roomId] || 0) + (userLikesByRoom[roomId] ? -1 : 1) }));
|
|
setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !userLikesByRoom[roomId] }));
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
const getPhoto = (room: Room, index: number): string | null => {
|
|
if (!room.photos || room.photos.length === 0) return null;
|
|
const photo = room.photos[Math.min(index, room.photos.length - 1)];
|
|
if (!photo) return null;
|
|
// If it's a URL with id= param, extract id; if it's base64, return as-is
|
|
if (photo.includes('id=')) {
|
|
return photo;
|
|
}
|
|
return photo;
|
|
};
|
|
|
|
const getPhotoId = (photo: string): number | null => {
|
|
if (!photo) return null;
|
|
if (photo.includes('id=')) {
|
|
const match = photo.match(/id=(\d+)/);
|
|
return match ? parseInt(match[1]) : null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getActiveIndex = (room: Room): number => {
|
|
return activePhotoByRoom[room.id] ?? 0;
|
|
};
|
|
|
|
const openModal = (type: 'reserve' | 'message' | 'calendar' | 'review', room: Room) => {
|
|
setSelectedRoom(room);
|
|
setActiveModal(type);
|
|
setReservationDates({ checkIn: '', checkOut: '' });
|
|
setMessageText('');
|
|
setReviewText('');
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setActiveModal(null);
|
|
setSelectedRoom(null);
|
|
};
|
|
|
|
const handleReserve = async () => {
|
|
if (!selectedRoom || !reservationDates.checkIn || !reservationDates.checkOut) {
|
|
alert('Please select dates');
|
|
return;
|
|
}
|
|
|
|
// For guests, require name and contact
|
|
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
|
|
alert('Please provide your name and contact info');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch('/api/reservations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
room_id: selectedRoom.id,
|
|
check_in: reservationDates.checkIn,
|
|
check_out: reservationDates.checkOut,
|
|
...(user ? {} : {
|
|
guest_name: guestForm.name.trim(),
|
|
guest_contact_method: guestForm.contactMethod,
|
|
guest_contact: guestForm.contact.trim(),
|
|
}),
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
alert('Reservation request sent! You will receive a confirmation shortly.');
|
|
closeModal();
|
|
setReservationDates({ checkIn: '', checkOut: '' });
|
|
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.error || 'Failed to create reservation');
|
|
}
|
|
} catch {
|
|
alert('Failed to create reservation');
|
|
}
|
|
setSubmitting(false);
|
|
};
|
|
|
|
const handleSendMessage = async () => {
|
|
if (!selectedRoom || !messageText.trim()) {
|
|
alert('Please enter a message');
|
|
return;
|
|
}
|
|
|
|
// For guests, require name and contact
|
|
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
|
|
alert('Please provide your name and contact info');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch('/api/messages', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
room_id: selectedRoom.id,
|
|
message: messageText.trim(),
|
|
...(user ? {} : {
|
|
guest_name: guestForm.name.trim(),
|
|
guest_contact_method: guestForm.contactMethod,
|
|
guest_contact: guestForm.contact.trim(),
|
|
}),
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
alert('Message sent to our team!');
|
|
closeModal();
|
|
setMessageText('');
|
|
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.error || 'Failed to send message');
|
|
}
|
|
} catch {
|
|
alert('Failed to send message');
|
|
}
|
|
setSubmitting(false);
|
|
};
|
|
|
|
const handleReview = async () => {
|
|
if (!selectedRoom || !reviewText.trim() || !user) {
|
|
alert('Please log in and enter a review');
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch(`/api/rooms/${selectedRoom.id}/comments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ text: reviewText.trim(), status: 'pending' }),
|
|
});
|
|
if (res.ok) {
|
|
alert('Review submitted for approval');
|
|
closeModal();
|
|
loadComments(selectedRoom.id);
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.error || 'Failed to submit review');
|
|
}
|
|
} catch {
|
|
alert('Failed to submit review');
|
|
}
|
|
setSubmitting(false);
|
|
};
|
|
|
|
const displayedRooms = rooms.filter((r) => r.active !== false);
|
|
|
|
return (
|
|
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}>
|
|
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms & Suites</h1>
|
|
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
|
Comfortable stays with views of the cloud forest.
|
|
</p>
|
|
|
|
{loading && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '2rem' }}>
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} style={{ background: cream, borderRadius: '20px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
|
|
<div style={{ width: '100%', height: '300px', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite' }} />
|
|
<div style={{ padding: '1.5rem' }}>
|
|
<div style={{ height: '24px', width: '60%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.75rem' }} />
|
|
<div style={{ height: '16px', width: '90%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.5rem' }} />
|
|
<div style={{ height: '16px', width: '75%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.5rem' }} />
|
|
<div style={{ height: '16px', width: '50%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<style jsx>{`
|
|
@keyframes shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
`}</style>
|
|
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
|
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '2rem' }}>
|
|
{displayedRooms.map((room) => {
|
|
const photos = room.photos || [];
|
|
const activeIdx = getActiveIndex(room);
|
|
const activeSrc = getPhoto(room, activeIdx);
|
|
const comments = commentsByRoom[room.id] || [];
|
|
const isExpanded = expandedRoom === room.id;
|
|
const totalLikes = likesByRoom[room.id] || 0;
|
|
const hasLiked = userLikesByRoom[room.id] || false;
|
|
|
|
return (
|
|
<article
|
|
key={room.id}
|
|
style={{
|
|
background: cream,
|
|
borderRadius: '20px',
|
|
overflow: 'hidden',
|
|
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
transition: 'transform 0.2s, box-shadow 0.2s',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
|
>
|
|
{/* ─── Main Image ─── */}
|
|
<div style={{ position: 'relative', width: '100%', height: '300px' }}>
|
|
{activeSrc ? (
|
|
activeSrc.startsWith('data:') ? (
|
|
<img
|
|
src={activeSrc}
|
|
alt={room.name}
|
|
style={{ width: '100%', height: '300px', objectFit: 'cover' }}
|
|
/>
|
|
) : (
|
|
<OptimizedImage
|
|
id={parseInt(activeSrc.split('id=')[1])}
|
|
alt={room.name}
|
|
size="medium"
|
|
lazy={true}
|
|
style={{ width: '100%', height: '300px' }}
|
|
/>
|
|
)
|
|
) : (
|
|
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
|
|
No photo
|
|
</div>
|
|
)}
|
|
|
|
{/* Like button with count */}
|
|
<button
|
|
onClick={() => toggleLike(room.id)}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 12,
|
|
left: 12,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
padding: '6px 12px',
|
|
borderRadius: '999px',
|
|
border: 'none',
|
|
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.5)',
|
|
color: '#fff',
|
|
fontSize: '14px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
|
aria-label="Like this room"
|
|
>
|
|
<span style={{ fontSize: '16px' }}>{hasLiked ? '❤️' : '♡'}</span>
|
|
<span>{totalLikes}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* ─── Thumbnail Strip ─── */}
|
|
{photos.length > 1 && (
|
|
<div style={{
|
|
display: 'flex',
|
|
gap: '6px',
|
|
padding: '10px 14px 14px',
|
|
overflowX: 'auto',
|
|
}}>
|
|
{photos.map((p, i) => {
|
|
const isActive = i === activeIdx;
|
|
return (
|
|
<button
|
|
key={`${room.id}-${i}`}
|
|
onClick={() => setActivePhotoByRoom((prev) => ({ ...prev, [room.id]: i }))}
|
|
style={{
|
|
width: 60,
|
|
height: 45,
|
|
flexShrink: 0,
|
|
borderRadius: 8,
|
|
border: isActive ? `2px solid ${gold}` : '2px solid transparent',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
overflow: 'hidden',
|
|
transition: 'border-color 0.2s, transform 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isActive) e.currentTarget.style.transform = 'scale(1.08)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isActive) e.currentTarget.style.transform = 'scale(1)';
|
|
}}
|
|
>
|
|
{p ? (
|
|
p.startsWith('data:') ? (
|
|
<img
|
|
src={p}
|
|
alt=""
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
) : (
|
|
<OptimizedImage
|
|
id={parseInt(p.split('id=')[1])}
|
|
alt=""
|
|
size="thumbnail"
|
|
lazy={true}
|
|
style={{ width: '100%', height: '100%' }}
|
|
/>
|
|
)
|
|
) : (
|
|
<div style={{ width: '100%', height: '100%', background: '#ddd' }} />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── Room Info ─── */}
|
|
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
<h2 style={{ margin: 0, color: navy, fontSize: '24px' }}>{room.name}</h2>
|
|
<p style={{ color: '#555', lineHeight: 1.5, flex: 1, marginTop: '0.5rem' }}>{room.description}</p>
|
|
|
|
{/* ─── Amenity Icons ─── */}
|
|
{room.amenities && room.amenities.length > 0 && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.75rem' }}>
|
|
{room.amenities.slice(0, 6).map((amenity) => (
|
|
<div
|
|
key={amenity.id}
|
|
title={amenity.name}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '0.25rem',
|
|
padding: '0.3rem 0.5rem',
|
|
background: 'rgba(1,13,30,0.06)',
|
|
borderRadius: '999px',
|
|
fontSize: '11px',
|
|
color: navy,
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{amenity.icon_svg ? (
|
|
<span
|
|
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '14px', height: '14px', color: navy }}
|
|
dangerouslySetInnerHTML={{ __html: amenity.icon_svg.replace(/width="[^"]*"/g, 'width="14"').replace(/height="[^"]*"/g, 'height="14"') }}
|
|
/>
|
|
) : null}
|
|
<span>{amenity.name}</span>
|
|
</div>
|
|
))}
|
|
{room.amenities.length > 6 && (
|
|
<span style={{ fontSize: '10px', color: '#777', alignSelf: 'center', padding: '0 0.5rem' }}>+{room.amenities.length - 6}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
|
|
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
|
|
</div>
|
|
|
|
{/* ─── Action Buttons ─── */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginTop: '1rem' }}>
|
|
<button
|
|
onClick={() => openModal('message', room)}
|
|
style={{
|
|
padding: '0.6rem 0.5rem',
|
|
borderRadius: '10px',
|
|
border: `2px solid ${navy}`,
|
|
background: 'transparent',
|
|
color: navy,
|
|
fontWeight: 600,
|
|
fontSize: '12px',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
Message
|
|
</button>
|
|
<button
|
|
onClick={() => openModal('calendar', room)}
|
|
style={{
|
|
padding: '0.6rem 0.5rem',
|
|
borderRadius: '10px',
|
|
border: `2px solid ${navy}`,
|
|
background: 'transparent',
|
|
color: navy,
|
|
fontWeight: 600,
|
|
fontSize: '12px',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
Reserve
|
|
</button>
|
|
<button
|
|
onClick={() => openModal('review', room)}
|
|
style={{
|
|
padding: '0.6rem 0.5rem',
|
|
borderRadius: '10px',
|
|
border: `2px solid ${navy}`,
|
|
background: 'transparent',
|
|
color: navy,
|
|
fontWeight: 600,
|
|
fontSize: '12px',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
Review
|
|
</button>
|
|
</div>
|
|
|
|
{/* Comments button — cream */}
|
|
<button
|
|
onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
|
|
style={{
|
|
marginTop: '1rem',
|
|
padding: '0.6rem 1rem',
|
|
borderRadius: '999px',
|
|
border: `2px solid ${cream}`,
|
|
background: cream,
|
|
color: navy,
|
|
cursor: 'pointer',
|
|
fontWeight: 600,
|
|
fontSize: '14px',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.borderColor = gold; e.currentTarget.style.color = navy; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = cream; e.currentTarget.style.borderColor = cream; e.currentTarget.style.color = navy; }}
|
|
>
|
|
{isExpanded ? 'Hide comments' : `Comments (${comments.length})`}
|
|
</button>
|
|
|
|
{/* Comments section */}
|
|
{isExpanded && (
|
|
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{loadingComments[room.id] && <p style={{ color: '#777', fontSize: '13px' }}>Loading comments...</p>}
|
|
|
|
{user ? (
|
|
user.comments_disabled ? (
|
|
<p style={{ color: '#c23b22', fontSize: '14px' }}>Comments are disabled for your account.</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
<textarea
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder="Share your thoughts about this room..."
|
|
rows={3}
|
|
style={{
|
|
padding: '0.75rem',
|
|
borderRadius: '10px',
|
|
border: '1px solid rgba(1,13,30,0.18)',
|
|
fontFamily: 'inherit',
|
|
fontSize: '14px',
|
|
resize: 'vertical',
|
|
}}
|
|
/>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span style={{ fontSize: '12px', color: '#777' }}>Posting as {user.first_name || user.username}</span>
|
|
<button
|
|
onClick={() => postComment(room.id)}
|
|
disabled={sending || !draft.trim()}
|
|
style={{
|
|
padding: '0.5rem 1rem',
|
|
borderRadius: '8px',
|
|
border: 'none',
|
|
background: gold,
|
|
color: navy,
|
|
fontWeight: 700,
|
|
cursor: draft.trim() ? 'pointer' : 'default',
|
|
opacity: draft.trim() ? 1 : 0.5,
|
|
}}
|
|
>
|
|
{sending ? 'Sending...' : 'Post'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
<p style={{ color: '#777', fontSize: '14px' }}>Log in to leave a comment.</p>
|
|
)}
|
|
|
|
{comments.length === 0 ? (
|
|
<p style={{ color: '#777', fontSize: '14px' }}>No comments yet.</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{comments.map((c) => (
|
|
<div key={c.id} style={{ background: '#fff', padding: '0.75rem', borderRadius: '10px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.3rem' }}>
|
|
<strong style={{ color: navy, fontSize: '14px' }}>
|
|
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
|
|
</strong>
|
|
<span style={{ fontSize: '12px', color: '#777' }}>
|
|
{formatDisplayDate(c.created_at)}
|
|
</span>
|
|
</div>
|
|
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
|
|
{user?.role === 'admin' && (
|
|
<button
|
|
onClick={() => deleteComment(room.id, c.id)}
|
|
style={{ marginTop: '0.5rem', fontSize: '12px', color: '#c23b22', background: 'none', border: 'none', cursor: 'pointer' }}
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* ─── Modals ─── */}
|
|
{activeModal && selectedRoom && (
|
|
<div
|
|
onClick={closeModal}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.5)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 1000,
|
|
padding: '1rem',
|
|
}}
|
|
>
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{
|
|
background: '#fff',
|
|
borderRadius: '20px',
|
|
padding: '2rem',
|
|
maxWidth: '500px',
|
|
width: '100%',
|
|
maxHeight: '90vh',
|
|
overflow: 'auto',
|
|
}}
|
|
>
|
|
{/* Reserve Modal */}
|
|
{activeModal === 'reserve' && (
|
|
<>
|
|
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
|
|
{!user ? (
|
|
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to make a reservation.</p>
|
|
) : (
|
|
<>
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date</label>
|
|
<input
|
|
type="date"
|
|
value={reservationDates.checkIn}
|
|
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
|
|
min={new Date().toISOString().split('T')[0]}
|
|
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date</label>
|
|
<input
|
|
type="date"
|
|
value={reservationDates.checkOut}
|
|
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
|
|
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
|
|
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
|
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
|
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Message Modal */}
|
|
{activeModal === 'message' && (
|
|
<>
|
|
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Send a Message</h3>
|
|
<p style={{ color: '#555', marginBottom: '1rem' }}>Send a message about {selectedRoom.name} to our team.</p>
|
|
{!user && (
|
|
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
|
|
<div style={{ marginBottom: '0.75rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
|
|
<input
|
|
type="text"
|
|
value={guestForm.name}
|
|
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
|
|
placeholder="Enter your name"
|
|
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: '0.75rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
|
|
<select
|
|
value={guestForm.contactMethod}
|
|
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
|
|
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
|
>
|
|
<option value="email">Email</option>
|
|
<option value="whatsapp">WhatsApp</option>
|
|
<option value="phone">Phone</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
|
|
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
|
|
</label>
|
|
<input
|
|
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
|
|
value={guestForm.contact}
|
|
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
|
|
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
|
|
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{user && (
|
|
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
|
|
<strong>From:</strong> {user.first_name} {user.last_name} ({user.email})
|
|
</div>
|
|
)}
|
|
<textarea
|
|
value={messageText}
|
|
onChange={(e) => setMessageText(e.target.value)}
|
|
placeholder="Your message..."
|
|
rows={5}
|
|
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
|
|
/>
|
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
|
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
|
<button onClick={handleSendMessage} disabled={submitting || !messageText.trim() || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Sending...' : 'Send Message'}</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Reserve/Calendar Modal */}
|
|
{activeModal === 'calendar' && (
|
|
<>
|
|
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
|
|
<p style={{ color: '#555', marginBottom: '1rem' }}>Select your dates and we'll contact you to confirm.</p>
|
|
{!user && (
|
|
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
|
|
<div style={{ marginBottom: '0.75rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
|
|
<input
|
|
type="text"
|
|
value={guestForm.name}
|
|
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
|
|
placeholder="Enter your name"
|
|
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: '0.75rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
|
|
<select
|
|
value={guestForm.contactMethod}
|
|
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
|
|
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
|
>
|
|
<option value="email">Email</option>
|
|
<option value="whatsapp">WhatsApp</option>
|
|
<option value="phone">Phone</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
|
|
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
|
|
</label>
|
|
<input
|
|
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
|
|
value={guestForm.contact}
|
|
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
|
|
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
|
|
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{user && (
|
|
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
|
|
<strong>Reserving as:</strong> {user.first_name} {user.last_name} ({user.email})
|
|
</div>
|
|
)}
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date *</label>
|
|
<input
|
|
type="date"
|
|
value={reservationDates.checkIn}
|
|
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
|
|
min={new Date().toISOString().split('T')[0]}
|
|
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date *</label>
|
|
<input
|
|
type="date"
|
|
value={reservationDates.checkOut}
|
|
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
|
|
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
|
|
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Availability Calendar</label>
|
|
<RoomCalendar roomId={selectedRoom.id} />
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
|
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
|
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Review Modal */}
|
|
{activeModal === 'review' && (
|
|
<>
|
|
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Leave a Review</h3>
|
|
<p style={{ color: '#555', marginBottom: '1rem' }}>Share your experience with {selectedRoom.name}.</p>
|
|
{!user ? (
|
|
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to leave a review.</p>
|
|
) : (
|
|
<>
|
|
<textarea
|
|
value={reviewText}
|
|
onChange={(e) => setReviewText(e.target.value)}
|
|
placeholder="Your review or suggestion..."
|
|
rows={5}
|
|
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
|
|
/>
|
|
<p style={{ fontSize: '12px', color: '#777', marginTop: '0.5rem' }}>Reviews are submitted for admin approval before being published.</p>
|
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
|
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
|
<button onClick={handleReview} disabled={submitting || !reviewText.trim()} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Submitting...' : 'Submit Review'}</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|