feat: user portal with trips, wifi, welcome message, chat; admin trips & messages mgmt

This commit is contained in:
2026-06-28 23:33:39 -05:00
parent 658a3f00a7
commit 291df16f1f
7 changed files with 657 additions and 142 deletions
+225
View File
@@ -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<Trip[]>([]);
const [users, setUsers] = useState<{id: number; username: string}[]>([]);
const [rooms, setRooms] = useState<{id: number; name: string}[]>([]);
const [editing, setEditing] = useState<Trip | null>(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 (
<div>
<SecHead title="✈️ Trips" count={trips.length} onAdd={() => { setCreating(true); setEditing(emptyTrip); }} />
{creating && editing && !editing.id && (
<div style={{ background: '#fff', borderRadius: 12, padding: '20px', marginBottom: '20px', boxShadow: C.shadow }}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px' }}>New Trip</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<S label="User" value={editing.user_id} onChange={(v: string) => setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
<S label="Room" value={editing.room_id || ''} onChange={(v: string) => setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
<I label="Check-in" type="date" value={editing.check_in} onChange={(v: string) => setEditing({ ...editing, check_in: v })} />
<I label="Check-out" type="date" value={editing.check_out} onChange={(v: string) => setEditing({ ...editing, check_out: v })} />
<S label="Status" value={editing.status} onChange={(v: string) => setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
</div>
<TA label="Notes" value={editing.notes || ''} onChange={(v: string) => setEditing({ ...editing, notes: v })} rows={2} />
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<Btn onClick={() => save(editing)}>Save Trip</Btn>
<Btn variant="secondary" onClick={() => { setCreating(false); setEditing(null); }}>Cancel</Btn>
</div>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{trips.map(t => (
<div key={t.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 600, color: C.navy }}>{t.username || `User ${t.user_id}`}</div>
<div style={{ fontSize: '13px', color: C.textLight }}>{t.room_name || 'No room assigned'}</div>
</div>
<div style={{ fontSize: '13px', color: C.text }}>
<strong>In:</strong> {new Date(t.check_in).toLocaleDateString()} · <strong>Out:</strong> {new Date(t.check_out).toLocaleDateString()}
</div>
<Badge color={t.status === 'confirmed' ? C.success : t.status === 'checked_in' ? '#1976d2' : t.status === 'cancelled' ? C.danger : '#666'}>{t.status.replace('_', ' ')}</Badge>
<div style={{ display: 'flex', gap: '8px' }}>
<Btn size="sm" onClick={() => { setCreating(false); setEditing(t); }}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => del(t.id)}>Delete</Btn>
</div>
</div>
))}
</div>
{!creating && editing && editing.id && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setEditing(null)}>
<div style={{ background: '#fff', borderRadius: 12, padding: '24px', maxWidth: '500px', width: '90%' }} onClick={e => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px' }}>Edit Trip</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<S label="User" value={editing.user_id} onChange={(v: string) => setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
<S label="Room" value={editing.room_id || ''} onChange={(v: string) => setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
<I label="Check-in" type="date" value={editing.check_in} onChange={(v: string) => setEditing({ ...editing, check_in: v })} />
<I label="Check-out" type="date" value={editing.check_out} onChange={(v: string) => setEditing({ ...editing, check_out: v })} />
<S label="Status" value={editing.status} onChange={(v: string) => setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
</div>
<TA label="Notes" value={editing.notes || ''} onChange={(v: string) => setEditing({ ...editing, notes: v })} rows={2} />
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<Btn onClick={() => save(editing)}>Save</Btn>
<Btn variant="secondary" onClick={() => setEditing(null)}>Cancel</Btn>
</div>
</div>
</div>
)}
</div>
);
}
/* ─── 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<MsgThread[]>([]);
const [selectedUser, setSelectedUser] = useState<number | null>(null);
const [messages, setMessages] = useState<Msg[]>([]);
const [reply, setReply] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '20px', minHeight: '400px' }}>
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow }}>
<div style={{ padding: '16px', borderBottom: `1px solid ${C.border}`, fontWeight: 600, color: C.navy }}>Conversations</div>
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
{threads.length === 0 ? (
<div style={{ padding: '20px', textAlign: 'center', color: C.textLight }}>No conversations yet</div>
) : (
threads.map(t => (
<div key={t.user_id} onClick={() => 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'; }}>
<div style={{ fontWeight: 500, color: C.navy }}>{t.username}</div>
<div style={{ fontSize: '12px', color: C.textLight, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.message}</div>
</div>
))
)}
</div>
</div>
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow, display: 'flex', flexDirection: 'column' }}>
{selectedUser ? (
<>
<div style={{ padding: '16px', borderBottom: `1px solid ${C.border}`, fontWeight: 600, color: C.navy }}>
{threads.find(t => t.user_id === selectedUser)?.username || `User ${selectedUser}`}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', maxHeight: '300px' }}>
{messages.map(m => (
<div key={m.id} style={{ display: 'flex', justifyContent: m.sender_role === 'admin' ? 'flex-start' : 'flex-end', marginBottom: '12px' }}>
<div style={{ maxWidth: '70%', padding: '10px 14px', borderRadius: 12, background: m.sender_role === 'admin' ? C.gold : '#f0f0f0', color: m.sender_role === 'admin' ? '#fff' : C.navy }}>
<div style={{ fontSize: '12px', fontWeight: 500, marginBottom: '4px' }}>{m.sender_role === 'admin' ? 'Staff' : 'Guest'}</div>
<div style={{ fontSize: '14px' }}>{m.message}</div>
<div style={{ fontSize: '11px', opacity: 0.7, marginTop: '4px' }}>{new Date(m.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div style={{ padding: '12px', borderTop: `1px solid ${C.border}`, display: 'flex', gap: '8px' }}>
<input value={reply} onChange={e => 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' }} />
<Btn onClick={sendReply} disabled={!reply.trim()}>Send</Btn>
</div>
</>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.textLight }}>Select a conversation to view messages</div>
)}
</div>
</div>
);
}
/* ─── Menu Section ─── */
function MenuSection({ items, setItems, images, onToast }: any) {
const [editing, setEditing] = useState<MenuItem | null>(null);
@@ -1846,6 +2069,8 @@ export default function AdminPage() {
case 'reviews': return <ReviewsSection reviews={reviews} setReviews={setReviews} onToast={showToast} />;
case 'users': return <UsersSection onToast={showToast} />;
case 'weather': return <WeatherSection onToast={showToast} />;
case 'trips': return <TripsSection onToast={showToast} />;
case 'messages': return <MessagesSection onToast={showToast} />;
default: return <Dashboard rooms={rooms} slides={slides} events={events} reviews={reviews} places={places} />;
}
};
+62
View File
@@ -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 });
}
}
+106
View File
@@ -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 });
}
}
+53
View File
@@ -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 });
}
}
+12 -1
View File
@@ -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') {
+176 -141
View File
@@ -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<PortalData | null>(null);
const [loading, setLoading] = useState(true);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
@@ -67,135 +114,123 @@ export default function UserPage() {
}
if (!data) {
return <main style={{ padding: '2rem' }}><p>Please log in as user to access this page.</p></main>;
return <main style={{ padding: '2rem' }}><p>Please log in to access this page.</p></main>;
}
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 (
<main style={{ padding: '2rem', maxWidth: '56rem', margin: '0 auto' }}>
<main style={{ padding: '2rem', maxWidth: '64rem', margin: '0 auto' }}>
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 2rem' }}>
Member Portal
Your Portal
</h1>
<Section title="Future Reservations">
{data.reservations.length === 0 ? (
<p style={{ color: '#777' }}>No upcoming reservations.</p>
) : (
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{data.reservations.map((r) => (
<li
key={r.id}
style={{
padding: '1rem 1.25rem',
background: cream,
borderRadius: '16px',
boxShadow: '0 1px 2px rgba(1,13,30,0.08)',
color: navy,
}}
>
<strong style={{ color: warm }}>{r.date}</strong> at {r.time} · {r.guests} guests · {r.table_name}
</li>
))}
</ul>
)}
</Section>
{/* Welcome Message - Show on reservation day */}
{activeTrip && (
<Section title="Welcome!" highlight>
<div style={{ background: 'linear-gradient(135deg, #E8A849 0%, #d4963f 100%)', padding: '1.5rem', borderRadius: '16px', color: '#fff' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '20px', fontWeight: 600 }}>
Welcome to Hosteria La Huasca!
</p>
<p style={{ margin: '0', opacity: 0.9 }}>
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' })}
</p>
</div>
</Section>
)}
<Section title="Discount Coupons">
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
{data.coupons.map((c) => (
<div
key={c.id}
style={{
padding: '1.25rem',
background: cream,
border: '2px dashed ' + warm,
borderRadius: '16px',
minWidth: '13rem',
color: navy,
}}
>
<strong style={{ fontSize: '18px', color: warm }}>{c.code}</strong>
<p style={{ margin: '0.5rem 0 0', color: '#555', fontSize: '14px' }}>{c.discount}</p>
{/* Future Trip Info */}
{upcomingTrip && (
<Section title="Your Upcoming Trip">
<div style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<div>
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-in</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{new Date(upcomingTrip.check_in).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-out</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{new Date(upcomingTrip.check_out).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div>
</div>
{upcomingTrip.room_name && (
<div>
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Room</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{upcomingTrip.room_name}</div>
</div>
)}
</div>
))}
</div>
</Section>
<Section title="Offers">
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{data.offers.map((o) => (
<li
key={o.id}
style={{
padding: '1rem 1.25rem',
background: cream,
borderRadius: '16px',
color: navy,
}}
>
<strong style={{ color: warm }}>{o.title}</strong> {o.description}
</li>
))}
</ul>
</Section>
{upcomingTrip.notes && (
<div style={{ fontSize: '14px', color: '#555', marginTop: '0.5rem' }}>
<strong>Notes:</strong> {upcomingTrip.notes}
</div>
)}
<div style={{ marginTop: '1rem', fontSize: '13px', color: '#777' }}>
Status: <span style={{ color: warm, fontWeight: 500, textTransform: 'capitalize' }}>{upcomingTrip.status.replace('_', ' ')}</span>
</div>
</div>
</Section>
)}
{/* WiFi Password */}
<Section title="WiFi Password">
<div
style={{
display: 'inline-block',
padding: '0.9rem 2rem',
background: navy,
color: cream,
borderRadius: '12px',
fontFamily: 'monospace',
fontSize: '1.4rem',
letterSpacing: '0.04em',
boxShadow: '0 4px 12px rgba(1,13,30,0.2)',
}}
>
{data.wifiPassword}
<div style={{ display: 'inline-block', padding: '0.9rem 2rem', background: navy, color: cream, borderRadius: '12px', fontFamily: 'monospace', fontSize: '1.4rem', letterSpacing: '0.04em', boxShadow: '0 4px 12px rgba(1,13,30,0.2)' }}>
{data.wifiPassword || 'Not available'}
</div>
</Section>
<Section title="Member Benefits">
<ul style={{ padding: 0, listStyle: 'none', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
{data.benefits.map((b) => (
<li
key={b}
style={{
padding: '0.9rem 1.1rem',
background: '#e8eff3',
borderLeft: '4px solid ' + gold,
borderRadius: '0 12px 12px 0',
color: navy,
}}
>
{b}
</li>
))}
</ul>
{/* Chat with Admins */}
<Section title="Message Staff">
<div style={{ background: cream, borderRadius: '16px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
<div style={{ maxHeight: '300px', overflowY: 'auto', padding: '1rem' }}>
{data.messages.length === 0 ? (
<div style={{ textAlign: 'center', color: '#777', padding: '2rem' }}>
No messages yet. Send a message to contact our staff.
</div>
) : (
data.messages.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: m.sender_role === 'user' ? 'flex-end' : 'flex-start', marginBottom: '0.75rem' }}>
<div style={{ maxWidth: '70%', padding: '0.75rem 1rem', borderRadius: '12px', background: m.sender_role === 'user' ? gold : '#fff', color: m.sender_role === 'user' ? '#fff' : navy, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '0.25rem' }}>
{m.sender_role === 'admin' ? 'Staff' : 'You'}
</div>
<div style={{ fontSize: '14px' }}>{m.message}</div>
<div style={{ fontSize: '11px', opacity: 0.7, marginTop: '0.25rem' }}>
{new Date(m.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} style={{ display: 'flex', gap: '0.5rem', padding: '1rem', borderTop: '1px solid rgba(0,0,0,0.1)' }}>
<input
type="text"
value={newMessage}
onChange={(e) => 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' }}
/>
<button type="submit" disabled={sending || !newMessage.trim()} style={{ padding: '0.75rem 1.5rem', background: gold, color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 600, cursor: sending ? 'wait' : 'pointer', opacity: sending ? 0.7 : 1 }}>
Send
</button>
</form>
</div>
</Section>
</main>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({ title, children, highlight }: { title: string; children: React.ReactNode; highlight?: boolean }) {
return (
<section style={{ marginBottom: '2.5rem' }}>
<h2
style={{
fontSize: '20px',
fontWeight: 600,
letterSpacing: '-0.01em',
color: navy,
margin: '0 0 1rem',
paddingBottom: '0.4rem',
borderBottom: '1.5px solid ' + warm,
display: 'inline-block',
}}
>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, letterSpacing: '-0.01em', color: highlight ? '#fff' : navy, margin: '0 0 1rem', paddingBottom: '0.4rem', borderBottom: highlight ? 'none' : '1.5px solid ' + warm, display: 'inline-block' }}>
{title}
</h2>
{children}
</section>
);
}
}