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} />;