diff --git a/src/app/api/admin/comments/route.ts b/src/app/api/admin/comments/route.ts new file mode 100644 index 0000000..8e444e8 --- /dev/null +++ b/src/app/api/admin/comments/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts new file mode 100644 index 0000000..e9eb7cf --- /dev/null +++ b/src/app/api/messages/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/reservations/route.ts b/src/app/api/reservations/route.ts new file mode 100644 index 0000000..6767fe3 --- /dev/null +++ b/src/app/api/reservations/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/rooms/[id]/availability/route.ts b/src/app/api/rooms/[id]/availability/route.ts new file mode 100644 index 0000000..7c5318e --- /dev/null +++ b/src/app/api/rooms/[id]/availability/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/rooms/[id]/comments/route.ts b/src/app/api/rooms/[id]/comments/route.ts index 4cf389d..3cbdd13 100644 --- a/src/app/api/rooms/[id]/comments/route.ts +++ b/src/app/api/rooms/[id]/comments/route.ts @@ -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' }, diff --git a/src/app/api/rooms/route.ts b/src/app/api/rooms/route.ts index 3cbd6e4..665bfc3 100644 --- a/src/app/api/rooms/route.ts +++ b/src/app/api/rooms/route.ts @@ -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(); - 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>(); + 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 }); diff --git a/src/app/rooms/page.tsx b/src/app/rooms/page.tsx index 1a98ddc..82526d9 100644 --- a/src/app/rooms/page.tsx +++ b/src/app/rooms/page.tsx @@ -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>({}); + + // Action modal states + const [activeModal, setActiveModal] = useState<'reserve' | 'message' | 'calendar' | 'review' | null>(null); + const [selectedRoom, setSelectedRoom] = useState(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() {

{room.name}

{room.description}

+ + {/* ─── Amenity Icons ─── */} + {room.amenities && room.amenities.length > 0 && ( +
+ {room.amenities.slice(0, 6).map((amenity) => ( +
+ ))} + {room.amenities.length > 6 && ( + +{room.amenities.length - 6} more + )} +
+ )} +
${room.price} per night
+ {/* ─── Action Buttons ─── */} +
+ + + + +
+ {/* Comments button — cream */}
+ + {/* ─── Modals ─── */} + {activeModal && selectedRoom && ( +
+
e.stopPropagation()} + style={{ + background: '#fff', + borderRadius: '20px', + padding: '2rem', + maxWidth: '500px', + width: '100%', + maxHeight: '90vh', + overflow: 'auto', + }} + > + {/* Reserve Modal */} + {activeModal === 'reserve' && ( + <> +

Reserve {selectedRoom.name}

+ {!user ? ( +

Please log in to make a reservation.

+ ) : ( + <> +
+ + 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' }} + /> +
+
+ + 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' }} + /> +
+
+ + +
+ + )} + + )} + + {/* Message Modal */} + {activeModal === 'message' && ( + <> +

Send a Message

+

Send a message about {selectedRoom.name} to our team.

+ {!user ? ( +

Please log in to send a message.

+ ) : ( + <> +