Add Social Events tab with reservations

This commit is contained in:
2026-06-27 19:40:22 -05:00
parent 71ae322b3f
commit 744830c3cd
10 changed files with 939 additions and 138 deletions
+471 -133
View File
@@ -84,15 +84,6 @@ interface Review {
approved: boolean; approved: boolean;
} }
interface User {
id: number;
username: string;
role: 'admin' | 'user';
first_name: string | null;
last_name: string | null;
comments_disabled: boolean;
}
interface Slide { interface Slide {
id: number; id: number;
title: string | null; title: string | null;
@@ -113,6 +104,31 @@ interface CalendarEvent {
active: boolean; 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 gold = '#E8A849';
const navy = '#001321'; const navy = '#001321';
const warm = '#a6683c'; 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 [eventForm, setEventForm] = useState<Partial<CalendarEvent>>({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true });
const [editingEvent, setEditingEvent] = useState<number | null>(null); 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({ const [footer, setFooter] = useState({
facebook_url: '', instagram_url: '', whatsapp_url: '', address: '', phone: '', email: '', 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/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/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/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) => { fetch('/api/site-assets').then((res) => res.json()).then((json) => {
const c = json.data || {}; const c = json.data || {};
@@ -404,6 +427,50 @@ export default function AdminPage() {
const editEvent = (ev: CalendarEvent) => { setEventForm({ ...ev }); setEditingEvent(ev.id); }; 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 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) { if (loading) {
return ( return (
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}> <main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
@@ -553,6 +620,72 @@ export default function AdminPage() {
</form> </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)} /> <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>
<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"> <Section title="Rooms">
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}> <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 })} /> <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 }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section style={{ marginBottom: '2.5rem', padding: '1.5rem', background: '#f5f0e8', borderRadius: '16px' }}> <section style={{ marginBottom: '2.5rem' }}>
<h2 style={{ fontSize: '20px', color: navy, margin: '0 0 1rem' }}>{title}</h2> <h2 style={{ fontSize: 'clamp(20px, 3vw, 28px)', fontWeight: 400, color: navy, margin: '0 0 1rem' }}>{title}</h2>
{children} {children}
</section> </section>
); );
} }
function Text({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (v: string) => void; type?: string }) { function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) {
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 }) {
return ( return (
<button <button
type={type} type={type}
onClick={onClick}
disabled={disabled} disabled={disabled}
onClick={onClick}
style={{ style={{
padding: '0.7rem 1.2rem', padding: '0.65rem 1.25rem',
background: disabled ? '#ccc' : gold, background: disabled ? '#ccc' : gold,
color: navy, color: navy,
border: 'none', border: 'none',
borderRadius: '999px', borderRadius: '999px',
cursor: disabled ? 'not-allowed' : 'pointer', cursor: disabled ? 'not-allowed' : 'pointer',
fontWeight: 600, 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} {children}
</button> </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 ( return (
<button <button
type={type}
onClick={onClick} 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} {children}
</button> </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 ( 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) => ( {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 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', color: navy }}> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
{row.map((cell, j) => <span key={j}>{cell}</span>)} {row.map((cell, j) => (
<span key={j} style={{ color: j === 0 ? navy : '#555', fontWeight: j === 0 ? 600 : 400 }}>{cell}</span>
))}
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
{onComments && <Button onClick={() => onComments(i)}>Comments</Button>} {onEdit && <SecondaryButton onClick={() => onEdit(i)}>Edit</SecondaryButton>}
{onEdit && <Button onClick={() => onEdit(i)}>Edit</Button>} {onComments && <SecondaryButton onClick={() => onComments(i)}>Reservations</SecondaryButton>}
<DangerButton onClick={() => onDelete(i)}>Delete</DangerButton> {onDelete && <DangerButton onClick={() => onDelete(i)}>Delete</DangerButton>}
</div> </div>
</div> </div>
))} ))}
@@ -830,71 +1013,226 @@ function ListTable({ rows, onEdit, onDelete, onComments }: { rows: string[][]; o
); );
} }
function PhotoPicker({ function Textarea({ label, value, onChange, rows = 3 }: { label: string; value: string; onChange: (v: string) => void; rows?: number }) {
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)]));
return ( 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} {label}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: '0.75rem' }}> <textarea
{allPhotos.map((url) => { value={value}
const isSelected = photos.includes(url); onChange={(e) => onChange(e.target.value)}
const isFeatured = featuredPhoto === url; rows={rows}
return ( style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '15px', resize: 'vertical' }}
<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>
)}
</label> </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 });
}
}
+6 -4
View File
@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth'; import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; 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 { try {
await requireRole('admin'); 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 body = await request.json();
const { category, name, description, price, is_daily_special, active, sort_order } = body; 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 { try {
await requireRole('admin'); 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]); await db.query('DELETE FROM menu_items WHERE id = $1', [id]);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error: any) { } 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 });
}
}
+36
View File
@@ -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 });
}
}
+18
View File
@@ -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 });
}
}
+37
View File
@@ -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 });
}
}
+245
View File
@@ -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>
);
}
+1
View File
@@ -8,6 +8,7 @@ const links = [
{ href: '/rooms', label: 'Rooms' }, { href: '/rooms', label: 'Rooms' },
{ href: '/coffee', label: 'Coffee' }, { href: '/coffee', label: 'Coffee' },
{ href: '/landscape', label: 'Landscape' }, { href: '/landscape', label: 'Landscape' },
{ href: '/social-events', label: 'Social Events' },
{ href: '/comments', label: 'Comments' }, { href: '/comments', label: 'Comments' },
]; ];
+29 -1
View File
@@ -172,6 +172,34 @@ export async function initSchema() {
ALTER TABLE places DROP COLUMN IF EXISTS photo; ALTER TABLE places DROP COLUMN IF EXISTS photo;
`); `);
await db.query(`
CREATE TABLE IF NOT EXISTS social_events (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price VARCHAR(50),
date DATE,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS social_event_reservations (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
social_event_id INTEGER REFERENCES social_events(id) ON DELETE CASCADE,
guests INTEGER NOT NULL,
notes TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, social_event_id)
);
`);
await db.query(` await db.query(`
CREATE TABLE IF NOT EXISTS user_sessions ( CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -288,7 +316,7 @@ export async function seed() {
} }
export async function resetDatabase() { export async function resetDatabase() {
await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events CASCADE'); await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events, social_events, social_event_reservations CASCADE');
await initSchema(); await initSchema();
await seed(); await seed();
} }