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;
}
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>
);
}