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:
2026-06-30 21:54:46 -05:00
parent c829e27bf6
commit 7eae73eefb
9 changed files with 911 additions and 12 deletions
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
// GET - list pending comments for admin review
export async function GET(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'pending';
const { rows } = await db.query(`
SELECT rc.*, r.name as room_name, u.username
FROM room_comments rc
LEFT JOIN rooms r ON rc.room_id = r.id
LEFT JOIN users u ON rc.user_id = u.id
WHERE rc.status = $1
ORDER BY rc.created_at DESC
`, [status]);
return NextResponse.json({ comments: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch comments' }, { status: 500 });
}
}
// PATCH - approve, reject, or edit a comment
export async function PATCH(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const { comment_id, action, text } = await request.json();
if (!comment_id || !action) {
return NextResponse.json({ error: 'comment_id and action required' }, { status: 400 });
}
let result;
if (action === 'approve') {
result = await db.query(`
UPDATE room_comments SET status = 'approved' WHERE id = $1 RETURNING *
`, [comment_id]);
} else if (action === 'reject') {
result = await db.query(`
UPDATE room_comments SET status = 'rejected' WHERE id = $1 RETURNING *
`, [comment_id]);
} else if (action === 'edit') {
if (!text) {
return NextResponse.json({ error: 'text required for edit action' }, { status: 400 });
}
result = await db.query(`
UPDATE room_comments SET text = $1, status = 'approved' WHERE id = $2 RETURNING *
`, [text, comment_id]);
} else if (action === 'delete') {
result = await db.query(`
UPDATE room_comments SET deleted_by_admin = TRUE WHERE id = $1 RETURNING *
`, [comment_id]);
} else {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
return NextResponse.json({ success: true, comment: result?.rows?.[0] });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to update comment' }, { status: 500 });
}
}
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { room_id, message } = await request.json();
if (!message || !message.trim()) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
}
const { rows } = await db.query(`
INSERT INTO messages (room_id, user_id, message)
VALUES ($1, $2, $3)
RETURNING *
`, [room_id || null, user.id, message.trim()]);
// Get admin emails for notification
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND (email IS NOT NULL OR phone IS NOT NULL)`);
// TODO: Send email/SMS notifications to admins
return NextResponse.json({ success: true, message: 'Message sent successfully' });
} catch (error) {
console.error('Message error:', error);
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const unreadOnly = searchParams.get('unread') === 'true';
let query = `
SELECT m.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email, u.phone
FROM messages m
LEFT JOIN rooms r ON m.room_id = r.id
JOIN users u ON m.user_id = u.id
`;
if (unreadOnly) {
query += ' WHERE m.read_by_admins = FALSE';
}
query += ' ORDER BY m.created_at DESC LIMIT 100';
const { rows } = await db.query(query);
return NextResponse.json({ messages: rows });
} catch (error) {
console.error('Get messages error:', error);
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { message_ids } = await request.json();
if (!message_ids || !Array.isArray(message_ids)) {
return NextResponse.json({ error: 'message_ids array is required' }, { status: 400 });
}
await db.query(`
UPDATE messages SET read_by_admins = TRUE WHERE id = ANY($1)
`, [message_ids]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Mark messages read error:', error);
return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 });
}
}
+130
View File
@@ -0,0 +1,130 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { room_id, check_in, check_out } = await request.json();
if (!room_id || !check_in || !check_out) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Check if room exists
const { rows: roomRows } = await db.query('SELECT * FROM rooms WHERE id = $1', [room_id]);
if (roomRows.length === 0) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
}
const room = roomRows[0];
// Check for conflicting reservations
const { rows: conflicts } = await db.query(`
SELECT * FROM reservations
WHERE room_id = $1
AND status != 'cancelled'
AND (
(check_in <= $2 AND check_out > $2) OR
(check_in < $3 AND check_out >= $3) OR
(check_in >= $2 AND check_out <= $3)
)
`, [room_id, check_in, check_out]);
if (conflicts.length > 0) {
return NextResponse.json({ error: 'Room is not available for selected dates' }, { status: 409 });
}
// Check for blocked dates
const { rows: blocked } = await db.query(`
SELECT * FROM room_availability
WHERE room_id = $1
AND date >= $2
AND date < $3
AND status != 'available'
`, [room_id, check_in, check_out]);
if (blocked.length > 0) {
return NextResponse.json({ error: 'Some dates are not available' }, { status: 409 });
}
// Calculate total price
const nights = Math.ceil((new Date(check_out).getTime() - new Date(check_in).getTime()) / (1000 * 60 * 60 * 24));
const pricePerNight = parseFloat(room.price) || 0;
const total_price = nights * pricePerNight;
// Create reservation
const { rows } = await db.query(`
INSERT INTO reservations (room_id, user_id, check_in, check_out, status, total_price)
VALUES ($1, $2, $3, $4, 'pending', $5)
RETURNING *
`, [room_id, user.id, check_in, check_out, total_price]);
// Block the dates
const insertPromises = [];
const checkInDate = new Date(check_in);
const checkOutDate = new Date(check_out);
for (let d = new Date(checkInDate); d < checkOutDate; d.setDate(d.getDate() + 1)) {
insertPromises.push(
db.query(`
INSERT INTO room_availability (room_id, date, status, reason)
VALUES ($1, $2, 'booked', $3)
ON CONFLICT (room_id, date) DO UPDATE SET status = 'booked', reason = $3
`, [room_id, d.toISOString().split('T')[0], `Reservation #${rows[0].id}`])
);
}
await Promise.all(insertPromises);
// Get admin emails for notification
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND email IS NOT NULL`);
// TODO: Send email notifications to admins
// TODO: Send welcome email to user with WiFi password and instructions
return NextResponse.json({
success: true,
reservation: rows[0],
message: 'Reservation request submitted. You will receive confirmation shortly.'
});
} catch (error) {
console.error('Reservation error:', error);
return NextResponse.json({ error: 'Failed to create reservation' }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'all';
let query = `
SELECT r.*, rm.name as room_name, u.username, u.first_name, u.last_name, u.email, u.phone
FROM reservations r
JOIN rooms rm ON r.room_id = rm.id
JOIN users u ON r.user_id = u.id
`;
const params: any[] = [];
if (status !== 'all') {
query += ' WHERE r.status = $1';
params.push(status);
}
query += ' ORDER BY r.created_at DESC';
const { rows } = await db.query(query, params);
return NextResponse.json({ reservations: rows });
} catch (error) {
console.error('Get reservations error:', error);
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
}
}
@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id);
const { searchParams } = new URL(request.url);
const start = searchParams.get('start');
const end = searchParams.get('end');
if (!start || !end) {
return NextResponse.json({ error: 'start and end dates required' }, { status: 400 });
}
// Get availability status for the date range
const { rows: availability } = await db.query(`
SELECT date, status, reason
FROM room_availability
WHERE room_id = $1 AND date >= $2 AND date <= $3
`, [roomId, start, end]);
// Get reservations in the date range
const { rows: reservations } = await db.query(`
SELECT check_in, check_out, status
FROM reservations
WHERE room_id = $1
AND status != 'cancelled'
AND check_in <= $3
AND check_out >= $2
`, [roomId, start, end]);
// Build day-by-day status
const days: Array<{ date: string; status: 'available' | 'booked' | 'maintenance' }> = [];
const startDate = new Date(start);
const endDate = new Date(end);
const availabilityMap = new Map(availability.map(a => [a.date, a]));
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const avail = availabilityMap.get(dateStr);
if (avail) {
days.push({ date: dateStr, status: avail.status as 'available' | 'booked' | 'maintenance' });
} else {
// Check if date falls within any reservation
const isBooked = reservations.some(r => {
const checkIn = new Date(r.check_in);
const checkOut = new Date(r.check_out);
return d >= checkIn && d < checkOut;
});
days.push({ date: dateStr, status: isBooked ? 'booked' : 'available' });
}
}
return NextResponse.json({ days });
} catch (error) {
console.error('Availability error:', error);
return NextResponse.json({ error: 'Failed to get availability' }, { status: 500 });
}
}
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id);
const { date, status, reason } = await request.json();
if (!date || !status) {
return NextResponse.json({ error: 'date and status required' }, { status: 400 });
}
const { rows } = await db.query(`
INSERT INTO room_availability (room_id, date, status, reason)
VALUES ($1, $2, $3, $4)
ON CONFLICT (room_id, date) DO UPDATE SET status = $3, reason = $4
RETURNING *
`, [roomId, date, status, reason || null]);
return NextResponse.json({ success: true, availability: rows[0] });
} catch (error) {
console.error('Set availability error:', error);
return NextResponse.json({ error: 'Failed to set availability' }, { status: 500 });
}
}
+5 -5
View File
@@ -9,9 +9,9 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
const photoUrl = searchParams.get('photo_url') || null;
const { rows } = await db.query(
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin, status
FROM room_comments
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE AND status = 'approved'
ORDER BY created_at DESC`,
[roomId, photoUrl]
);
@@ -47,12 +47,12 @@ export async function POST(request: NextRequest, { params }: { params: { id: str
const lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null;
const { rows: inserted } = await db.query(
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING *`,
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()]
);
return NextResponse.json({ data: inserted[0] });
return NextResponse.json({ data: inserted[0], message: 'Comment submitted for review' });
} catch (err: any) {
return NextResponse.json(
{ error: err.message || 'Failed to post comment' },
+13 -7
View File
@@ -6,17 +6,23 @@ export async function GET() {
try {
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id');
// Get amenity links for all rooms
const { rows: amenityLinks } = await db.query('SELECT room_id, amenity_id FROM room_amenity_links');
const amenityMap = new Map<number, number[]>();
for (const link of amenityLinks) {
if (!amenityMap.has(link.room_id)) amenityMap.set(link.room_id, []);
amenityMap.get(link.room_id)!.push(link.amenity_id);
// Get amenity details with icons for all rooms
const { rows: amenityRows } = await db.query(`
SELECT ral.room_id, ra.id, ra.name, ra.icon_svg
FROM room_amenity_links ral
JOIN room_amenities ra ON ral.amenity_id = ra.id
ORDER BY ra.sort_order
`);
const amenityMap = new Map<number, Array<{id: number; name: string; icon_svg: string}>>();
for (const row of amenityRows) {
if (!amenityMap.has(row.room_id)) amenityMap.set(row.room_id, []);
amenityMap.get(row.room_id)!.push({ id: row.id, name: row.name, icon_svg: row.icon_svg });
}
const roomsWithAmenities = rows.map(r => ({
...r,
amenity_ids: amenityMap.get(r.id) || []
amenities: amenityMap.get(r.id) || []
}));
return NextResponse.json({ data: roomsWithAmenities });
+334
View File
@@ -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>
);
}
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { useEffect, useState } from 'react';
interface RoomCalendarProps {
roomId: number;
}
interface AvailabilityDay {
date: string;
status: 'available' | 'booked' | 'maintenance';
}
const gold = '#E8A849';
const navy = '#010D1E';
export default function RoomCalendar({ roomId }: RoomCalendarProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [availability, setAvailability] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = new Date(year, month + 1, 0).toISOString().split('T')[0];
fetch(`/api/rooms/${roomId}/availability?start=${startDate}&end=${endDate}`)
.then((r) => r.json())
.then((data) => {
const map: Record<string, string> = {};
(data.days || []).forEach((d: AvailabilityDay) => {
map[d.date] = d.status;
});
setAvailability(map);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [roomId, currentMonth]);
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = new Date(year, month, 1).getDay();
return { daysInMonth, firstDayOfMonth };
};
const { daysInMonth, firstDayOfMonth } = getDaysInMonth(currentMonth);
const days = [];
// Empty cells for days before the first day of the month
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={`empty-${i}`} />);
}
// Days of the month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const status = availability[dateStr] || 'available';
const isToday = dateStr === new Date().toISOString().split('T')[0];
let bgColor = '#e8f5e9'; // green for available
let textColor = navy;
if (status === 'booked') {
bgColor = '#ffebee'; // red for booked
textColor = '#c23b22';
} else if (status === 'maintenance') {
bgColor = '#fff3e0'; // orange for maintenance
textColor = '#e65100';
}
days.push(
<div
key={day}
title={`${dateStr}: ${status}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '36px',
borderRadius: '8px',
background: bgColor,
color: textColor,
fontWeight: isToday ? 700 : 400,
border: isToday ? `2px solid ${gold}` : 'none',
fontSize: '13px',
}}
>
{day}
</div>
);
}
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
};
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
};
const monthName = currentMonth.toLocaleString('en-US', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<button onClick={prevMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>&larr;</button>
<span style={{ fontWeight: 600, color: navy }}>{monthName}</span>
<button onClick={nextMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>&rarr;</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '4px', textAlign: 'center' }}>
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
<div key={d} style={{ fontSize: '11px', fontWeight: 600, color: '#777', padding: '4px' }}>{d}</div>
))}
{days}
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', fontSize: '12px', justifyContent: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#e8f5e9', borderRadius: '2px' }} /> Available
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#ffebee', borderRadius: '2px' }} /> Booked
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#fff3e0', borderRadius: '2px' }} /> Maintenance
</div>
</div>
</div>
);
}
+43
View File
@@ -308,6 +308,49 @@ export async function initSchema() {
PRIMARY KEY (room_id, amenity_id)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
user_id INTEGER NOT NULL REFERENCES users(id),
check_in DATE NOT NULL,
check_out DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total_price DECIMAL(10,2),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_availability (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, date)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id),
user_id INTEGER NOT NULL REFERENCES users(id),
message TEXT NOT NULL,
read_by_admins BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`ALTER TABLE room_comments ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'approved'`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_reservations_room_dates ON reservations(room_id, check_in, check_out)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_availability_room_date ON room_availability(room_id, date)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC)`);
}
export async function migrateFromLegacyPlaces() {