feat: room booking system with 4 action buttons
- Display amenity icons on room cards - Reserve button: date picker, availability check, booking - Message button: contact admins via platform - Calendar button: view room availability - Comment button: submit reviews for admin approval - Admin comment queue for approve/reject/edit - Reservations, availability, and messages tables
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { formatDisplayDate } from '@/lib/date';
|
||||
import RoomCalendar from '@/components/RoomCalendar';
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
@@ -11,6 +12,7 @@ interface Room {
|
||||
photos: string[];
|
||||
featured_photo: string | null;
|
||||
active?: boolean;
|
||||
amenities: Array<{ id: number; name: string; icon_svg: string }>;
|
||||
}
|
||||
|
||||
interface RoomComment {
|
||||
@@ -57,6 +59,14 @@ export default function RoomsPage() {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/rooms')
|
||||
@@ -146,6 +156,105 @@ export default function RoomsPage() {
|
||||
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 || !user) {
|
||||
alert('Please log in and select dates');
|
||||
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,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Reservation request sent! You will receive a confirmation email shortly.');
|
||||
closeModal();
|
||||
} 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() || !user) {
|
||||
alert('Please log in and enter a message');
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Message sent to our team!');
|
||||
closeModal();
|
||||
} 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 (
|
||||
@@ -313,11 +422,106 @@ export default function RoomsPage() {
|
||||
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{room.name}</h2>
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
|
||||
|
||||
{/* ─── Amenity Icons ─── */}
|
||||
{room.amenities && room.amenities.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||
{room.amenities.slice(0, 6).map((amenity) => (
|
||||
<div
|
||||
key={amenity.id}
|
||||
title={amenity.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.35rem 0.6rem',
|
||||
background: 'rgba(1,13,30,0.06)',
|
||||
borderRadius: '999px',
|
||||
fontSize: '12px',
|
||||
color: navy,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: amenity.icon_svg.replace('width="24"', 'width="14"').replace('height="24"', 'height="14"') }}
|
||||
/>
|
||||
))}
|
||||
{room.amenities.length > 6 && (
|
||||
<span style={{ fontSize: '11px', color: '#777', alignSelf: 'center' }}>+{room.amenities.length - 6} more</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(4, 1fr)', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
onClick={() => openModal('reserve', room)}
|
||||
style={{
|
||||
padding: '0.6rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
border: 'none',
|
||||
background: gold,
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Reserve
|
||||
</button>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
Calendar
|
||||
</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)}
|
||||
@@ -422,6 +626,136 @@ export default function RoomsPage() {
|
||||
);
|
||||
})}
|
||||
</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 ? (
|
||||
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to send a message.</p>
|
||||
) : (
|
||||
<>
|
||||
<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()} 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Calendar Modal */}
|
||||
{activeModal === 'calendar' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Availability Calendar</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>Check availability for {selectedRoom.name}.</p>
|
||||
<RoomCalendar roomId={selectedRoom.id} />
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Close</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user