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} />;
}
};