From 291df16f1fded5750e16da8a8641db132c6fea91 Mon Sep 17 00:00:00 2001 From: muken Date: Sun, 28 Jun 2026 23:33:39 -0500 Subject: [PATCH] feat: user portal with trips, wifi, welcome message, chat; admin trips & messages mgmt --- src/app/admin/page.tsx | 225 ++++++++++++++++++++ src/app/api/admin/messages/route.ts | 62 ++++++ src/app/api/admin/trips/route.ts | 106 ++++++++++ src/app/api/user/messages/route.ts | 53 +++++ src/app/api/user/portal/route.ts | 13 +- src/app/user/page.tsx | 317 +++++++++++++++------------- src/lib/schema.ts | 23 ++ 7 files changed, 657 insertions(+), 142 deletions(-) create mode 100644 src/app/api/admin/messages/route.ts create mode 100644 src/app/api/admin/trips/route.ts create mode 100644 src/app/api/user/messages/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 24868fd..0145430 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -27,6 +27,8 @@ const NAV = [ { id: 'calendar', label: 'Calendar', icon: 'πŸ“…' }, { id: 'social', label: 'Social Events', icon: 'πŸŽͺ' }, { id: 'rooms', label: 'Rooms', icon: '🏨' }, + { id: 'trips', label: 'Trips', icon: '✈️' }, + { id: 'messages', label: 'Messages', icon: 'πŸ’¬' }, { id: 'menu', label: 'Menu', icon: '🍽️' }, { id: 'places', label: 'Places', icon: 'πŸ—ΊοΈ' }, { id: 'reviews', label: 'Reviews', icon: '⭐' }, @@ -897,6 +899,227 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) { ); } +/* ─── Trips Section ─── */ +type Trip = { id: number; user_id: number; username: string; room_id: number | null; room_name: string | null; check_in: string; check_out: string; notes: string | null; status: string }; + +function TripsSection({ onToast }: { onToast: (msg: string, type: string) => void }) { + const [trips, setTrips] = useState([]); + const [users, setUsers] = useState<{id: number; username: string}[]>([]); + const [rooms, setRooms] = useState<{id: number; name: string}[]>([]); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + + useEffect(() => { + const load = async () => { + const [tripsRes, usersRes, roomsRes] = await Promise.all([ + fetch('/api/admin/trips'), + fetch('/api/admin/users'), + fetch('/api/rooms'), + ]); + if (tripsRes.ok) { const d = await tripsRes.json(); setTrips(d.trips || []); } + if (usersRes.ok) { const d = await usersRes.json(); setUsers(d.users || []); } + if (roomsRes.ok) { const d = await roomsRes.json(); setRooms(Array.isArray(d) ? d : (d.data || [])); } + }; + load(); + }, []); + + const save = async (trip: Trip) => { + try { + const method = trip.id ? 'PUT' : 'POST'; + const res = await fetch('/api/admin/trips', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(trip), + }); + if (res.ok) { + const d = await res.json(); + if (trip.id) { + setTrips(prev => prev.map(t => t.id === trip.id ? d.trip : t)); + } else { + setTrips(prev => [...prev, d.trip]); + } + setEditing(null); + setCreating(false); + onToast('Trip saved', 'success'); + } else { + const err = await res.json(); + onToast(err.error || 'Failed to save trip', 'error'); + } + } catch { onToast('Error saving trip', 'error'); } + }; + + const del = async (id: number) => { + if (!confirm('Delete this trip?')) return; + try { + await fetch('/api/admin/trips', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }); + setTrips(prev => prev.filter(t => t.id !== id)); + onToast('Trip deleted', 'success'); + } catch { onToast('Error deleting trip', 'error'); } + }; + + const emptyTrip: Trip = { id: 0, user_id: 0, username: '', room_id: null, room_name: null, check_in: '', check_out: '', notes: '', status: 'confirmed' }; + + return ( +
+ { setCreating(true); setEditing(emptyTrip); }} /> + {creating && editing && !editing.id && ( +
+

New Trip

+
+ setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} /> + setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} /> + setEditing({ ...editing, check_in: v })} /> + setEditing({ ...editing, check_out: v })} /> + setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} /> +
+ setEditing({ ...editing, notes: v })} rows={2} /> +
+ save(editing)}>Save Trip + { setCreating(false); setEditing(null); }}>Cancel +
+
+ )} +
+ {trips.map(t => ( +
+
+
{t.username || `User ${t.user_id}`}
+
{t.room_name || 'No room assigned'}
+
+
+ In: {new Date(t.check_in).toLocaleDateString()} Β· Out: {new Date(t.check_out).toLocaleDateString()} +
+ {t.status.replace('_', ' ')} +
+ { setCreating(false); setEditing(t); }}>Edit + del(t.id)}>Delete +
+
+ ))} +
+ {!creating && editing && editing.id && ( +
setEditing(null)}> +
e.stopPropagation()}> +

Edit Trip

+
+ setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} /> + setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} /> + setEditing({ ...editing, check_in: v })} /> + setEditing({ ...editing, check_out: v })} /> + setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} /> +
+ setEditing({ ...editing, notes: v })} rows={2} /> +
+ save(editing)}>Save + setEditing(null)}>Cancel +
+
+
+ )} +
+ ); +} + +/* ─── Messages Section ─── */ +type MsgThread = { user_id: number; username: string; message: string; created_at: string }; +type Msg = { id: number; sender_role: string; message: string; created_at: string }; + +function MessagesSection({ onToast }: { onToast: (msg: string, type: string) => void }) { + const [threads, setThreads] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [messages, setMessages] = useState([]); + const [reply, setReply] = useState(''); + const messagesEndRef = useRef(null); + + useEffect(() => { + const load = async () => { + const res = await fetch('/api/admin/messages'); + if (res.ok) { const d = await res.json(); setThreads(d.threads || []); } + }; + load(); + }, []); + + useEffect(() => { + if (selectedUser) { + const load = async () => { + const res = await fetch(`/api/admin/messages?user_id=${selectedUser}`); + if (res.ok) { const d = await res.json(); setMessages(d.messages || []); } + }; + load(); + } + }, [selectedUser]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const sendReply = async () => { + if (!reply.trim() || !selectedUser) return; + try { + const res = await fetch('/api/admin/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: selectedUser, message: reply.trim() }), + }); + if (res.ok) { + const d = await res.json(); + setMessages(prev => [...prev, d.message]); + setReply(''); + onToast('Reply sent', 'success'); + } + } catch { onToast('Error sending reply', 'error'); } + }; + + return ( +
+
+
Conversations
+
+ {threads.length === 0 ? ( +
No conversations yet
+ ) : ( + threads.map(t => ( +
setSelectedUser(t.user_id)} style={{ padding: '12px 16px', cursor: 'pointer', background: selectedUser === t.user_id ? C.goldLight : 'transparent', borderBottom: `1px solid ${C.border}` }} + onMouseEnter={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = '#f5f5f5'; }} + onMouseLeave={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = 'transparent'; }}> +
{t.username}
+
{t.message}
+
+ )) + )} +
+
+
+ {selectedUser ? ( + <> +
+ {threads.find(t => t.user_id === selectedUser)?.username || `User ${selectedUser}`} +
+
+ {messages.map(m => ( +
+
+
{m.sender_role === 'admin' ? 'Staff' : 'Guest'}
+
{m.message}
+
{new Date(m.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
+
+
+ ))} +
+
+
+ setReply(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') sendReply(); }} placeholder="Type a reply..." style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none' }} /> + Send +
+ + ) : ( +
Select a conversation to view messages
+ )} +
+
+ ); +} + /* ─── Menu Section ─── */ function MenuSection({ items, setItems, images, onToast }: any) { const [editing, setEditing] = useState(null); @@ -1846,6 +2069,8 @@ export default function AdminPage() { case 'reviews': return ; case 'users': return ; case 'weather': return ; + case 'trips': return ; + case 'messages': return ; default: return ; } }; diff --git a/src/app/api/admin/messages/route.ts b/src/app/api/admin/messages/route.ts new file mode 100644 index 0000000..9df74e7 --- /dev/null +++ b/src/app/api/admin/messages/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireRole } from '@/lib/auth'; +import { db } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + await requireRole('admin'); + const userId = request.nextUrl.searchParams.get('user_id'); + + if (userId) { + const { rows } = await db.query( + `SELECT m.id, m.user_id, m.sender_role, m.message, m.created_at, u.username + FROM messages m + JOIN users u ON m.user_id = u.id + WHERE m.user_id = $1 + ORDER BY m.created_at ASC`, + [userId] + ); + return NextResponse.json({ messages: rows }); + } + + const { rows } = await db.query( + `SELECT DISTINCT ON (m.user_id) m.user_id, m.created_at, u.username, m.message + FROM messages m + JOIN users u ON m.user_id = u.id + ORDER BY m.user_id, m.created_at DESC` + ); + return NextResponse.json({ threads: rows }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('GET /api/admin/messages error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + await requireRole('admin'); + const { user_id, message } = await request.json(); + + if (!user_id || !message || typeof message !== 'string') { + return NextResponse.json({ error: 'user_id and message are required' }, { status: 400 }); + } + + const { rows } = await db.query( + `INSERT INTO messages (user_id, sender_role, message) VALUES ($1, 'admin', $2) RETURNING *`, + [user_id, message.trim()] + ); + + return NextResponse.json({ message: rows[0] }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('POST /api/admin/messages error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/trips/route.ts b/src/app/api/admin/trips/route.ts new file mode 100644 index 0000000..326f522 --- /dev/null +++ b/src/app/api/admin/trips/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireRole } from '@/lib/auth'; +import { db } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + await requireRole('admin'); + + const { rows } = await db.query( + `SELECT t.id, t.user_id, t.room_id, t.check_in, t.check_out, t.notes, t.status, t.created_at, + u.username, r.name as room_name + FROM trips t + JOIN users u ON t.user_id = u.id + LEFT JOIN rooms r ON t.room_id = r.id + ORDER BY t.check_in DESC` + ); + + return NextResponse.json({ trips: rows }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('GET /api/admin/trips error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + await requireRole('admin'); + const { user_id, room_id, check_in, check_out, notes, status } = await request.json(); + + if (!user_id || !check_in || !check_out) { + return NextResponse.json({ error: 'user_id, check_in, and check_out are required' }, { status: 400 }); + } + + const { rows } = await db.query( + `INSERT INTO trips (user_id, room_id, check_in, check_out, notes, status) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [user_id, room_id || null, check_in, check_out, notes || null, status || 'confirmed'] + ); + + return NextResponse.json({ trip: rows[0] }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('POST /api/admin/trips error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + await requireRole('admin'); + const { id, user_id, room_id, check_in, check_out, notes, status } = await request.json(); + + if (!id) { + return NextResponse.json({ error: 'Trip id is required' }, { status: 400 }); + } + + const { rows } = await db.query( + `UPDATE trips + SET user_id = COALESCE($1, user_id), + room_id = COALESCE($2, room_id), + check_in = COALESCE($3, check_in), + check_out = COALESCE($4, check_out), + notes = COALESCE($5, notes), + status = COALESCE($6, status) + WHERE id = $7 + RETURNING *`, + [user_id, room_id, check_in, check_out, notes, status, id] + ); + + return NextResponse.json({ trip: rows[0] }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('PUT /api/admin/trips error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + await requireRole('admin'); + const { id } = await request.json(); + + if (!id) { + return NextResponse.json({ error: 'Trip id is required' }, { status: 400 }); + } + + await db.query('DELETE FROM trips WHERE id = $1', [id]); + return NextResponse.json({ success: true }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('DELETE /api/admin/trips error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/user/messages/route.ts b/src/app/api/user/messages/route.ts new file mode 100644 index 0000000..ea3bbaf --- /dev/null +++ b/src/app/api/user/messages/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireRole } from '@/lib/auth'; +import { db } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const session = await requireRole('user'); + const userId = session.id; + + const { rows } = await db.query( + `SELECT id, sender_role, message, created_at + FROM messages + WHERE user_id = $1 + ORDER BY created_at ASC`, + [userId] + ); + + return NextResponse.json({ messages: rows }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('GET /api/user/messages error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await requireRole('user'); + const userId = session.id; + const { message } = await request.json(); + + if (!message || typeof message !== 'string' || message.trim().length === 0) { + return NextResponse.json({ error: 'Message is required' }, { status: 400 }); + } + + const { rows } = await db.query( + `INSERT INTO messages (user_id, sender_role, message) VALUES ($1, 'user', $2) RETURNING *`, + [userId, message.trim()] + ); + + return NextResponse.json({ message: rows[0] }); + } catch (error: any) { + if (error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('POST /api/user/messages error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/user/portal/route.ts b/src/app/api/user/portal/route.ts index b001840..eb0ad9c 100644 --- a/src/app/api/user/portal/route.ts +++ b/src/app/api/user/portal/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { requireRole } from '@/lib/auth'; import { db } from '@/lib/db'; -export const dynamic = 'force-dynamic'; // Prevents static generation +export const dynamic = 'force-dynamic'; export async function GET() { try { @@ -32,12 +32,23 @@ export async function GET() { ); const wifiPassword = configRows[0]?.value || ''; + // Get user's trips (future and current) + const { rows: trips } = await db.query( + `SELECT t.id, t.room_id, t.check_in, t.check_out, t.notes, t.status, r.name as room_name + FROM trips t + LEFT JOIN rooms r ON t.room_id = r.id + WHERE t.user_id = $1 AND t.check_out >= CURRENT_DATE + ORDER BY t.check_in ASC`, + [userId] + ); + return NextResponse.json({ reservations, coupons, offers, benefits: benefits.map((b: { text: string }) => b.text), wifiPassword, + trips, }); } catch (error) { if ((error as Error).message === 'Unauthorized') { diff --git a/src/app/user/page.tsx b/src/app/user/page.tsx index 2391cda..6e63268 100644 --- a/src/app/user/page.tsx +++ b/src/app/user/page.tsx @@ -1,34 +1,29 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useRouter } from 'next/navigation'; -interface Reservation { +interface Trip { id: number; - date: string; - time: string; - guests: number; - table_name: string; + room_id: number | null; + room_name: string | null; + check_in: string; + check_out: string; + notes: string | null; + status: string; } -interface Coupon { +interface Message { id: number; - code: string; - discount: string; -} - -interface Offer { - id: number; - title: string; - description: string; + sender_role: 'user' | 'admin'; + message: string; + created_at: string; } interface PortalData { - reservations: Reservation[]; - coupons: Coupon[]; - offers: Offer[]; - benefits: string[]; + trips: Trip[]; wifiPassword: string; + messages: Message[]; } const gold = '#E8A849'; @@ -40,24 +35,76 @@ export default function UserPage() { const router = useRouter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [newMessage, setNewMessage] = useState(''); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + + const fetchData = async () => { + try { + const res = await fetch('/api/user/portal', { credentials: 'same-origin' }); + if (!res.ok) { + if (res.status === 401) { + router.push('/login'); + return; + } + throw new Error('Failed to load portal data'); + } + const json = await res.json(); + setData(json); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const fetchMessages = async () => { + try { + const res = await fetch('/api/user/messages', { credentials: 'same-origin' }); + if (res.ok) { + const json = await res.json(); + setData(prev => prev ? { ...prev, messages: json.messages } : null); + } + } catch (err) { + console.error(err); + } + }; useEffect(() => { - fetch('/api/user/portal', { credentials: 'same-origin' }) - .then(async (res) => { - if (!res.ok) { - if (res.status === 401) { - router.push('/login'); - return; - } - throw new Error('Failed to load portal data'); - } - return res.json(); - }) - .then((json) => setData(json)) - .catch((err) => console.error(err)) - .finally(() => setLoading(false)); + fetchData(); }, [router]); + useEffect(() => { + const interval = setInterval(fetchMessages, 10000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [data?.messages]); + + const sendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMessage.trim() || sending) return; + setSending(true); + try { + const res = await fetch('/api/user/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ message: newMessage.trim() }), + }); + if (res.ok) { + setNewMessage(''); + fetchMessages(); + } + } catch (err) { + console.error(err); + } finally { + setSending(false); + } + }; + if (loading) { return (
@@ -67,135 +114,123 @@ export default function UserPage() { } if (!data) { - return

Please log in as user to access this page.

; + return

Please log in to access this page.

; } + const today = new Date().toISOString().split('T')[0]; + const activeTrip = data.trips.find(t => t.check_in <= today && t.check_out >= today); + const upcomingTrip = data.trips.find(t => t.check_in > today); + return ( -
+

- Member Portal + Your Portal

-
- {data.reservations.length === 0 ? ( -

No upcoming reservations.

- ) : ( -
    - {data.reservations.map((r) => ( -
  • - {r.date} at {r.time} Β· {r.guests} guests Β· {r.table_name} -
  • - ))} -
- )} -
+ {/* Welcome Message - Show on reservation day */} + {activeTrip && ( +
+
+

+ Welcome to Hosteria La Huasca! +

+

+ Your room{activeTrip.room_name ? ` (${activeTrip.room_name})` : ''} is ready. Check-out: {new Date(activeTrip.check_out).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })} +

+
+
+ )} -
-
- {data.coupons.map((c) => ( -
- {c.code} -

{c.discount}

+ {/* Future Trip Info */} + {upcomingTrip && ( +
+
+
+
+
Check-in
+
{new Date(upcomingTrip.check_in).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
+
+
+
Check-out
+
{new Date(upcomingTrip.check_out).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
+
+ {upcomingTrip.room_name && ( +
+
Room
+
{upcomingTrip.room_name}
+
+ )}
- ))} -
-
- -
-
    - {data.offers.map((o) => ( -
  • - {o.title} β€” {o.description} -
  • - ))} -
-
+ {upcomingTrip.notes && ( +
+ Notes: {upcomingTrip.notes} +
+ )} +
+ Status: {upcomingTrip.status.replace('_', ' ')} +
+
+
+ )} + {/* WiFi Password */}
-
- {data.wifiPassword} +
+ {data.wifiPassword || 'Not available'}
-
-
    - {data.benefits.map((b) => ( -
  • - {b} -
  • - ))} -
+ {/* Chat with Admins */} +
+
+
+ {data.messages.length === 0 ? ( +
+ No messages yet. Send a message to contact our staff. +
+ ) : ( + data.messages.map((m) => ( +
+
+
+ {m.sender_role === 'admin' ? 'Staff' : 'You'} +
+
{m.message}
+
+ {new Date(m.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+
+
+ )) + )} +
+
+
+ setNewMessage(e.target.value)} + placeholder="Type a message..." + disabled={sending} + style={{ flex: 1, padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }} + /> + +
+
); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ title, children, highlight }: { title: string; children: React.ReactNode; highlight?: boolean }) { return ( -
-

+
+

{title}

{children}
); -} +} \ No newline at end of file diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 152357a..ea78b69 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -233,6 +233,29 @@ export async function initSchema() { UNIQUE(room_id, date) ); `); + + await db.query(` + CREATE TABLE IF NOT EXISTS trips ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL, + check_in DATE NOT NULL, + check_out DATE NOT NULL, + notes TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'confirmed' CHECK (status IN ('pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled')), + created_at TIMESTAMP DEFAULT NOW() + ); + `); + + await db.query(` + CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + sender_role VARCHAR(20) NOT NULL CHECK (sender_role IN ('user', 'admin')), + message TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + `); } export async function migrateFromLegacyPlaces() {