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: 'calendar', label: 'Calendar', icon: '📅' },
|
||||||
{ id: 'social', label: 'Social Events', icon: '🎪' },
|
{ id: 'social', label: 'Social Events', icon: '🎪' },
|
||||||
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
|
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
|
||||||
|
{ id: 'trips', label: 'Trips', icon: '✈️' },
|
||||||
|
{ id: 'messages', label: 'Messages', icon: '💬' },
|
||||||
{ id: 'menu', label: 'Menu', icon: '🍽️' },
|
{ id: 'menu', label: 'Menu', icon: '🍽️' },
|
||||||
{ id: 'places', label: 'Places', icon: '🗺️' },
|
{ id: 'places', label: 'Places', icon: '🗺️' },
|
||||||
{ id: 'reviews', label: 'Reviews', 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 ─── */
|
/* ─── Menu Section ─── */
|
||||||
function MenuSection({ items, setItems, images, onToast }: any) {
|
function MenuSection({ items, setItems, images, onToast }: any) {
|
||||||
const [editing, setEditing] = useState<MenuItem | null>(null);
|
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 'reviews': return <ReviewsSection reviews={reviews} setReviews={setReviews} onToast={showToast} />;
|
||||||
case 'users': return <UsersSection onToast={showToast} />;
|
case 'users': return <UsersSection onToast={showToast} />;
|
||||||
case 'weather': return <WeatherSection 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} />;
|
default: return <Dashboard rooms={rooms} slides={slides} events={events} reviews={reviews} places={places} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // Prevents static generation
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -32,12 +32,23 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
const wifiPassword = configRows[0]?.value || '';
|
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({
|
return NextResponse.json({
|
||||||
reservations,
|
reservations,
|
||||||
coupons,
|
coupons,
|
||||||
offers,
|
offers,
|
||||||
benefits: benefits.map((b: { text: string }) => b.text),
|
benefits: benefits.map((b: { text: string }) => b.text),
|
||||||
wifiPassword,
|
wifiPassword,
|
||||||
|
trips,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as Error).message === 'Unauthorized') {
|
if ((error as Error).message === 'Unauthorized') {
|
||||||
|
|||||||
+176
-141
@@ -1,34 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface Reservation {
|
interface Trip {
|
||||||
id: number;
|
id: number;
|
||||||
date: string;
|
room_id: number | null;
|
||||||
time: string;
|
room_name: string | null;
|
||||||
guests: number;
|
check_in: string;
|
||||||
table_name: string;
|
check_out: string;
|
||||||
|
notes: string | null;
|
||||||
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Coupon {
|
interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
code: string;
|
sender_role: 'user' | 'admin';
|
||||||
discount: string;
|
message: string;
|
||||||
}
|
created_at: string;
|
||||||
|
|
||||||
interface Offer {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortalData {
|
interface PortalData {
|
||||||
reservations: Reservation[];
|
trips: Trip[];
|
||||||
coupons: Coupon[];
|
|
||||||
offers: Offer[];
|
|
||||||
benefits: string[];
|
|
||||||
wifiPassword: string;
|
wifiPassword: string;
|
||||||
|
messages: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const gold = '#E8A849';
|
const gold = '#E8A849';
|
||||||
@@ -40,24 +35,76 @@ export default function UserPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [data, setData] = useState<PortalData | null>(null);
|
const [data, setData] = useState<PortalData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/user/portal', { credentials: 'same-origin' })
|
fetchData();
|
||||||
.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));
|
|
||||||
}, [router]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
|
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
|
||||||
@@ -67,135 +114,123 @@ export default function UserPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
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 (
|
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' }}>
|
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 2rem' }}>
|
||||||
Member Portal
|
Your Portal
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Section title="Future Reservations">
|
{/* Welcome Message - Show on reservation day */}
|
||||||
{data.reservations.length === 0 ? (
|
{activeTrip && (
|
||||||
<p style={{ color: '#777' }}>No upcoming reservations.</p>
|
<Section title="Welcome!" highlight>
|
||||||
) : (
|
<div style={{ background: 'linear-gradient(135deg, #E8A849 0%, #d4963f 100%)', padding: '1.5rem', borderRadius: '16px', color: '#fff' }}>
|
||||||
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<p style={{ margin: '0 0 0.5rem', fontSize: '20px', fontWeight: 600 }}>
|
||||||
{data.reservations.map((r) => (
|
Welcome to Hosteria La Huasca!
|
||||||
<li
|
</p>
|
||||||
key={r.id}
|
<p style={{ margin: '0', opacity: 0.9 }}>
|
||||||
style={{
|
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' })}
|
||||||
padding: '1rem 1.25rem',
|
</p>
|
||||||
background: cream,
|
</div>
|
||||||
borderRadius: '16px',
|
</Section>
|
||||||
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>
|
|
||||||
|
|
||||||
<Section title="Discount Coupons">
|
{/* Future Trip Info */}
|
||||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
{upcomingTrip && (
|
||||||
{data.coupons.map((c) => (
|
<Section title="Your Upcoming Trip">
|
||||||
<div
|
<div style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
|
||||||
key={c.id}
|
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||||
style={{
|
<div>
|
||||||
padding: '1.25rem',
|
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-in</div>
|
||||||
background: cream,
|
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{new Date(upcomingTrip.check_in).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div>
|
||||||
border: '2px dashed ' + warm,
|
</div>
|
||||||
borderRadius: '16px',
|
<div>
|
||||||
minWidth: '13rem',
|
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-out</div>
|
||||||
color: navy,
|
<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 && (
|
||||||
<strong style={{ fontSize: '18px', color: warm }}>{c.code}</strong>
|
<div>
|
||||||
<p style={{ margin: '0.5rem 0 0', color: '#555', fontSize: '14px' }}>{c.discount}</p>
|
<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>
|
||||||
))}
|
{upcomingTrip.notes && (
|
||||||
</div>
|
<div style={{ fontSize: '14px', color: '#555', marginTop: '0.5rem' }}>
|
||||||
</Section>
|
<strong>Notes:</strong> {upcomingTrip.notes}
|
||||||
|
</div>
|
||||||
<Section title="Offers">
|
)}
|
||||||
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ marginTop: '1rem', fontSize: '13px', color: '#777' }}>
|
||||||
{data.offers.map((o) => (
|
Status: <span style={{ color: warm, fontWeight: 500, textTransform: 'capitalize' }}>{upcomingTrip.status.replace('_', ' ')}</span>
|
||||||
<li
|
</div>
|
||||||
key={o.id}
|
</div>
|
||||||
style={{
|
</Section>
|
||||||
padding: '1rem 1.25rem',
|
)}
|
||||||
background: cream,
|
|
||||||
borderRadius: '16px',
|
|
||||||
color: navy,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ color: warm }}>{o.title}</strong> — {o.description}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
|
{/* WiFi Password */}
|
||||||
<Section title="WiFi Password">
|
<Section title="WiFi Password">
|
||||||
<div
|
<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)' }}>
|
||||||
style={{
|
{data.wifiPassword || 'Not available'}
|
||||||
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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Member Benefits">
|
{/* Chat with Admins */}
|
||||||
<ul style={{ padding: 0, listStyle: 'none', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
|
<Section title="Message Staff">
|
||||||
{data.benefits.map((b) => (
|
<div style={{ background: cream, borderRadius: '16px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
|
||||||
<li
|
<div style={{ maxHeight: '300px', overflowY: 'auto', padding: '1rem' }}>
|
||||||
key={b}
|
{data.messages.length === 0 ? (
|
||||||
style={{
|
<div style={{ textAlign: 'center', color: '#777', padding: '2rem' }}>
|
||||||
padding: '0.9rem 1.1rem',
|
No messages yet. Send a message to contact our staff.
|
||||||
background: '#e8eff3',
|
</div>
|
||||||
borderLeft: '4px solid ' + gold,
|
) : (
|
||||||
borderRadius: '0 12px 12px 0',
|
data.messages.map((m) => (
|
||||||
color: navy,
|
<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' }}>
|
||||||
{b}
|
{m.sender_role === 'admin' ? 'Staff' : 'You'}
|
||||||
</li>
|
</div>
|
||||||
))}
|
<div style={{ fontSize: '14px' }}>{m.message}</div>
|
||||||
</ul>
|
<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>
|
</Section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children, highlight }: { title: string; children: React.ReactNode; highlight?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section style={{ marginBottom: '2.5rem' }}>
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
<h2
|
<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' }}>
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,6 +233,29 @@ export async function initSchema() {
|
|||||||
UNIQUE(room_id, date)
|
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() {
|
export async function migrateFromLegacyPlaces() {
|
||||||
|
|||||||
Reference in New Issue
Block a user