feat: add messaging system with admin bulk messages and user conversations

- Add conversations and direct_messages tables to schema
- User messaging page at /messages (users can start conversations, see admin responses)
- Admin messaging page at /admin/messages (view all conversations, reply to users)
- Admin bulk messaging: send to all customers, specific users, or admins
- Mail icon in navbar for logged-in users (links to appropriate messaging page)
- Show first name of admin who responded in conversation view
- Clean build cache after middleware changes
This commit is contained in:
2026-07-03 00:06:07 -05:00
parent dc0c5fd289
commit 1e66e918ac
24 changed files with 1862 additions and 171 deletions
+252 -1
View File
@@ -14,6 +14,9 @@ type Review = { id: number; author_name: string; author_email: string | null; ra
type FooterConfig = { logo: string; favicon: string; tagline: string; facebook_url: string; instagram_url: string; whatsapp_url: string; address: string; phone: string; email: string };
type SocialRes = { id: number; event_id: number; username: string; event_name: string; guests: number; notes: string; status: string; created_at: string };
type Amenity = { id: number; name: string; icon_svg: string; sort_order: number };
type RestaurantReservation = { id: number; user_id: number | null; date: string; time: string; guests: number; table_name: string; created_at: string; username?: string; first_name?: string; last_name?: string };
type RoomReservation = { id: number; room_id: number; user_id: number | null; check_in: string; check_out: string; status: string; total_price: string; guest_name: string | null; guest_contact_method: string | null; guest_contact: string | null; created_at: string; room_name?: string; username?: string; first_name?: string; last_name?: string };
type Order = { id: number; user_id: number | null; order_type: string; room_id: number | null; subtotal: string; service_fee: string; total: string; status: string; notes: string | null; created_at: string; room_name?: string; username?: string; first_name?: string; last_name?: string; items: { menu_item_id: number; name: string; quantity: number; unit_price: string; total_price: string }[] };
const C = {
gold: '#D4A853', goldLight: 'rgba(212,168,83,0.12)', navy: '#010D1E',
@@ -30,10 +33,13 @@ const NAV = [
{ id: 'social', label: 'Public Events', icon: '🎪' },
{ id: 'private-events', label: 'Private Events', icon: '🔒' },
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
{ id: 'room-reservations', label: 'Room Bookings', icon: '🛏️' },
{ id: 'restaurant-reservations', label: 'Restaurant', icon: '🍽️' },
{ id: 'orders', label: 'Food Orders', icon: '📦' },
{ id: 'amenities', label: 'Amenities', 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: 'reviews', label: 'Reviews', icon: '⭐' },
{ id: 'users', label: 'Users', icon: '👥' },
@@ -1071,6 +1077,248 @@ function PrivateEventsSection({ onToast }: { onToast: (msg: string, type: 'succe
);
}
/* ─── Room Reservations Section ─── */
function RoomReservationsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
const [reservations, setReservations] = useState<RoomReservation[]>([]);
const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending');
const [loading, setLoading] = useState(true);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
useEffect(() => { loadReservations(); }, [statusFilter]);
const loadReservations = async () => {
setLoading(true);
try {
const url = statusFilter === 'all' ? '/api/admin/room-reservations?status=all' : `/api/admin/room-reservations?status=${statusFilter}`;
const res = await fetch(url);
const data = await res.json();
setReservations(data.reservations || []);
} catch { onToast('Error loading reservations', 'error'); }
setLoading(false);
};
const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => {
try {
const res = await fetch('/api/admin/room-reservations', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
});
if (res.ok) {
setReservations(r => r.map(x => x.id === id ? { ...x, status } : x));
onToast(`Reservation ${status}`, 'success');
}
} catch { onToast('Error updating reservation', 'error'); }
};
const del = async (id: number) => {
try {
await fetch(`/api/admin/room-reservations?id=${id}`, { method: 'DELETE' });
setReservations(r => r.filter(x => x.id !== id));
onToast('Reservation deleted', 'success');
} catch { onToast('Error deleting reservation', 'error'); }
setConfirmDel(null);
};
const formatDate = (d: string) => new Date(d).toLocaleDateString();
const formatPrice = (p: string) => `$${parseFloat(p).toFixed(2)}`;
return (
<div>
<SecHead title="🛏️ Room Bookings" count={reservations.length} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
{(['pending', 'confirmed', 'cancelled', 'all'] as const).map(s => (
<Btn key={s} size="sm" variant={statusFilter === s ? 'primary' : 'secondary'} onClick={() => setStatusFilter(s)}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</Btn>
))}
</div>
{loading ? <Empty icon="⏳" title="Loading..." /> : reservations.length === 0 ? <Empty icon="🛏️" title="No bookings" desc={`No ${statusFilter === 'all' ? '' : statusFilter} bookings`} /> : (
<div style={{ display: 'grid', gap: '12px' }}>
{reservations.map((r) => (
<div key={r.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${r.status === 'pending' ? '#f59e0b' : r.status === 'confirmed' ? '#10b981' : '#ef4444'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{r.room_name || `Room #${r.room_id}`}</div>
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
{formatDate(r.check_in)} {formatDate(r.check_out)} {formatPrice(r.total_price)}
</div>
</div>
<span style={{
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
background: r.status === 'confirmed' ? '#d1fae5' : r.status === 'cancelled' ? '#fee2e2' : '#fef3c7',
color: r.status === 'confirmed' ? '#047857' : r.status === 'cancelled' ? '#b91c1c' : '#b45309'
}}>{r.status}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '13px', marginBottom: '12px' }}>
<div><strong>Guest:</strong> {r.guest_name || r.username || r.first_name || 'N/A'}</div>
{r.guest_contact && <div><strong>Contact:</strong> {r.guest_contact}</div>}
{r.guest_contact_method && <div><strong>Method:</strong> {r.guest_contact_method}</div>}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{r.status === 'pending' && (
<>
<Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}> Confirm</Btn>
<Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}> Cancel</Btn>
</>
)}
{r.status === 'confirmed' && <Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}> Cancel</Btn>}
{r.status === 'cancelled' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}> Reactivate</Btn>}
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(r.id)}>🗑 Delete</Btn>
</div>
</div>
))}
</div>
)}
{confirmDel !== null && <Confirm message="Delete this booking?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</div>
);
}
/* ─── Restaurant Reservations Section ─── */
function RestaurantReservationsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
const [reservations, setReservations] = useState<RestaurantReservation[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadReservations(); }, []);
const loadReservations = async () => {
setLoading(true);
try {
const res = await fetch('/api/admin/restaurant-reservations');
const data = await res.json();
setReservations(data.reservations || []);
} catch { onToast('Error loading reservations', 'error'); }
setLoading(false);
};
const del = async (id: number) => {
try {
await fetch(`/api/admin/restaurant-reservations?id=${id}`, { method: 'DELETE' });
setReservations(r => r.filter(x => x.id !== id));
onToast('Reservation deleted', 'success');
} catch { onToast('Error deleting reservation', 'error'); }
};
return (
<div>
<SecHead title="🍽️ Restaurant Reservations" count={reservations.length} />
{loading ? <Empty icon="⏳" title="Loading..." /> : reservations.length === 0 ? <Empty icon="🍽️" title="No reservations" desc="No restaurant reservations yet" /> : (
<div style={{ display: 'grid', gap: '12px' }}>
{reservations.map((r) => (
<div key={r.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${C.border}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{r.table_name}</div>
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
{formatDisplayDate(r.date)} at {r.time} {r.guests} guests
</div>
</div>
<div style={{ fontSize: '12px', color: C.textLight }}>{formatDisplayDateTime(r.created_at)}</div>
</div>
<div style={{ fontSize: '13px', color: '#666', marginBottom: '8px' }}>
<strong>Guest:</strong> {r.username || r.first_name || 'Guest'}
</div>
<Btn size="sm" variant="danger" onClick={() => del(r.id)}>🗑 Delete</Btn>
</div>
))}
</div>
)}
</div>
);
}
/* ─── Orders Section ─── */
function OrdersSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
const [orders, setOrders] = useState<Order[]>([]);
const [statusFilter, setStatusFilter] = useState<'pending' | 'preparing' | 'ready' | 'delivered' | 'cancelled' | 'all'>('pending');
const [loading, setLoading] = useState(true);
useEffect(() => { loadOrders(); }, [statusFilter]);
const loadOrders = async () => {
setLoading(true);
try {
const url = statusFilter === 'all' ? '/api/orders?status=all' : `/api/orders?status=${statusFilter}`;
const res = await fetch(url);
const data = await res.json();
setOrders(data.orders || []);
} catch { onToast('Error loading orders', 'error'); }
setLoading(false);
};
const updateStatus = async (id: number, status: string) => {
try {
const res = await fetch('/api/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: id, status }),
});
if (res.ok) {
setOrders(o => o.map(x => x.id === id ? { ...x, status } : x));
onToast(`Order ${status}`, 'success');
}
} catch { onToast('Error updating order', 'error'); }
};
const formatPrice = (p: string) => `$${parseFloat(p).toFixed(2)}`;
const statusColor = (s: string) => s === 'pending' ? '#f59e0b' : s === 'preparing' ? '#3b82f6' : s === 'ready' ? '#10b981' : s === 'delivered' ? '#6b7280' : '#ef4444';
return (
<div>
<SecHead title="📦 Food Orders" count={orders.length} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px', flexWrap: 'wrap' }}>
{(['pending', 'preparing', 'ready', 'delivered', 'cancelled', 'all'] as const).map(s => (
<Btn key={s} size="sm" variant={statusFilter === s ? 'primary' : 'secondary'} onClick={() => setStatusFilter(s)}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</Btn>
))}
</div>
{loading ? <Empty icon="⏳" title="Loading..." /> : orders.length === 0 ? <Empty icon="📦" title="No orders" desc={`No ${statusFilter === 'all' ? '' : statusFilter} orders`} /> : (
<div style={{ display: 'grid', gap: '12px' }}>
{orders.map((o) => (
<div key={o.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${statusColor(o.status)}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>
{o.order_type === 'room_delivery' ? `🚪 Room ${o.room_name || o.room_id}` : o.order_type === 'dine_in' ? '🍽️ Dine-in' : '📦 Pickup'}
</div>
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
{formatPrice(o.total)} {formatDisplayDateTime(o.created_at)}
</div>
</div>
<span style={{
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
background: `${statusColor(o.status)}20`, color: statusColor(o.status)
}}>{o.status}</span>
</div>
<div style={{ marginBottom: '12px' }}>
{o.items?.map((item, i) => (
<div key={i} style={{ fontSize: '13px', color: '#666', display: 'flex', justifyContent: 'space-between' }}>
<span>{item.quantity}x {item.name}</span>
<span>{formatPrice(item.total_price)}</span>
</div>
))}
</div>
{o.notes && <div style={{ fontSize: '13px', color: '#666', fontStyle: 'italic', marginBottom: '12px' }}>"{o.notes}"</div>}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{o.status === 'pending' && (
<>
<Btn size="sm" variant="secondary" onClick={() => updateStatus(o.id, 'preparing')}>🍳 Start Preparing</Btn>
<Btn size="sm" variant="danger" onClick={() => updateStatus(o.id, 'cancelled')}> Cancel</Btn>
</>
)}
{o.status === 'preparing' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(o.id, 'ready')}> Ready</Btn>}
{o.status === 'ready' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(o.id, 'delivered')}> Delivered</Btn>}
</div>
</div>
))}
</div>
)}
</div>
);
}
function RoomsSection({ rooms, setRooms, images, onToast }: any) {
const [editing, setEditing] = useState<Room | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
@@ -2753,6 +3001,9 @@ export default function AdminPage() {
case 'social': return <SocialSection events={socialEvents} setEvents={setSocialEvents} images={images} onToast={showToast} />;
case 'private-events': return <PrivateEventsSection onToast={showToast} />;
case 'rooms': return <RoomsSection rooms={rooms} setRooms={setRooms} images={images} onToast={showToast} />;
case 'room-reservations': return <RoomReservationsSection onToast={showToast} />;
case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />;
case 'orders': return <OrdersSection onToast={showToast} />;
case 'amenities': return <AmenitiesSection onToast={showToast} />;
case 'menu': return <MenuSection items={menuItems} setItems={setMenuItems} images={images} onToast={showToast} />;
case 'places': return <PlacesSection places={places} setPlaces={setPlaces} images={images} onToast={showToast} />;