Add Social Events tab with reservations
This commit is contained in:
+471
-133
@@ -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<Partial<CalendarEvent>>({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true });
|
||||
const [editingEvent, setEditingEvent] = useState<number | null>(null);
|
||||
|
||||
const [socialEvents, setSocialEvents] = useState<SocialEvent[]>([]);
|
||||
const [socialEventForm, setSocialEventForm] = useState<Partial<SocialEvent>>({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 });
|
||||
const [editingSocialEvent, setEditingSocialEvent] = useState<number | null>(null);
|
||||
const [socialEventReservations, setSocialEventReservations] = useState<SocialEventReservation[]>([]);
|
||||
const [socialEventReservationsFor, setSocialEventReservationsFor] = useState<number | null>(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 (
|
||||
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
|
||||
@@ -553,6 +620,72 @@ export default function AdminPage() {
|
||||
</form>
|
||||
<ListTable rows={events.map((ev) => [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
|
||||
</Section>
|
||||
|
||||
<Section title="Social Events">
|
||||
<form onSubmit={saveSocialEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||
<Text label="Name" value={socialEventForm.name || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, name: v })} />
|
||||
<Text label="Price" value={socialEventForm.price || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, price: v })} />
|
||||
<Text label="Date" type="date" value={socialEventForm.date || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, date: v })} />
|
||||
<Number label="Sort order" value={socialEventForm.sort_order ?? 0} onChange={(v) => setSocialEventForm({ ...socialEventForm, sort_order: parseInt(v || '0', 10) })} />
|
||||
<PhotoPicker
|
||||
label="Event photos"
|
||||
images={images}
|
||||
photos={socialEventForm.photos || []}
|
||||
featuredPhoto={socialEventForm.featured_photo || null}
|
||||
onChange={(photos: string[], featured: string | null) => setSocialEventForm({ ...socialEventForm, photos, featured_photo: featured })}
|
||||
/>
|
||||
<Text label="Description" value={socialEventForm.description || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, description: v })} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
||||
<input type="checkbox" checked={socialEventForm.active !== false} onChange={(e) => setSocialEventForm({ ...socialEventForm, active: e.target.checked })} />
|
||||
Active
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||
<Button type="submit">{editingSocialEvent ? 'Update social event' : 'Add social event'}</Button>
|
||||
{editingSocialEvent && <SecondaryButton onClick={() => { setEditingSocialEvent(null); setSocialEventForm({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
|
||||
</div>
|
||||
</form>
|
||||
<ListTable rows={socialEvents.map((se) => [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 && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#fff', borderRadius: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||
<strong style={{ color: navy }}>Reservations for event #{socialEventReservationsFor}</strong>
|
||||
<SecondaryButton onClick={() => { setSocialEventReservationsFor(null); setSocialEventReservations([]); }}>Close</SecondaryButton>
|
||||
</div>
|
||||
{socialEventReservations.length === 0 ? (
|
||||
<p style={{ color: '#777' }}>No reservations yet.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{socialEventReservations.map((r) => (
|
||||
<div key={r.id} style={{ padding: '0.75rem', background: '#f5f0e8', borderRadius: '10px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.3rem' }}>
|
||||
<strong>{r.username || r.event_name}</strong>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>{new Date(r.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: '#555' }}>{`${r.guests} guest${r.guests === 1 ? '' : 's'}${r.notes ? ' — ' + r.notes : ''}`}</p>
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<select
|
||||
value={r.status}
|
||||
onChange={(e) => updateReservationStatus(r.id, e.target.value as SocialEventReservation['status'])}
|
||||
style={{ padding: '0.4rem 0.6rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '14px' }}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<DangerButton onClick={() => deleteReservation(r.id)}>Delete</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Rooms">
|
||||
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||
<Text label="Name" value={roomForm.name || ''} onChange={(v) => setRoomForm({ ...roomForm, name: v })} />
|
||||
@@ -716,84 +849,51 @@ export default function AdminPage() {
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={{ marginBottom: '2.5rem', padding: '1.5rem', background: '#f5f0e8', borderRadius: '16px' }}>
|
||||
<h2 style={{ fontSize: '20px', color: navy, margin: '0 0 1rem' }}>{title}</h2>
|
||||
<section style={{ marginBottom: '2.5rem' }}>
|
||||
<h2 style={{ fontSize: 'clamp(20px, 3vw, 28px)', fontWeight: 400, color: navy, margin: '0 0 1rem' }}>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Text({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (v: string) => void; type?: string }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Number({ label, value, onChange }: { label: string; value: number; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSelect({ label, images, value, onChange }: { label: string; images: ImageRecord[]; value: string | number; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}>
|
||||
<option value="">None (use URL)</option>
|
||||
{images.map((img) => (
|
||||
<option key={img.id} value={img.id}>
|
||||
#{img.id} {img.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '0.7rem 1.2rem',
|
||||
padding: '0.65rem 1.25rem',
|
||||
background: disabled ? '#ccc' : gold,
|
||||
color: navy,
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'filter 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.filter = 'brightness(1.08)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.filter = 'brightness(1)'; }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
style={{ padding: '0.7rem 1.2rem', background: 'transparent', color: warm, border: `1.5px solid ${warm}`, borderRadius: '999px', cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||
style={{
|
||||
padding: '0.65rem 1.25rem',
|
||||
background: 'transparent',
|
||||
color: navy,
|
||||
border: `1px solid ${navy}`,
|
||||
borderRadius: '999px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '15px' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Number({ label, value, onChange }: { label: string; value: number; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '15px' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSelect({ label, images, value, onChange }: { label: string; images: ImageRecord[]; value: string | number; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}>
|
||||
<option value="">-- select image --</option>
|
||||
{images.map((img) => (
|
||||
<option key={img.id} value={img.id}>{img.filename}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoPicker({ label, images, photos, featuredPhoto, onChange }: { label: string; images: ImageRecord[]; photos: string[]; featuredPhoto: string | null; onChange: (photos: string[], featured: string | null) => void }) {
|
||||
return (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555', marginBottom: '0.5rem' }}>{label}</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '0.75rem' }}>
|
||||
{images.map((img) => {
|
||||
const selected = photos.includes(img.data);
|
||||
const featured = featuredPhoto === img.data;
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
onClick={() => {
|
||||
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 && <span style={{ position: 'absolute', top: 4, left: 4, background: gold, color: navy, fontSize: '11px', fontWeight: 700, padding: '2px 6px', borderRadius: '6px' }}>FEATURED</span>}
|
||||
{selected && !featured && <span style={{ position: 'absolute', top: 4, right: 4, background: navy, color: '#fff', fontSize: '11px', fontWeight: 700, padding: '2px 6px', borderRadius: '6px' }}>✓</span>}
|
||||
<img src={img.data} alt={img.filename} style={{ width: '100%', height: '100px', objectFit: 'cover' }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <p style={{ color: '#777' }}>No items yet.</p>;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} style={{ background: '#fff', padding: '0.9rem 1.1rem', borderRadius: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', color: navy }}>
|
||||
{row.map((cell, j) => <span key={j}>{cell}</span>)}
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#fff', borderRadius: '12px', boxShadow: '0 1px 3px rgba(1,13,30,0.06)' }}>
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
{row.map((cell, j) => (
|
||||
<span key={j} style={{ color: j === 0 ? navy : '#555', fontWeight: j === 0 ? 600 : 400 }}>{cell}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
{onComments && <Button onClick={() => onComments(i)}>Comments</Button>}
|
||||
{onEdit && <Button onClick={() => onEdit(i)}>Edit</Button>}
|
||||
<DangerButton onClick={() => onDelete(i)}>Delete</DangerButton>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{onEdit && <SecondaryButton onClick={() => onEdit(i)}>Edit</SecondaryButton>}
|
||||
{onComments && <SecondaryButton onClick={() => onComments(i)}>Reservations</SecondaryButton>}
|
||||
{onDelete && <DangerButton onClick={() => onDelete(i)}>Delete</DangerButton>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -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 (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', fontSize: '14px', color: '#555', gridColumn: '1 / -1' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: '0.75rem' }}>
|
||||
{allPhotos.map((url) => {
|
||||
const isSelected = photos.includes(url);
|
||||
const isFeatured = featuredPhoto === url;
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
const next = photos.filter((p) => p !== url);
|
||||
onChange(next, featuredPhoto === url ? null : featuredPhoto);
|
||||
} else {
|
||||
onChange([...photos, url], featuredPhoto || url);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
border: `2px solid ${isFeatured ? gold : isSelected ? navy : 'transparent'}`,
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
opacity: isSelected ? 1 : 0.55,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<img src={url} alt="" style={{ width: '100%', height: '90px', objectFit: 'cover', display: 'block' }} />
|
||||
{isFeatured && (
|
||||
<span style={{ position: 'absolute', top: 4, left: 4, background: gold, color: navy, fontSize: '10px', fontWeight: 700, padding: '2px 6px', borderRadius: '8px' }}>FEATURED</span>
|
||||
)}
|
||||
{!isSelected && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}>+</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allPhotos.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '13px', color: '#777' }}>Featured:</span>
|
||||
<select
|
||||
value={featuredPhoto || ''}
|
||||
onChange={(e) => onChange(photos, e.target.value || null)}
|
||||
style={{ padding: '0.5rem 0.75rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '14px' }}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{photos.map((url) => (
|
||||
<option key={url} value={url}>
|
||||
{url.slice(0, 40)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '15px', resize: 'vertical' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, value, options, onChange }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange(n)}
|
||||
style={{ background: 'transparent', border: 'none', cursor: 'pointer', fontSize: '22px', color: n <= value ? gold : '#ccc' }}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatePicker({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '15px' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPicker({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '3rem', height: '2.4rem', padding: 0, border: '1px solid rgba(1,13,30,0.18)', borderRadius: '10px', cursor: 'pointer', background: 'transparent' }}
|
||||
/>
|
||||
<Text label="Hex" value={value} onChange={onChange} />
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ background: '#fffdf9', padding: '1.5rem', borderRadius: '16px', maxWidth: '560px', width: '90%', maxHeight: '80vh', overflow: 'auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h3 style={{ margin: 0, color: navy }}>{title}</h3>
|
||||
<button onClick={onClose} style={{ background: 'transparent', border: 'none', fontSize: '20px', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'pending' | 'confirmed' | 'cancelled' }) {
|
||||
const color = status === 'confirmed' ? '#3b7a22' : status === 'cancelled' ? '#c23b22' : '#a6683c';
|
||||
return (
|
||||
<span style={{ padding: '0.25rem 0.6rem', borderRadius: '999px', background: `${color}20`, color, fontSize: '12px', fontWeight: 700, textTransform: 'uppercase' }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
|
||||
<div style={{ width: '2rem', height: '2rem', border: `3px solid ${gold}40`, borderTop: `3px solid ${gold}`, borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return <p style={{ color: '#777', textAlign: 'center', padding: '2rem 0' }}>{text}</p>;
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return <p style={{ color: '#c23b22', textAlign: 'center', padding: '1rem 0' }}>{message}</p>;
|
||||
}
|
||||
|
||||
function Card({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 2px 8px rgba(1,13,30,0.06)',
|
||||
padding: '1.25rem',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'transform 200ms, box-shadow 200ms',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (onClick) { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = '0 8px 22px rgba(1,13,30,0.12)'; }}}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.06)'; }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (v: string) => void; type?: string }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||
{label}
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '15px' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ children }: { children: React.ReactNode }) {
|
||||
return <span style={{ fontSize: '14px', color: '#555' }}>{children}</span>;
|
||||
}
|
||||
|
||||
function FormGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>{children}</div>;
|
||||
}
|
||||
|
||||
function PageHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>{title}</h1>
|
||||
{subtitle && <p style={{ color: '#555', margin: 0 }}>{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (t: string) => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', borderBottom: `1px solid rgba(1,13,30,0.12)` }}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onChange(tab)}
|
||||
style={{
|
||||
padding: '0.75rem 1.25rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: active === tab ? `3px solid ${gold}` : '3px solid transparent',
|
||||
color: active === tab ? navy : '#555',
|
||||
fontWeight: active === tab ? 700 : 400,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto' }}>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBar({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>{children}</div>;
|
||||
}
|
||||
|
||||
function StatsCards({ stats }: { stats: { label: string; value: string | number }[] }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} style={{ background: '#fff', borderRadius: '12px', padding: '1rem', boxShadow: '0 1px 3px rgba(1,13,30,0.06)' }}>
|
||||
<div style={{ fontSize: '12px', color: '#777', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{s.label}</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 700, color: navy, marginTop: '0.25rem' }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
const { rows } = await db.query(
|
||||
'UPDATE social_event_reservations SET status=$1 WHERE id=$2 RETURNING *',
|
||||
[status, id]
|
||||
);
|
||||
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('PUT /api/admin/social_event_reservations/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
await db.query('DELETE FROM social_event_reservations WHERE id=$1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('DELETE /api/admin/social_event_reservations/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to delete reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
const body = await request.json();
|
||||
const { category, name, description, price, is_daily_special, active, sort_order } = body;
|
||||
|
||||
@@ -26,10 +27,11 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
await db.query('DELETE FROM menu_items WHERE id = $1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth, requireRole } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const socialEventId = searchParams.get('social_event_id');
|
||||
|
||||
let query = `
|
||||
SELECT ser.*, u.username, u.first_name, u.last_name, se.name as event_name
|
||||
FROM social_event_reservations ser
|
||||
JOIN users u ON u.id = ser.user_id
|
||||
JOIN social_events se ON se.id = ser.social_event_id
|
||||
`;
|
||||
const params: (number | string)[] = [];
|
||||
|
||||
if (session.role === 'admin') {
|
||||
if (socialEventId) {
|
||||
query += ' WHERE ser.social_event_id = $1';
|
||||
params.push(parseInt(socialEventId, 10));
|
||||
}
|
||||
} else {
|
||||
query += ' WHERE ser.user_id = $1';
|
||||
params.push(session.id);
|
||||
if (socialEventId) {
|
||||
query += ' AND ser.social_event_id = $2';
|
||||
params.push(parseInt(socialEventId, 10));
|
||||
}
|
||||
}
|
||||
|
||||
query += ' ORDER BY ser.created_at DESC';
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/social_event_reservations error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const body = await request.json();
|
||||
const { social_event_id, guests, notes } = body;
|
||||
if (!social_event_id || !guests) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, social_event_id)
|
||||
DO UPDATE SET guests = EXCLUDED.guests, notes = EXCLUDED.notes, status = 'pending'
|
||||
RETURNING *`,
|
||||
[session.id, parseInt(social_event_id, 10), parseInt(guests, 10), notes || '']
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('POST /api/social_event_reservations error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
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 { 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
|
||||
RETURNING *`,
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0, id]
|
||||
);
|
||||
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('PUT /api/social_events/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update social event' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
await db.query('DELETE FROM social_events WHERE id=$1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('DELETE /api/social_events/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to delete social event' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { rows } = await db.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
|
||||
`);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/social_events/public error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to load events' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const { rows } = await db.query(`
|
||||
SELECT se.*,
|
||||
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
|
||||
FROM social_events se
|
||||
ORDER BY se.sort_order, se.date, se.id
|
||||
`);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/social_events error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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)
|
||||
RETURNING *`,
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('POST /api/social_events error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to create social event' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface SocialEvent {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: string | null;
|
||||
date: string | null;
|
||||
photos: string[];
|
||||
featured_photo: string | null;
|
||||
active?: boolean;
|
||||
reservation_count: number;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
comments_disabled?: boolean;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
const warm = '#a6683c';
|
||||
|
||||
export default function SocialEventsPage() {
|
||||
const [events, setEvents] = useState<SocialEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [activePhoto, setActivePhoto] = useState<Record<number, string>>({});
|
||||
const [reserving, setReserving] = useState<number | null>(null);
|
||||
const [guests, setGuests] = useState<Record<number, string>>({});
|
||||
const [notes, setNotes] = useState<Record<number, string>>({});
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [myReservations, setMyReservations] = useState<Record<number, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/social_events/public')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to load social events');
|
||||
const json = await res.json();
|
||||
setEvents(json.data || []);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then((r) => (r.ok ? r.json() : Promise.resolve({ user: null })))
|
||||
.then((j) => setUser(j.user || null))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/social_event_reservations', { credentials: 'same-origin' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
const map: Record<number, number> = {};
|
||||
(json.data || []).forEach((r: { social_event_id: number; guests: number }) => {
|
||||
map[r.social_event_id] = r.guests;
|
||||
});
|
||||
setMyReservations(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const makeReservation = async (eventId: number) => {
|
||||
const count = parseInt(guests[eventId] || '1', 10);
|
||||
if (count < 1) return;
|
||||
setReserving(eventId);
|
||||
const res = await fetch('/api/social_event_reservations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
social_event_id: eventId,
|
||||
guests: count,
|
||||
notes: notes[eventId] || '',
|
||||
}),
|
||||
});
|
||||
setReserving(null);
|
||||
if (res.ok) {
|
||||
setMessage('Reservation submitted.');
|
||||
setMyReservations((prev) => ({ ...prev, [eventId]: count }));
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} else {
|
||||
setError('Reservation failed. Make sure you are logged in.');
|
||||
}
|
||||
};
|
||||
|
||||
const displayedEvents = events.filter((e) => e.active !== false);
|
||||
|
||||
return (
|
||||
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Social Events</h1>
|
||||
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
||||
Curated gatherings, tastings, and celebrations at La Huasca. Reserve your spot.
|
||||
</p>
|
||||
|
||||
{message && <p style={{ color: '#3b7a22', marginBottom: '1rem' }}>{message}</p>}
|
||||
{loading && <p style={{ color: warm }}>Loading events...</p>}
|
||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||
{!loading && displayedEvents.length === 0 && <p style={{ color: '#777' }}>No social events scheduled yet.</p>}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '2rem' }}>
|
||||
{displayedEvents.map((event) => {
|
||||
const photos = event.photos?.length ? event.photos : [];
|
||||
const featured = event.featured_photo || photos[0];
|
||||
const currentPhoto = activePhoto[event.id] || featured || photos[0];
|
||||
const myCount = myReservations[event.id];
|
||||
|
||||
return (
|
||||
<article
|
||||
key={event.id}
|
||||
style={{
|
||||
background: cream,
|
||||
borderRadius: '20px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '260px',
|
||||
background: '#ddd',
|
||||
backgroundImage: currentPhoto ? `url(${currentPhoto})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
cursor: photos.length > 1 ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (photos.length > 1) {
|
||||
const idx = photos.indexOf(currentPhoto);
|
||||
setActivePhoto((prev) => ({ ...prev, [event.id]: photos[(idx + 1) % photos.length] }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div style={{ position: 'absolute', bottom: 10, right: 10, display: 'flex', gap: '0.4rem' }}>
|
||||
{photos.map((p, i) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setActivePhoto((prev) => ({ ...prev, [event.id]: p }))}
|
||||
style={{
|
||||
width: 10, height: 10, borderRadius: '50%', border: 'none',
|
||||
background: p === currentPhoto ? gold : 'rgba(255,255,255,0.7)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
aria-label={`Photo ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{event.name}</h2>
|
||||
{event.date && (
|
||||
<p style={{ margin: '0 0 0.5rem', color: warm, fontSize: '15px', fontWeight: 600 }}>
|
||||
{new Date(event.date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{event.description}</p>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>
|
||||
{event.price ? `$${event.price}` : 'Free'}
|
||||
</span>
|
||||
<span style={{ fontSize: '13px', color: '#777' }}>
|
||||
{event.reservation_count || 0} reserved
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{photos.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', overflowX: 'auto' }}>
|
||||
{photos.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setActivePhoto((prev) => ({ ...prev, [event.id]: p }))}
|
||||
style={{
|
||||
width: 64, height: 48, flexShrink: 0, borderRadius: 8, border: `2px solid ${p === currentPhoto ? gold : 'transparent'}`,
|
||||
backgroundImage: `url(${p})`, backgroundSize: 'cover', backgroundPosition: 'center', cursor: 'pointer',
|
||||
}}
|
||||
aria-label="View photo"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
<div style={{ marginTop: '1.5rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '1rem', background: '#fff', borderRadius: '12px' }}>
|
||||
{myCount ? (
|
||||
<p style={{ margin: 0, color: '#3b7a22', fontWeight: 600 }}>
|
||||
You reserved {myCount} guest{myCount === 1 ? '' : 's'}.
|
||||
</p>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555', flex: 1 }}>
|
||||
Guests
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={guests[event.id] || '1'}
|
||||
onChange={(e) => setGuests((prev) => ({ ...prev, [event.id]: e.target.value }))}
|
||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => makeReservation(event.id)}
|
||||
disabled={reserving === event.id}
|
||||
style={{ padding: '0.7rem 1.2rem', borderRadius: '999px', border: 'none', background: gold, color: navy, fontWeight: 700, cursor: 'pointer' }}
|
||||
>
|
||||
{reserving === event.id ? 'Saving...' : myCount ? 'Update reservation' : 'Reserve'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={notes[event.id] || ''}
|
||||
onChange={(e) => setNotes((prev) => ({ ...prev, [event.id]: e.target.value }))}
|
||||
placeholder="Dietary restrictions or notes..."
|
||||
rows={2}
|
||||
style={{ padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontFamily: 'inherit', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ marginTop: '1.5rem', color: '#777', fontSize: '14px' }}>
|
||||
<a href="/login" style={{ color: warm, textDecoration: 'underline' }}>Log in</a> to reserve a spot.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user