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
This commit is contained in:
2026-07-03 00:27:44 -05:00
parent bfc627f451
commit 4e514c2fbb
4 changed files with 77 additions and 49 deletions
+47 -36
View File
@@ -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<SocialEvent | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const [tab, setTab] = useState<'events' | 'reservations'>('events');
const [confirmDelEvent, setConfirmDelEvent] = useState<number | null>(null);
const [confirmDelReservation, setConfirmDelReservation] = useState<number | null>(null);
const [tab, setTab] = useState<'public' | 'private' | 'reservations'>('public');
const [reservations, setReservations] = useState<any[]>([]);
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) => (
<>
<SecHead title={`${icon} ${title}`} count={eventList.length}
action={<Btn onClick={() => 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</Btn>} />
{eventList.length === 0 ? <Empty icon={icon} title={`No ${title.toLowerCase()}`} /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{eventList.map((ev: SocialEvent) => (
<EntityCard key={ev.id} title={ev.name} desc={ev.description || ''} meta={`${ev.price || 'Free'}${ev.date ? ' • ' + ev.date : ''}${ev.max_guests ? ' • Max ' + ev.max_guests + ' guests' : ''}`}
thumb={ev.featured_photo || ev.photos?.[0]}
photoUrls={ev.photos}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(ev)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDelEvent(ev.id)}>Del</Btn>
</>
} />
))}
</div>
)}
</>
);
return (
<div>
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', borderBottom: '1px solid #e5e7eb', paddingBottom: '12px' }}>
<Btn size="sm" variant={tab === 'events' ? 'primary' : 'secondary'} onClick={() => setTab('events')}>🎪 Events</Btn>
<Btn size="sm" variant={tab === 'public' ? 'primary' : 'secondary'} onClick={() => setTab('public')}>🎪 Public</Btn>
<Btn size="sm" variant={tab === 'private' ? 'primary' : 'secondary'} onClick={() => setTab('private')}>🔒 Private</Btn>
<Btn size="sm" variant={tab === 'reservations' ? 'primary' : 'secondary'} onClick={() => setTab('reservations')}>📅 Reservations</Btn>
</div>
{tab === 'events' ? (
<>
<SecHead title="🎪 Public Events" count={events.length}
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, sort_order: events.length })} size="sm">+ New Event</Btn>} />
{events.length === 0 ? <Empty icon="🎪" title="No public events" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{events.map((ev: SocialEvent) => (
<EntityCard key={ev.id} title={ev.name} desc={ev.description || ''} meta={`${ev.price || 'Free'}${ev.date ? ' • ' + ev.date : ''}`}
thumb={ev.featured_photo || ev.photos?.[0]}
photoUrls={ev.photos}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(ev)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(ev.id)}>Del</Btn>
</>
} />
))}
</div>
)}
{editing && <SocialEditor event={editing} setEvent={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDel !== null && <Confirm message="Delete this event?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</>
) : (
{tab === 'public' && renderEventList(publicEvents, 'Public Events', '🎪')}
{tab === 'private' && renderEventList(privateEvents, 'Private Events', '🔒')}
{tab === 'reservations' && (
<>
<SecHead title="📅 Public Event Reservations" count={reservations.length} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
@@ -912,15 +918,17 @@ function SocialSection({ events, setEvents, images, onToast }: any) {
)}
{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>
<Btn size="sm" variant="danger" onClick={() => setConfirmDelReservation(r.id)}>🗑 Delete</Btn>
</div>
</div>
))}
</div>
)}
{confirmDel !== null && <Confirm message="Delete this reservation?" onCancel={() => setConfirmDel(null)} onConfirm={() => delReservation(confirmDel)} />}
{confirmDelReservation !== null && <Confirm message="Delete this reservation?" onCancel={() => setConfirmDelReservation(null)} onConfirm={() => delReservation(confirmDelReservation)} />}
</>
)}
{editing && <SocialEditor event={editing} setEvent={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDelEvent !== null && <Confirm message="Delete this event?" onCancel={() => setConfirmDelEvent(null)} onConfirm={() => del(confirmDelEvent)} />}
</div>
);
}
@@ -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) {
))}
</div>
</div>
<Tgl label="Private Event" checked={ev.is_private || false} onChange={(v: boolean) => setEvent({ ...ev, is_private: v })} />
{ev.is_private && (
<I label="Max Guests" type="number" value={ev.max_guests || ''} onChange={(v: string) => setEvent({ ...ev, max_guests: v ? parseInt(v) : null })} placeholder="Leave empty for unlimited" />
)}
<Tgl label="Active" checked={ev.active} onChange={(v: boolean) => setEvent({ ...ev, active: v })} />
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
@@ -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 <SlidesSection slides={slides} setSlides={setSlides} images={images} onToast={showToast} onRefreshImages={loadImages} />;
case 'calendar': return <CalendarSection events={events} setEvents={setEvents} images={images} onToast={showToast} />;
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-status': return <RoomStatusSection onToast={showToast} />;
case 'room-reservations': return <RoomReservationsSection onToast={showToast} />;
+4 -4
View File
@@ -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] });
+20 -9
View File
@@ -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) {
+6
View File
@@ -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,