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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user