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:
+47
-36
@@ -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} />;
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user