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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' }}>←</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' }}>→</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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user