From 0ca4f961f63fb09e3a39a397d95a4874f4e31b36 Mon Sep 17 00:00:00 2001 From: muken Date: Thu, 2 Jul 2026 20:29:17 -0500 Subject: [PATCH] feat: add reservation system for public and private events - Add private_event_reservations table with contact info fields - Add status column to social_event_reservations (pending/confirmed/cancelled) - Create /api/private-event-reservations CRUD endpoints - Update social_event_reservations API to support guest submissions - Add Reservations tab to Public Events admin section - Add full reservation management UI for Private Events - Support filtering by status (pending/confirmed/cancelled/all) - Allow confirm/cancel/reactivate/delete actions --- src/app/admin/page.tsx | 226 ++++++++++++++++-- .../private-event-reservations/[id]/route.ts | 82 +++++++ .../api/private-event-reservations/route.ts | 65 +++++ .../social_event_reservations/[id]/route.ts | 78 ++++++ .../api/social_event_reservations/route.ts | 50 +++- src/lib/schema.ts | 26 ++ 6 files changed, 495 insertions(+), 32 deletions(-) create mode 100644 src/app/api/private-event-reservations/[id]/route.ts create mode 100644 src/app/api/private-event-reservations/route.ts create mode 100644 src/app/api/social_event_reservations/[id]/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 94da970..9f03b75 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -767,6 +767,44 @@ function CalendarEditor({ event, setEvent, onSave, onCancel }: any) { function SocialSection({ events, setEvents, images, onToast }: any) { const [editing, setEditing] = useState(null); const [confirmDel, setConfirmDel] = useState(null); + const [tab, setTab] = useState<'events' | 'reservations'>('events'); + const [reservations, setReservations] = useState([]); + const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending'); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (tab === 'reservations') loadReservations(); + }, [tab, statusFilter]); + + const loadReservations = async () => { + setLoading(true); + try { + const url = statusFilter === 'all' ? '/api/social_event_reservations' : `/api/social_event_reservations?status=${statusFilter}`; + const res = await fetch(url); + const data = await res.json(); + setReservations(data.data || []); + } catch { onToast('Error loading reservations', 'error'); } + setLoading(false); + }; + + const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => { + try { + await fetch(`/api/social_event_reservations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + setReservations(r => r.map(x => x.id === id ? { ...x, status } : x)); + onToast(`Reservation ${status}`, 'success'); + } catch { onToast('Error updating reservation', 'error'); } + }; + + const delReservation = async (id: number) => { + await fetch(`/api/social_event_reservations/${id}`, { method: 'DELETE' }); + setReservations(r => r.filter(x => x.id !== id)); + onToast('Reservation deleted', 'success'); + setConfirmDel(null); + }; const save = async (ev: SocialEvent) => { try { @@ -792,27 +830,88 @@ function SocialSection({ events, setEvents, images, onToast }: any) { setConfirmDel(null); }; + const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : '-'; + const contactMethodIcon = (m: string) => m === 'email' ? '✉️' : m === 'whatsapp' ? '📱' : '📞'; + return (
- setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, sort_order: events.length })} size="sm">+ New Event} /> - {events.length === 0 ? : ( -
- {events.map((ev: SocialEvent) => ( - - setEditing(ev)}>Edit - setConfirmDel(ev.id)}>Del - - } /> - ))} -
+
+ setTab('events')}>🎪 Events + setTab('reservations')}>📅 Reservations +
+ + {tab === 'events' ? ( + <> + setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, sort_order: events.length })} size="sm">+ New Event} /> + {events.length === 0 ? : ( +
+ {events.map((ev: SocialEvent) => ( + + setEditing(ev)}>Edit + setConfirmDel(ev.id)}>Del + + } /> + ))} +
+ )} + {editing && setEditing(null)} />} + {confirmDel !== null && setConfirmDel(null)} onConfirm={() => del(confirmDel)} />} + + ) : ( + <> + +
+ {(['pending', 'confirmed', 'cancelled', 'all'] as const).map(s => ( + setStatusFilter(s)}> + {s.charAt(0).toUpperCase() + s.slice(1)} + + ))} +
+ {loading ? : reservations.length === 0 ? : ( +
+ {reservations.map((r: any) => ( +
+
+
+
{r.event_name}
+
{r.guests} guests
+
+ {r.status} +
+
+
Contact: {r.contact_name || r.first_name || r.username || '-'}
+
Method: {r.contact_method ? contactMethodIcon(r.contact_method) : '-'} {r.contact_method || '-'}
+ {r.contact_email &&
Email: {r.contact_email}
} + {r.contact_phone &&
Phone: {r.contact_phone}
} +
+ {r.notes &&
"{r.notes}"
} +
+ {r.status === 'pending' && ( + <> + updateStatus(r.id, 'confirmed')}>✓ Confirm + updateStatus(r.id, 'cancelled')}>✗ Cancel + + )} + {r.status === 'confirmed' && updateStatus(r.id, 'cancelled')}>✗ Cancel} + {r.status === 'cancelled' && updateStatus(r.id, 'confirmed')}>✓ Reactivate} + setConfirmDel(r.id)}>🗑 Delete +
+
+ ))} +
+ )} + {confirmDel !== null && setConfirmDel(null)} onConfirm={() => delReservation(confirmDel)} />} + )} - {editing && setEditing(null)} />} - {confirmDel !== null && setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
); } @@ -877,10 +976,97 @@ function SocialEditor({ event, setEvent, images, onSave, onCancel }: any) { /* ─── Rooms Section ─── */ function PrivateEventsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) { + const [reservations, setReservations] = useState([]); + const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending'); + const [loading, setLoading] = useState(true); + const [confirmDel, setConfirmDel] = useState(null); + + useEffect(() => { + loadReservations(); + }, [statusFilter]); + + const loadReservations = async () => { + setLoading(true); + try { + const url = statusFilter === 'all' ? '/api/private-event-reservations' : `/api/private-event-reservations?status=${statusFilter}`; + const res = await fetch(url); + const data = await res.json(); + setReservations(data.data || []); + } catch { onToast('Error loading reservations', 'error'); } + setLoading(false); + }; + + const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => { + try { + await fetch(`/api/private-event-reservations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + 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) => { + await fetch(`/api/private-event-reservations/${id}`, { method: 'DELETE' }); + setReservations(r => r.filter(x => x.id !== id)); + onToast('Reservation deleted', 'success'); + setConfirmDel(null); + }; + + const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : '-'; + const formatTime = (t: string | null) => t || '-'; + const contactMethodIcon = (m: string) => m === 'email' ? '✉️' : m === 'whatsapp' ? '📱' : '📞'; + return (
- - + +
+ {(['pending', 'confirmed', 'cancelled', 'all'] as const).map(s => ( + setStatusFilter(s)}> + {s.charAt(0).toUpperCase() + s.slice(1)} + + ))} +
+ {loading ? : reservations.length === 0 ? : ( +
+ {reservations.map((r: any) => ( +
+
+
+
{r.event_name}
+
{r.guests} guests • {formatDate(r.event_date)} • {formatTime(r.event_time)}
+
+ {r.status} +
+
+
Contact: {r.contact_name}
+
Method: {contactMethodIcon(r.contact_method)} {r.contact_method}
+ {r.contact_email &&
Email: {r.contact_email}
} + {r.contact_phone &&
Phone: {r.contact_phone}
} +
+ {r.notes &&
"{r.notes}"
} +
+ {r.status === 'pending' && ( + <> + updateStatus(r.id, 'confirmed')}>✓ Confirm + updateStatus(r.id, 'cancelled')}>✗ Cancel + + )} + {r.status === 'confirmed' && updateStatus(r.id, 'cancelled')}>✗ Cancel} + {r.status === 'cancelled' && updateStatus(r.id, 'confirmed')}>✓ Reactivate} + setConfirmDel(r.id)}>🗑 Delete +
+
+ ))} +
+ )} + {confirmDel !== null && setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
); } diff --git a/src/app/api/private-event-reservations/[id]/route.ts b/src/app/api/private-event-reservations/[id]/route.ts new file mode 100644 index 0000000..4266fa2 --- /dev/null +++ b/src/app/api/private-event-reservations/[id]/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET - Get a single private event reservation +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { rows } = await db.query( + 'SELECT * FROM private_event_reservations WHERE id = $1', + [params.id] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: 'Reservation not found' }, { status: 404 }); + } + + return NextResponse.json({ data: rows[0] }); + } catch (error) { + console.error('Error fetching reservation:', error); + return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 }); + } +} + +// PUT - Update reservation (status, notes, etc.) +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + const { status, notes } = body; + + if (status && !['pending', 'confirmed', 'cancelled'].includes(status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }); + } + + const updates: string[] = []; + const values: any[] = []; + let paramCount = 1; + + if (status) { + updates.push(`status = $${paramCount++}`); + values.push(status); + } + if (notes !== undefined) { + updates.push(`notes = $${paramCount++}`); + values.push(notes); + } + + if (updates.length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }); + } + + values.push(params.id); + + const { rows } = await db.query( + `UPDATE private_event_reservations SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`, + values + ); + + return NextResponse.json({ data: rows[0] }); + } catch (error) { + console.error('Error updating reservation:', error); + return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 }); + } +} + +// DELETE - Delete a reservation +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await db.query('DELETE FROM private_event_reservations WHERE id = $1', [params.id]); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting reservation:', error); + return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/private-event-reservations/route.ts b/src/app/api/private-event-reservations/route.ts new file mode 100644 index 0000000..ac75625 --- /dev/null +++ b/src/app/api/private-event-reservations/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET - List all private event reservations (with optional status filter) +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const status = searchParams.get('status'); + + let query = 'SELECT * FROM private_event_reservations'; + const params: any[] = []; + + if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) { + query += ' WHERE status = $1 ORDER BY created_at DESC'; + params.push(status); + } else { + query += ' ORDER BY status = \'pending\' DESC, created_at DESC'; + } + + const { rows } = await db.query(query, params); + return NextResponse.json({ data: rows }); + } catch (error) { + console.error('Error fetching private event reservations:', error); + return NextResponse.json({ error: 'Failed to fetch reservations' }, { status: 500 }); + } +} + +// POST - Create a new private event reservation (public endpoint) +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + event_name, + contact_name, + contact_email, + contact_phone, + contact_method, + event_date, + event_time, + guests, + notes + } = body; + + if (!event_name || !contact_name || !contact_method || !guests) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + if (!['email', 'phone', 'whatsapp'].includes(contact_method)) { + return NextResponse.json({ error: 'Invalid contact method' }, { status: 400 }); + } + + const { rows } = await db.query( + `INSERT INTO private_event_reservations + (event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guests, notes, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending') + RETURNING *`, + [event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guests, notes] + ); + + return NextResponse.json({ data: rows[0] }); + } catch (error) { + console.error('Error creating private event reservation:', error); + return NextResponse.json({ error: 'Failed to create reservation' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/social_event_reservations/[id]/route.ts b/src/app/api/social_event_reservations/[id]/route.ts new file mode 100644 index 0000000..2d50e22 --- /dev/null +++ b/src/app/api/social_event_reservations/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { requireAuth } from '@/lib/auth'; + +// GET - Get a single social event reservation +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await requireAuth(); + const { rows } = await db.query( + `SELECT ser.*, se.name as event_name + FROM social_event_reservations ser + JOIN social_events se ON se.id = ser.social_event_id + WHERE ser.id = $1`, + [params.id] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: 'Reservation not found' }, { status: 404 }); + } + + return NextResponse.json({ data: rows[0] }); + } catch (error) { + console.error('Error fetching reservation:', error); + return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 }); + } +} + +// PUT - Update reservation status (admin only) +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await requireAuth(); + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + const body = await request.json(); + const { status } = body; + + if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }); + } + + const { rows } = await db.query( + `UPDATE social_event_reservations SET status = $1 WHERE id = $2 RETURNING *`, + [status, params.id] + ); + + return NextResponse.json({ data: rows[0] }); + } catch (error) { + console.error('Error updating reservation:', error); + return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 }); + } +} + +// DELETE - Delete a reservation (admin only) +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await requireAuth(); + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + await db.query('DELETE FROM social_event_reservations WHERE id = $1', [params.id]); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting reservation:', error); + return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/social_event_reservations/route.ts b/src/app/api/social_event_reservations/route.ts index 77af909..58d9a59 100644 --- a/src/app/api/social_event_reservations/route.ts +++ b/src/app/api/social_event_reservations/route.ts @@ -7,20 +7,31 @@ export async function GET(request: Request) { const session = await requireAuth(); const { searchParams } = new URL(request.url); const socialEventId = searchParams.get('social_event_id'); + const status = searchParams.get('status'); let query = ` - SELECT ser.*, u.username, u.first_name, u.last_name, se.name as event_name + SELECT ser.*, se.name as event_name FROM social_event_reservations ser - JOIN users u ON u.id = ser.user_id JOIN social_events se ON se.id = ser.social_event_id `; const params: (number | string)[] = []; if (session.role === 'admin') { + const conditions: string[] = []; + let paramCount = 1; + if (socialEventId) { - query += ' WHERE ser.social_event_id = $1'; + conditions.push(`ser.social_event_id = $${paramCount++}`); params.push(parseInt(socialEventId, 10)); } + if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) { + conditions.push(`ser.status = $${paramCount++}`); + params.push(status); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } } else { query += ' WHERE ser.user_id = $1'; params.push(session.id); @@ -41,23 +52,38 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { - const session = await requireAuth(); const body = await request.json(); - const { social_event_id, guests, notes } = body; + const { social_event_id, guests, notes, contact_name, contact_email, contact_phone, contact_method } = body; + if (!social_event_id || !guests) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + + // Try to authenticate, but allow guest submissions + let userId: number | null = null; + try { + const session = await requireAuth(); + userId = session.id; + } catch { + // Guest submission - require contact info + if (!contact_name || !contact_method) { + return NextResponse.json({ error: 'Contact name and method required for guest reservations' }, { status: 400 }); + } + if (!['email', 'phone', 'whatsapp'].includes(contact_method)) { + return NextResponse.json({ error: 'Invalid contact method' }, { status: 400 }); + } + } + const { rows } = await db.query( - `INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id, social_event_id) - DO UPDATE SET guests = EXCLUDED.guests, notes = EXCLUDED.notes, status = 'pending' + `INSERT INTO social_event_reservations + (user_id, social_event_id, guests, notes, status, contact_name, contact_email, contact_phone, contact_method) + VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8) RETURNING *`, - [session.id, parseInt(social_event_id, 10), parseInt(guests, 10), notes || ''] + [userId, parseInt(social_event_id, 10), parseInt(guests, 10), notes || null, contact_name || null, contact_email || null, contact_phone || null, contact_method || null] ); return NextResponse.json({ data: rows[0] }); } catch (error: any) { console.error('POST /api/social_event_reservations error:', error); - return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: error.message || 'Failed to create reservation' }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 8404a03..9a9461f 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -404,6 +404,32 @@ export async function initSchema() { ); `); + await db.query(` + CREATE TABLE IF NOT EXISTS private_event_reservations ( + id SERIAL PRIMARY KEY, + event_name VARCHAR(255) NOT NULL, + contact_name VARCHAR(255) NOT NULL, + contact_email VARCHAR(255), + contact_phone VARCHAR(50), + contact_method VARCHAR(20) NOT NULL CHECK (contact_method IN ('email', 'phone', 'whatsapp')), + event_date DATE, + event_time TIME, + guests INTEGER NOT NULL, + notes TEXT, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')), + created_at TIMESTAMP DEFAULT NOW() + ); + `); + + // Add status column to social_event_reservations if it doesn't exist + await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled'))`); + + // Add contact columns to social_event_reservations + await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255)`); + await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255)`); + await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50)`); + await db.query(`ALTER TABLE social_event_reservations ALTER COLUMN user_id DROP NOT NULL`); + await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id)`); await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`); await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);