From 4e514c2fbb07d26638879515309a5417a7bbedd4 Mon Sep 17 00:00:00 2001 From: muken Date: Fri, 3 Jul 2026 00:27:44 -0500 Subject: [PATCH] feat: admins can create private events with max_guests - Added is_private and max_guests columns to social_events table - Updated SocialEvent type with new fields - Modified /api/social_events to include private events for admins - Merged Public/Private Events into single 'Events' section with tabs - SocialEditor now supports private event creation with max_guests field - Public frontend still only sees public events --- src/app/admin/page.tsx | 83 ++++++++++++++----------- src/app/api/social_events/[id]/route.ts | 8 +-- src/app/api/social_events/route.ts | 29 ++++++--- src/lib/schema.ts | 6 ++ 4 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5c268a5..aac3c9d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -7,7 +7,7 @@ type Image = { id: number; filename: string; data: string; category: string; roo type Slide = { id: number; title: string; subtitle: string; image_url: string | null; image_id: number | null; active: boolean; sort_order: number }; type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number; amenity_ids?: number[] }; type CalendarEvent = { id: number; date: string; title: string; type: string; color: string; description: string | null; active: boolean }; -type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number }; +type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; is_private: boolean; max_guests: number | null; sort_order: number }; type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number; photos: string[]; featured_photo: string | null }; type Place = { id: number; name: string; description: string; photos: string[]; featured_photo: string | null; distance_km: string; visit_time: string; suggestion: string; active: boolean; sort_order: number }; type Review = { id: number; author_name: string; author_email: string | null; rating: number; text: string; approved: boolean }; @@ -30,8 +30,7 @@ const NAV = [ { id: 'gallery', label: 'Images', icon: '๐Ÿ–ผ๏ธ' }, { id: 'slides', label: 'Slideshow', icon: '๐ŸŽž๏ธ' }, { id: 'calendar', label: 'Calendar', icon: '๐Ÿ“…' }, - { id: 'social', label: 'Public Events', icon: '๐ŸŽช' }, - { id: 'private-events', label: 'Private Events', icon: '๐Ÿ”’' }, + { id: 'social', label: 'Events', icon: '๐ŸŽช' }, { id: 'rooms', label: 'Rooms', icon: '๐Ÿจ' }, { id: 'room-status', label: 'Room Status', icon: '๐Ÿงน' }, { id: 'room-assignments', label: 'Assignments', icon: '๐Ÿ”‘' }, @@ -775,12 +774,16 @@ function CalendarEditor({ event, setEvent, onSave, onCancel }: any) { /* โ”€โ”€โ”€ Social Events Section โ”€โ”€โ”€ */ 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 [confirmDelEvent, setConfirmDelEvent] = useState(null); + const [confirmDelReservation, setConfirmDelReservation] = useState(null); + const [tab, setTab] = useState<'public' | 'private' | 'reservations'>('public'); const [reservations, setReservations] = useState([]); const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending'); const [loading, setLoading] = useState(false); + const publicEvents = events.filter((e: SocialEvent) => !e.is_private); + const privateEvents = events.filter((e: SocialEvent) => e.is_private); + useEffect(() => { if (tab === 'reservations') loadReservations(); }, [tab, statusFilter]); @@ -812,7 +815,7 @@ function SocialSection({ events, setEvents, images, onToast }: any) { await fetch(`/api/social_event_reservations/${id}`, { method: 'DELETE' }); setReservations(r => r.filter(x => x.id !== id)); onToast('Reservation deleted', 'success'); - setConfirmDel(null); + setConfirmDelReservation(null); }; const save = async (ev: SocialEvent) => { @@ -836,42 +839,45 @@ function SocialSection({ events, setEvents, images, onToast }: any) { await fetch(`/api/social_events/${id}`, { method: 'DELETE' }); setEvents((e: any) => e.filter((x: SocialEvent) => x.id !== id)); onToast('Event deleted', 'success'); - setConfirmDel(null); + setConfirmDelEvent(null); }; const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : '-'; const contactMethodIcon = (m: string) => m === 'email' ? 'โœ‰๏ธ' : m === 'whatsapp' ? '๐Ÿ“ฑ' : '๐Ÿ“ž'; + const renderEventList = (eventList: SocialEvent[], title: string, icon: string) => ( + <> + setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, is_private: icon === '๐Ÿ”’', max_guests: null, sort_order: events.length })} size="sm">+ New Event} /> + {eventList.length === 0 ? : ( +
+ {eventList.map((ev: SocialEvent) => ( + + setEditing(ev)}>Edit + setConfirmDelEvent(ev.id)}>Del + + } /> + ))} +
+ )} + + ); + return (
- setTab('events')}>๐ŸŽช Events + setTab('public')}>๐ŸŽช Public + setTab('private')}>๐Ÿ”’ Private 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)} />} - - ) : ( + {tab === 'public' && renderEventList(publicEvents, 'Public Events', '๐ŸŽช')} + {tab === 'private' && renderEventList(privateEvents, 'Private Events', '๐Ÿ”’')} + {tab === 'reservations' && ( <>
@@ -912,15 +918,17 @@ function SocialSection({ events, setEvents, images, onToast }: any) { )} {r.status === 'confirmed' && updateStatus(r.id, 'cancelled')}>โœ— Cancel} {r.status === 'cancelled' && updateStatus(r.id, 'confirmed')}>โœ“ Reactivate} - setConfirmDel(r.id)}>๐Ÿ—‘ Delete + setConfirmDelReservation(r.id)}>๐Ÿ—‘ Delete
))} )} - {confirmDel !== null && setConfirmDel(null)} onConfirm={() => delReservation(confirmDel)} />} + {confirmDelReservation !== null && setConfirmDelReservation(null)} onConfirm={() => delReservation(confirmDelReservation)} />} )} + {editing && setEditing(null)} />} + {confirmDelEvent !== null && setConfirmDelEvent(null)} onConfirm={() => del(confirmDelEvent)} />} ); } @@ -937,7 +945,7 @@ function SocialEditor({ event, setEvent, images, onSave, onCancel }: any) { try { const r = await fetch('/api/admin/images', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename: f.name, data: b64, category: 'social', room_id: null, location_target: 'social' }), + body: JSON.stringify({ filename: f.name, data: b64, category: event.is_private ? 'private' : 'social', room_id: null, location_target: event.is_private ? 'private' : 'social' }), }); if (r.ok) { const d = await r.json(); newPhotos.push(d.data.data); } } catch { const b = await readFileBase64(f); newPhotos.push(b); } @@ -972,6 +980,10 @@ function SocialEditor({ event, setEvent, images, onSave, onCancel }: any) { ))} + setEvent({ ...ev, is_private: v })} /> + {ev.is_private && ( + setEvent({ ...ev, max_guests: v ? parseInt(v) : null })} placeholder="Leave empty for unlimited" /> + )} setEvent({ ...ev, active: v })} />
@@ -3605,7 +3617,7 @@ export default function AdminPage() { fetch('/api/slides'), fetch('/api/rooms'), fetch('/api/calendar_events'), - fetch('/api/social_events/public'), + fetch('/api/social_events?includePrivate=true'), fetch('/api/admin/menu'), fetch('/api/places'), fetch('/api/reviews'), @@ -3646,7 +3658,6 @@ export default function AdminPage() { case 'slides': return ; case 'calendar': return ; case 'social': return ; - case 'private-events': return ; case 'rooms': return ; case 'room-status': return ; case 'room-reservations': return ; diff --git a/src/app/api/social_events/[id]/route.ts b/src/app/api/social_events/[id]/route.ts index dee2dbc..964c8c7 100644 --- a/src/app/api/social_events/[id]/route.ts +++ b/src/app/api/social_events/[id]/route.ts @@ -7,13 +7,13 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri await requireRole('admin'); const id = parseInt(params.id, 10); const body = await request.json(); - const { name, description, price, date, photos, featured_photo, active, sort_order } = body; + const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body; const { rows } = await db.query( `UPDATE social_events - SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8 - WHERE id=$9 + SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, is_private=$8, max_guests=$9, sort_order=$10 + WHERE id=$11 RETURNING *`, - [name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0, id] + [name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0, id] ); if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ data: rows[0] }); diff --git a/src/app/api/social_events/route.ts b/src/app/api/social_events/route.ts index 63f31f3..d3657b6 100644 --- a/src/app/api/social_events/route.ts +++ b/src/app/api/social_events/route.ts @@ -2,15 +2,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireRole } from '@/lib/auth'; import { db } from '@/lib/db'; -export async function GET() { +export async function GET(request: NextRequest) { try { - const { rows } = await db.query(` + const { searchParams } = new URL(request.url); + const includePrivate = searchParams.get('includePrivate') === 'true'; + + // Public requests only see active, non-private events + // Admin requests with includePrivate=true see all events + let query = ` SELECT se.*, (SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count FROM social_events se - WHERE se.active = TRUE - ORDER BY se.sort_order, se.date, se.id - `); + `; + + if (includePrivate) { + query += ' ORDER BY se.is_private, se.sort_order, se.date, se.id'; + } else { + query += ' WHERE se.active = TRUE AND (se.is_private = FALSE OR se.is_private IS NULL) ORDER BY se.sort_order, se.date, se.id'; + } + + const { rows } = await db.query(query); return NextResponse.json({ data: rows }); } catch (error: any) { console.error('GET /api/social_events error:', error); @@ -22,12 +33,12 @@ export async function POST(request: NextRequest) { try { await requireRole('admin'); const body = await request.json(); - const { name, description, price, date, photos, featured_photo, active, sort_order } = body; + const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body; const { rows } = await db.query( - `INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, - [name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0] + [name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0] ); return NextResponse.json({ data: rows[0] }); } catch (error: any) { diff --git a/src/lib/schema.ts b/src/lib/schema.ts index be0436e..51fc945 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -184,11 +184,17 @@ export async function initSchema() { photos TEXT[] DEFAULT '{}', featured_photo VARCHAR(500), active BOOLEAN DEFAULT TRUE, + is_private BOOLEAN DEFAULT FALSE, + max_guests INTEGER, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ); `); + // Add is_private and max_guests columns if they don't exist + await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE`); + await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS max_guests INTEGER`); + await db.query(` CREATE TABLE IF NOT EXISTS social_event_reservations ( id SERIAL PRIMARY KEY,