diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ac59373..a78444d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -84,15 +84,6 @@ interface Review { approved: boolean; } -interface User { - id: number; - username: string; - role: 'admin' | 'user'; - first_name: string | null; - last_name: string | null; - comments_disabled: boolean; -} - interface Slide { id: number; title: string | null; @@ -113,6 +104,31 @@ interface CalendarEvent { active: boolean; } +interface SocialEvent { + id: number; + name: string; + description: string; + price: string | null; + date: string | null; + photos: string[]; + featured_photo: string | null; + active: boolean; + sort_order: number; + reservation_count: number; +} + +interface SocialEventReservation { + id: number; + social_event_id: number; + user_id: number; + username: string; + event_name: string; + guests: number; + notes: string; + status: 'pending' | 'confirmed' | 'cancelled'; + created_at: string; +} + const gold = '#E8A849'; const navy = '#001321'; const warm = '#a6683c'; @@ -157,6 +173,12 @@ export default function AdminPage() { const [eventForm, setEventForm] = useState>({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true }); const [editingEvent, setEditingEvent] = useState(null); + const [socialEvents, setSocialEvents] = useState([]); + const [socialEventForm, setSocialEventForm] = useState>({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); + const [editingSocialEvent, setEditingSocialEvent] = useState(null); + const [socialEventReservations, setSocialEventReservations] = useState([]); + const [socialEventReservationsFor, setSocialEventReservationsFor] = useState(null); + const [footer, setFooter] = useState({ facebook_url: '', instagram_url: '', whatsapp_url: '', address: '', phone: '', email: '', }); @@ -181,6 +203,7 @@ export default function AdminPage() { fetch('/api/admin/users', { credentials: 'same-origin' }).then((r) => r.json()).then((j) => setUsers(j.data || [])).catch(() => {}); fetch('/api/slides').then((r) => r.json()).then((j) => setSlides(j.data || [])).catch(() => {}); fetch('/api/calendar_events').then((r) => r.json()).then((j) => setEvents(j.data || [])).catch(() => {}); + fetch('/api/social_events').then((r) => r.json()).then((j) => setSocialEvents(j.data || [])).catch(() => {}); fetch('/api/site-assets').then((res) => res.json()).then((json) => { const c = json.data || {}; @@ -404,6 +427,50 @@ export default function AdminPage() { const editEvent = (ev: CalendarEvent) => { setEventForm({ ...ev }); setEditingEvent(ev.id); }; const deleteEvent = async (id: number) => { await api(`/api/calendar_events/${id}`, 'DELETE'); setEvents((prev) => prev.filter((ev) => ev.id !== id)); }; + const saveSocialEvent = async (e: React.FormEvent) => { + e.preventDefault(); + const payload = { + name: socialEventForm.name || '', + description: socialEventForm.description || '', + price: socialEventForm.price || null, + date: socialEventForm.date || null, + photos: socialEventForm.photos || [], + featured_photo: socialEventForm.featured_photo || null, + active: socialEventForm.active !== false, + sort_order: socialEventForm.sort_order || 0, + }; + if (editingSocialEvent) { + await api(`/api/social_events/${editingSocialEvent}`, 'PUT', payload); + setSocialEvents((prev) => prev.map((se) => (se.id === editingSocialEvent ? { ...se, ...payload } as SocialEvent : se))); + setEditingSocialEvent(null); + } else { + const json = await api('/api/social_events', 'POST', payload); + setSocialEvents((prev) => [...prev, json.data]); + } + setSocialEventForm({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); + notify('Social event saved.'); + }; + + const editSocialEvent = (se: SocialEvent) => { setSocialEventForm({ ...se, photos: se.photos || [], featured_photo: se.featured_photo || null }); setEditingSocialEvent(se.id); }; + const deleteSocialEvent = async (id: number) => { await api(`/api/social_events/${id}`, 'DELETE'); setSocialEvents((prev) => prev.filter((se) => se.id !== id)); }; + + const loadSocialEventReservations = async (eventId: number) => { + setSocialEventReservationsFor(eventId); + const res = await fetch(`/api/social_event_reservations?social_event_id=${eventId}`, { credentials: 'same-origin' }); + const json = await res.json().catch(() => ({ data: [] })); + setSocialEventReservations(json.data || []); + }; + + const updateReservationStatus = async (reservationId: number, status: SocialEventReservation['status']) => { + await api(`/api/admin/social_event_reservations/${reservationId}`, 'PUT', { status }); + setSocialEventReservations((prev) => prev.map((r) => (r.id === reservationId ? { ...r, status } : r))); + }; + + const deleteReservation = async (reservationId: number) => { + await api(`/api/admin/social_event_reservations/${reservationId}`, 'DELETE'); + setSocialEventReservations((prev) => prev.filter((r) => r.id !== reservationId)); + }; + if (loading) { return (
@@ -553,6 +620,72 @@ export default function AdminPage() { [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} /> + +
+
+ setSocialEventForm({ ...socialEventForm, name: v })} /> + setSocialEventForm({ ...socialEventForm, price: v })} /> + setSocialEventForm({ ...socialEventForm, date: v })} /> + setSocialEventForm({ ...socialEventForm, sort_order: parseInt(v || '0', 10) })} /> + setSocialEventForm({ ...socialEventForm, photos, featured_photo: featured })} + /> + setSocialEventForm({ ...socialEventForm, description: v })} /> + +
+ + {editingSocialEvent && { setEditingSocialEvent(null); setSocialEventForm({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); }}>Cancel} +
+ + [se.name, se.date || '', se.active ? 'On' : 'Off', `#${se.sort_order}`, `${se.reservation_count || 0} reservations`])} + onEdit={(i) => editSocialEvent(socialEvents[i])} + onDelete={(i) => deleteSocialEvent(socialEvents[i].id)} + onComments={(i) => loadSocialEventReservations(socialEvents[i].id)} + /> + {socialEventReservationsFor !== null && ( +
+
+ Reservations for event #{socialEventReservationsFor} + { setSocialEventReservationsFor(null); setSocialEventReservations([]); }}>Close +
+ {socialEventReservations.length === 0 ? ( +

No reservations yet.

+ ) : ( +
+ {socialEventReservations.map((r) => ( +
+
+ {r.username || r.event_name} + {new Date(r.created_at).toLocaleDateString()} +
+

{`${r.guests} guest${r.guests === 1 ? '' : 's'}${r.notes ? ' — ' + r.notes : ''}`}

+
+ + deleteReservation(r.id)}>Delete +
+
+ ))} +
+ )} +
+ )} +
+
setRoomForm({ ...roomForm, name: v })} /> @@ -716,84 +849,51 @@ export default function AdminPage() { function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( -
-

{title}

+
+

{title}

{children}
); } -function Text({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (v: string) => void; type?: string }) { - return ( - - ); -} - -function Number({ label, value, onChange }: { label: string; value: number; onChange: (v: string) => void }) { - return ( - - ); -} - -function ImageSelect({ label, images, value, onChange }: { label: string; images: ImageRecord[]; value: string | number; onChange: (v: string) => void }) { - return ( - - ); -} - -function Button({ children, onClick, type = 'button', disabled = false }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit'; disabled?: boolean }) { +function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) { return ( ); } -function SecondaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { +function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) { return ( @@ -811,18 +911,101 @@ function DangerButton({ children, onClick }: { children: React.ReactNode; onClic ); } -function ListTable({ rows, onEdit, onDelete, onComments }: { rows: string[][]; onEdit?: (index: number) => void; onDelete: (index: number) => void; onComments?: (index: number) => void }) { +function Text({ label, value, type = 'text', onChange }: { label: string; value: string; type?: string; onChange: (v: string) => void }) { return ( -
+ + ); +} + +function Number({ label, value, onChange }: { label: string; value: number; onChange: (v: string) => void }) { + return ( + + ); +} + +function ImageSelect({ label, images, value, onChange }: { label: string; images: ImageRecord[]; value: string | number; onChange: (v: string) => void }) { + return ( + + ); +} + +function PhotoPicker({ label, images, photos, featuredPhoto, onChange }: { label: string; images: ImageRecord[]; photos: string[]; featuredPhoto: string | null; onChange: (photos: string[], featured: string | null) => void }) { + return ( +
+ +
+ {images.map((img) => { + const selected = photos.includes(img.data); + const featured = featuredPhoto === img.data; + return ( +
{ + const next = selected ? photos.filter((p) => p !== img.data) : [...photos, img.data]; + const nextFeatured = featured ? null : img.data; + onChange(next, nextFeatured); + }} + style={{ + border: selected ? `3px solid ${gold}` : '3px solid transparent', + borderRadius: '12px', + overflow: 'hidden', + cursor: 'pointer', + position: 'relative', + transition: 'transform 150ms', + }} + onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.03)'; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} + > + {featured && FEATURED} + {selected && !featured && } + {img.filename} +
+ ); + })} +
+
+ ); +} + +function ListTable({ rows, onEdit, onDelete, onComments }: { rows: (string | number)[][]; onEdit?: (i: number) => void; onDelete?: (i: number) => void; onComments?: (i: number) => void }) { + if (rows.length === 0) return

No items yet.

; + return ( +
{rows.map((row, i) => ( -
-
- {row.map((cell, j) => {cell})} +
+
+ {row.map((cell, j) => ( + {cell} + ))}
-
- {onComments && } - {onEdit && } - onDelete(i)}>Delete +
+ {onEdit && onEdit(i)}>Edit} + {onComments && onComments(i)}>Reservations} + {onDelete && onDelete(i)}>Delete}
))} @@ -830,71 +1013,226 @@ function ListTable({ rows, onEdit, onDelete, onComments }: { rows: string[][]; o ); } -function PhotoPicker({ - label, images, photos, featuredPhoto, onChange, -}: { - label: string; - images: ImageRecord[]; - photos: string[]; - featuredPhoto: string | null; - onChange: (photos: string[], featured: string | null) => void; -}) { - const allPhotos = Array.from(new Set([...photos, ...images.map((i) => i.data)])); +function Textarea({ label, value, onChange, rows = 3 }: { label: string; value: string; onChange: (v: string) => void; rows?: number }) { return ( -