feat: user portal with trips, wifi, welcome message, chat; admin trips & messages mgmt
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user