246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { formatDisplayDateFull } from '@/lib/date';
|
|
|
|
const gold = '#E8A849';
|
|
const navy = '#010D1E';
|
|
const cream = '#f5f0e8';
|
|
const warm = '#a6683c';
|
|
|
|
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;
|
|
}
|
|
|
|
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 }}>
|
|
{formatDisplayDateFull(event.date)}
|
|
</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>
|
|
);
|
|
} |