Add admin slideshow, horizontal animated calendar, and weather strip

This commit is contained in:
2026-06-27 18:40:56 -05:00
parent 75da132327
commit 8394b60bc2
12 changed files with 955 additions and 66 deletions
+214 -24
View File
@@ -16,6 +16,8 @@ interface Room {
description: string; description: string;
price: string; price: string;
photos: string[]; photos: string[];
active: boolean;
sort_order: number;
} }
interface RoomForm { interface RoomForm {
@@ -24,6 +26,8 @@ interface RoomForm {
description: string; description: string;
price: string; price: string;
photos: string | string[]; photos: string | string[];
active: boolean;
sort_order: number;
} }
interface MenuItem { interface MenuItem {
@@ -33,6 +37,8 @@ interface MenuItem {
description: string; description: string;
price: string; price: string;
is_daily_special: boolean; is_daily_special: boolean;
active: boolean;
sort_order: number;
} }
interface Place { interface Place {
@@ -43,6 +49,8 @@ interface Place {
distance_km: string | null; distance_km: string | null;
visit_time: string | null; visit_time: string | null;
suggestion: string | null; suggestion: string | null;
active: boolean;
sort_order: number;
} }
interface Review { interface Review {
@@ -59,6 +67,26 @@ interface User {
role: 'admin' | 'user'; role: 'admin' | 'user';
} }
interface Slide {
id: number;
title: string | null;
subtitle: string | null;
image_id: number | null;
image_url: string | null;
active: boolean;
sort_order: number;
}
interface CalendarEvent {
id: number;
date: string;
title: string;
type: 'holiday' | 'season' | 'weather' | 'event';
description: string | null;
color: string | null;
active: boolean;
}
const gold = '#E8A849'; const gold = '#E8A849';
const navy = '#001321'; const navy = '#001321';
const warm = '#a6683c'; const warm = '#a6683c';
@@ -72,17 +100,18 @@ export default function AdminPage() {
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState(''); const [logoUrl, setLogoUrl] = useState('');
const [faviconUrl, setFaviconUrl] = useState(''); const [faviconUrl, setFaviconUrl] = useState('');
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
const [roomForm, setRoomForm] = useState<RoomForm>({ name: '', description: '', price: '', photos: '' }); const [roomForm, setRoomForm] = useState<RoomForm>({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 });
const [editingRoom, setEditingRoom] = useState<number | null>(null); const [editingRoom, setEditingRoom] = useState<number | null>(null);
const [menu, setMenu] = useState<MenuItem[]>([]); const [menu, setMenu] = useState<MenuItem[]>([]);
const [menuForm, setMenuForm] = useState<Partial<MenuItem>>({ category: '', name: '', description: '', price: '', is_daily_special: false }); const [menuForm, setMenuForm] = useState<Partial<MenuItem>>({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 });
const [editingMenu, setEditingMenu] = useState<number | null>(null); const [editingMenu, setEditingMenu] = useState<number | null>(null);
const [places, setPlaces] = useState<Place[]>([]); const [places, setPlaces] = useState<Place[]>([]);
const [placeForm, setPlaceForm] = useState<Partial<Place>>({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '' }); const [placeForm, setPlaceForm] = useState<Partial<Place>>({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
const [editingPlace, setEditingPlace] = useState<number | null>(null); const [editingPlace, setEditingPlace] = useState<number | null>(null);
const [reviews, setReviews] = useState<Review[]>([]); const [reviews, setReviews] = useState<Review[]>([]);
@@ -90,6 +119,14 @@ export default function AdminPage() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [userForm, setUserForm] = useState({ username: '', password: '', role: 'user' as 'admin' | 'user' }); const [userForm, setUserForm] = useState({ username: '', password: '', role: 'user' as 'admin' | 'user' });
const [slides, setSlides] = useState<Slide[]>([]);
const [slideForm, setSlideForm] = useState<Partial<Slide>>({ title: '', subtitle: '', image_id: null, image_url: '', active: true, sort_order: 0 });
const [editingSlide, setEditingSlide] = useState<number | null>(null);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [eventForm, setEventForm] = useState<Partial<CalendarEvent>>({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true });
const [editingEvent, setEditingEvent] = 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: '',
}); });
@@ -112,6 +149,8 @@ export default function AdminPage() {
fetch('/api/places').then((r) => r.json()).then((j) => setPlaces(j.data || [])).catch(() => {}); fetch('/api/places').then((r) => r.json()).then((j) => setPlaces(j.data || [])).catch(() => {});
fetch('/api/admin/reviews', { credentials: 'same-origin' }).then((r) => r.json()).then((j) => setReviews(j.data || [])).catch(() => {}); fetch('/api/admin/reviews', { credentials: 'same-origin' }).then((r) => r.json()).then((j) => setReviews(j.data || [])).catch(() => {});
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/calendar_events').then((r) => r.json()).then((j) => setEvents(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 || {};
@@ -121,6 +160,7 @@ export default function AdminPage() {
}); });
if (c.logo) setLogoUrl(c.logo); if (c.logo) setLogoUrl(c.logo);
if (c.favicon) setFaviconUrl(c.favicon); if (c.favicon) setFaviconUrl(c.favicon);
if (c.tagline) setTagline(c.tagline);
}).catch(() => {}); }).catch(() => {});
setLoading(false); setLoading(false);
@@ -168,7 +208,7 @@ export default function AdminPage() {
}); });
if (!res.ok) { setError('Upload failed'); return; } if (!res.ok) { setError('Upload failed'); return; }
const result = await res.json(); const result = await res.json();
setImages((prev) => [result.image, ...prev]); setImages((prev) => [{ ...result.image, data }, ...prev]);
setCount((prev) => prev + 1); setCount((prev) => prev + 1);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@@ -201,8 +241,8 @@ export default function AdminPage() {
const saveRoom = async (e: React.FormEvent) => { const saveRoom = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const photosValue = roomForm.photos || []; const photosValue = roomForm.photos || [];
const photosArray: string[] = typeof photosValue === 'string' ? (photosValue as unknown as string).split(',').map((s: string) => s.trim()) : photosValue; const photosArray: string[] = typeof photosValue === 'string' ? (photosValue as unknown as string).split(',').map((s: string) => s.trim()).filter(Boolean) : photosValue;
const payload = { ...roomForm, photos: photosArray }; const payload = { ...roomForm, photos: photosArray, active: roomForm.active !== false, sort_order: roomForm.sort_order || 0 };
if (editingRoom) { if (editingRoom) {
await api(`/api/rooms/${editingRoom}`, 'PUT', payload); await api(`/api/rooms/${editingRoom}`, 'PUT', payload);
setRooms((prev) => prev.map((r) => (r.id === editingRoom ? { ...r, ...payload } as Room : r))); setRooms((prev) => prev.map((r) => (r.id === editingRoom ? { ...r, ...payload } as Room : r)));
@@ -211,24 +251,25 @@ export default function AdminPage() {
const json = await api('/api/rooms', 'POST', payload); const json = await api('/api/rooms', 'POST', payload);
setRooms((prev) => [...prev, json.data]); setRooms((prev) => [...prev, json.data]);
} }
setRoomForm({ name: '', description: '', price: '', photos: [] }); setRoomForm({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 });
notify('Room saved.'); notify('Room saved.');
}; };
const editRoom = (r: Room) => { setRoomForm({ ...r, photos: r.photos.join(', ') }); setEditingRoom(r.id); }; const editRoom = (r: Room) => { setRoomForm({ ...r, photos: r.photos?.join(', ') || '' }); setEditingRoom(r.id); };
const deleteRoom = async (id: number) => { await api(`/api/rooms/${id}`, 'DELETE'); setRooms((prev) => prev.filter((r) => r.id !== id)); }; const deleteRoom = async (id: number) => { await api(`/api/rooms/${id}`, 'DELETE'); setRooms((prev) => prev.filter((r) => r.id !== id)); };
const saveMenu = async (e: React.FormEvent) => { const saveMenu = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const payload = { ...menuForm, active: menuForm.active !== false, sort_order: menuForm.sort_order || 0 };
if (editingMenu) { if (editingMenu) {
await api(`/api/menu/${editingMenu}`, 'PUT', menuForm); await api(`/api/menu/${editingMenu}`, 'PUT', payload);
setMenu((prev) => prev.map((m) => (m.id === editingMenu ? { ...m, ...menuForm } as MenuItem : m))); setMenu((prev) => prev.map((m) => (m.id === editingMenu ? { ...m, ...payload } as MenuItem : m)));
setEditingMenu(null); setEditingMenu(null);
} else { } else {
const json = await api('/api/menu', 'POST', menuForm); const json = await api('/api/menu', 'POST', payload);
setMenu((prev) => [...prev, json.data]); setMenu((prev) => [...prev, json.data]);
} }
setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false }); setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 });
notify('Menu item saved.'); notify('Menu item saved.');
}; };
@@ -237,15 +278,16 @@ export default function AdminPage() {
const savePlace = async (e: React.FormEvent) => { const savePlace = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const payload = { ...placeForm, active: placeForm.active !== false, sort_order: placeForm.sort_order || 0 };
if (editingPlace) { if (editingPlace) {
await api(`/api/places/${editingPlace}`, 'PUT', placeForm); await api(`/api/places/${editingPlace}`, 'PUT', payload);
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...placeForm } as Place : p))); setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...payload } as Place : p)));
setEditingPlace(null); setEditingPlace(null);
} else { } else {
const json = await api('/api/places', 'POST', placeForm); const json = await api('/api/places', 'POST', payload);
setPlaces((prev) => [...prev, json.data]); setPlaces((prev) => [...prev, json.data]);
} }
setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '' }); setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
notify('Place saved.'); notify('Place saved.');
}; };
@@ -269,6 +311,42 @@ export default function AdminPage() {
const deleteUser = async (id: number) => { await api(`/api/admin/users/${id}`, 'DELETE'); setUsers((prev) => prev.filter((u) => u.id !== id)); }; const deleteUser = async (id: number) => { await api(`/api/admin/users/${id}`, 'DELETE'); setUsers((prev) => prev.filter((u) => u.id !== id)); };
const saveSlide = async (e: React.FormEvent) => {
e.preventDefault();
const payload = { ...slideForm, active: slideForm.active !== false, sort_order: slideForm.sort_order || 0 };
if (editingSlide) {
await api(`/api/slides/${editingSlide}`, 'PUT', payload);
setSlides((prev) => prev.map((s) => (s.id === editingSlide ? { ...s, ...payload } as Slide : s)));
setEditingSlide(null);
} else {
const json = await api('/api/slides', 'POST', payload);
setSlides((prev) => [...prev, json.data]);
}
setSlideForm({ title: '', subtitle: '', image_id: null, image_url: '', active: true, sort_order: 0 });
notify('Slide saved.');
};
const editSlide = (s: Slide) => { setSlideForm({ ...s }); setEditingSlide(s.id); };
const deleteSlide = async (id: number) => { await api(`/api/slides/${id}`, 'DELETE'); setSlides((prev) => prev.filter((s) => s.id !== id)); };
const saveEvent = async (e: React.FormEvent) => {
e.preventDefault();
const payload = { ...eventForm, active: eventForm.active !== false };
if (editingEvent) {
await api(`/api/calendar_events/${editingEvent}`, 'PUT', payload);
setEvents((prev) => prev.map((ev) => (ev.id === editingEvent ? { ...ev, ...payload } as CalendarEvent : ev)));
setEditingEvent(null);
} else {
const json = await api('/api/calendar_events', 'POST', payload);
setEvents((prev) => [...prev, json.data]);
}
setEventForm({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true });
notify('Calendar event saved.');
};
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)); };
if (loading) { if (loading) {
return ( return (
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}> <main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
@@ -294,6 +372,8 @@ export default function AdminPage() {
<Button onClick={() => updateAsset('logo', logoUrl)} disabled={!logoUrl}>Save logo</Button> <Button onClick={() => updateAsset('logo', logoUrl)} disabled={!logoUrl}>Save logo</Button>
<Text label="Favicon" value={faviconUrl} onChange={setFaviconUrl} /> <Text label="Favicon" value={faviconUrl} onChange={setFaviconUrl} />
<Button onClick={() => updateAsset('favicon', faviconUrl)} disabled={!faviconUrl}>Save favicon</Button> <Button onClick={() => updateAsset('favicon', faviconUrl)} disabled={!faviconUrl}>Save favicon</Button>
<Text label="Homepage tagline" value={tagline} onChange={setTagline} />
<Button onClick={() => updateAsset('tagline', tagline)} disabled={!tagline}>Save tagline</Button>
</div> </div>
<p style={{ margin: '1rem 0 0', fontSize: '13px', color: '#777' }}> <p style={{ margin: '1rem 0 0', fontSize: '13px', color: '#777' }}>
Tip: click an uploaded gallery image to preview, copy its data URL, and paste it above. Tip: click an uploaded gallery image to preview, copy its data URL, and paste it above.
@@ -324,27 +404,97 @@ export default function AdminPage() {
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '1.25rem' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '1.25rem' }}>
{images.map((img) => ( {images.map((img) => (
<div key={img.id} style={{ borderRadius: '16px', overflow: 'hidden', display: 'flex', flexDirection: 'column', background: '#f5f0e8' }}> <div
key={img.id}
style={{
borderRadius: '16px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: '#f5f0e8',
transition: 'transform 200ms, box-shadow 200ms',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.03)'; e.currentTarget.style.boxShadow = '0 8px 22px rgba(0,0,0,0.12)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none'; }}
onClick={() => { navigator.clipboard.writeText(img.data); notify('Image URL copied.'); }}
>
<img src={img.data} alt={img.filename} style={{ width: '100%', height: '180px', objectFit: 'cover' }} /> <img src={img.data} alt={img.filename} style={{ width: '100%', height: '180px', objectFit: 'cover' }} />
<button onClick={() => handleDelete(img.id)} style={{ padding: '0.65rem', background: '#c23b22', color: '#fff', border: 'none', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}>Delete</button> <button
onClick={(e) => { e.stopPropagation(); handleDelete(img.id); }}
style={{ padding: '0.65rem', background: '#c23b22', color: '#fff', border: 'none', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}
>
Delete
</button>
</div> </div>
))} ))}
</div> </div>
)} )}
</Section> </Section>
<Section title="Homepage Slideshow">
<form onSubmit={saveSlide} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Title" value={slideForm.title || ''} onChange={(v) => setSlideForm({ ...slideForm, title: v })} />
<Text label="Subtitle" value={slideForm.subtitle || ''} onChange={(v) => setSlideForm({ ...slideForm, subtitle: v })} />
<Text label="Image URL (or pick image ID below)" value={slideForm.image_url || ''} onChange={(v) => setSlideForm({ ...slideForm, image_url: v })} />
<Number label="Sort order" value={slideForm.sort_order ?? 0} onChange={(v) => setSlideForm({ ...slideForm, sort_order: parseInt(v || '0', 10) })} />
<ImageSelect label="Gallery image" images={images} value={slideForm.image_id ?? ''} onChange={(v) => setSlideForm({ ...slideForm, image_id: v ? parseInt(v, 10) : null })} />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={slideForm.active !== false} onChange={(e) => setSlideForm({ ...slideForm, active: e.target.checked })} />
Active
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
<Button type="submit">{editingSlide ? 'Update slide' : 'Add slide'}</Button>
{editingSlide && <SecondaryButton onClick={() => { setEditingSlide(null); setSlideForm({ title: '', subtitle: '', image_id: null, image_url: '', active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
</div>
</form>
<ListTable rows={slides.map((s) => [s.title || '(untitled)', s.active ? 'On' : 'Off', `#${s.sort_order}`])} onEdit={(i) => editSlide(slides[i])} onDelete={(i) => deleteSlide(slides[i].id)} />
</Section>
<Section title="Calendar Events">
<form onSubmit={saveEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Date" type="date" value={eventForm.date || ''} onChange={(v) => setEventForm({ ...eventForm, date: v })} />
<Text label="Title" value={eventForm.title || ''} onChange={(v) => setEventForm({ ...eventForm, title: v })} />
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
Type
<select value={eventForm.type || 'event'} onChange={(e) => setEventForm({ ...eventForm, type: e.target.value as CalendarEvent['type'] })} style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}>
<option value="holiday">Holiday</option>
<option value="season">Season</option>
<option value="weather">Weather</option>
<option value="event">Event</option>
</select>
</label>
<Text label="Color" value={eventForm.color || '#E8A849'} onChange={(v) => setEventForm({ ...eventForm, color: v })} />
<Text label="Description" value={eventForm.description || ''} onChange={(v) => setEventForm({ ...eventForm, description: v })} />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={eventForm.active !== false} onChange={(e) => setEventForm({ ...eventForm, active: e.target.checked })} />
Active
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
<Button type="submit">{editingEvent ? 'Update event' : 'Add event'}</Button>
{editingEvent && <SecondaryButton onClick={() => { setEditingEvent(null); setEventForm({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true }); }}>Cancel</SecondaryButton>}
</div>
</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="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 })} />
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} /> <Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
<Text label="Photos (comma-separated URLs)" value={typeof roomForm.photos === 'string' ? roomForm.photos : roomForm.photos.join(', ')} onChange={(v) => setRoomForm({ ...roomForm, photos: v })} /> <Text label="Photos (comma-separated URLs)" value={typeof roomForm.photos === 'string' ? roomForm.photos : roomForm.photos.join(', ')} onChange={(v) => setRoomForm({ ...roomForm, photos: v })} />
<Number label="Sort order" value={roomForm.sort_order ?? 0} onChange={(v) => setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} />
<Text label="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} /> <Text label="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={roomForm.active !== false} onChange={(e) => setRoomForm({ ...roomForm, active: e.target.checked })} />
Active
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
<Button type="submit">{editingRoom ? 'Update room' : 'Add room'}</Button> <Button type="submit">{editingRoom ? 'Update room' : 'Add room'}</Button>
{editingRoom && <SecondaryButton onClick={() => { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: [] }); }}>Cancel</SecondaryButton>} {editingRoom && <SecondaryButton onClick={() => { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
</div> </div>
</form> </form>
<ListTable rows={rooms.map((r) => [r.name, `$${r.price}`, r.description.slice(0, 60)])} onEdit={(i) => editRoom(rooms[i])} onDelete={(i) => deleteRoom(rooms[i].id)} /> <ListTable rows={rooms.map((r) => [r.name, `$${r.price}`, r.description.slice(0, 60), r.active ? 'On' : 'Off', `#${r.sort_order}`])} onEdit={(i) => editRoom(rooms[i])} onDelete={(i) => deleteRoom(rooms[i].id)} />
</Section> </Section>
<Section title="Menu"> <Section title="Menu">
@@ -352,17 +502,22 @@ export default function AdminPage() {
<Text label="Category" value={menuForm.category || ''} onChange={(v) => setMenuForm({ ...menuForm, category: v })} /> <Text label="Category" value={menuForm.category || ''} onChange={(v) => setMenuForm({ ...menuForm, category: v })} />
<Text label="Name" value={menuForm.name || ''} onChange={(v) => setMenuForm({ ...menuForm, name: v })} /> <Text label="Name" value={menuForm.name || ''} onChange={(v) => setMenuForm({ ...menuForm, name: v })} />
<Text label="Price" value={menuForm.price || ''} onChange={(v) => setMenuForm({ ...menuForm, price: v })} /> <Text label="Price" value={menuForm.price || ''} onChange={(v) => setMenuForm({ ...menuForm, price: v })} />
<Number label="Sort order" value={menuForm.sort_order ?? 0} onChange={(v) => setMenuForm({ ...menuForm, sort_order: parseInt(v || '0', 10) })} />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={menuForm.is_daily_special || false} onChange={(e) => setMenuForm({ ...menuForm, is_daily_special: e.target.checked })} /> <input type="checkbox" checked={menuForm.is_daily_special || false} onChange={(e) => setMenuForm({ ...menuForm, is_daily_special: e.target.checked })} />
Daily special Daily special
</label> </label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={menuForm.active !== false} onChange={(e) => setMenuForm({ ...menuForm, active: e.target.checked })} />
Active
</label>
<Text label="Description" value={menuForm.description || ''} onChange={(v) => setMenuForm({ ...menuForm, description: v })} /> <Text label="Description" value={menuForm.description || ''} onChange={(v) => setMenuForm({ ...menuForm, description: v })} />
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
<Button type="submit">{editingMenu ? 'Update item' : 'Add item'}</Button> <Button type="submit">{editingMenu ? 'Update item' : 'Add item'}</Button>
{editingMenu && <SecondaryButton onClick={() => { setEditingMenu(null); setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false }); }}>Cancel</SecondaryButton>} {editingMenu && <SecondaryButton onClick={() => { setEditingMenu(null); setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
</div> </div>
</form> </form>
<ListTable rows={menu.map((m) => [m.category, m.name, `$${m.price}`, m.is_daily_special ? '★ Daily' : ''])} onEdit={(i) => editMenu(menu[i])} onDelete={(i) => deleteMenu(menu[i].id)} /> <ListTable rows={menu.map((m) => [m.category, m.name, `$${m.price}`, m.is_daily_special ? '★ Daily' : '', m.active ? 'On' : 'Off', `#${m.sort_order}`])} onEdit={(i) => editMenu(menu[i])} onDelete={(i) => deleteMenu(menu[i].id)} />
</Section> </Section>
<Section title="Landscape Places"> <Section title="Landscape Places">
@@ -371,14 +526,19 @@ export default function AdminPage() {
<Text label="Distance (km)" value={placeForm.distance_km || ''} onChange={(v) => setPlaceForm({ ...placeForm, distance_km: v })} /> <Text label="Distance (km)" value={placeForm.distance_km || ''} onChange={(v) => setPlaceForm({ ...placeForm, distance_km: v })} />
<Text label="Visit time" value={placeForm.visit_time || ''} onChange={(v) => setPlaceForm({ ...placeForm, visit_time: v })} /> <Text label="Visit time" value={placeForm.visit_time || ''} onChange={(v) => setPlaceForm({ ...placeForm, visit_time: v })} />
<Text label="Photo URL" value={placeForm.photo || ''} onChange={(v) => setPlaceForm({ ...placeForm, photo: v })} /> <Text label="Photo URL" value={placeForm.photo || ''} onChange={(v) => setPlaceForm({ ...placeForm, photo: v })} />
<Number label="Sort order" value={placeForm.sort_order ?? 0} onChange={(v) => setPlaceForm({ ...placeForm, sort_order: parseInt(v || '0', 10) })} />
<Text label="Description" value={placeForm.description || ''} onChange={(v) => setPlaceForm({ ...placeForm, description: v })} /> <Text label="Description" value={placeForm.description || ''} onChange={(v) => setPlaceForm({ ...placeForm, description: v })} />
<Text label="Suggestion" value={placeForm.suggestion || ''} onChange={(v) => setPlaceForm({ ...placeForm, suggestion: v })} /> <Text label="Suggestion" value={placeForm.suggestion || ''} onChange={(v) => setPlaceForm({ ...placeForm, suggestion: v })} />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={placeForm.active !== false} onChange={(e) => setPlaceForm({ ...placeForm, active: e.target.checked })} />
Active
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
<Button type="submit">{editingPlace ? 'Update place' : 'Add place'}</Button> <Button type="submit">{editingPlace ? 'Update place' : 'Add place'}</Button>
{editingPlace && <SecondaryButton onClick={() => { setEditingPlace(null); setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '' }); }}>Cancel</SecondaryButton>} {editingPlace && <SecondaryButton onClick={() => { setEditingPlace(null); setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
</div> </div>
</form> </form>
<ListTable rows={places.map((p) => [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60)])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} /> <ListTable rows={places.map((p) => [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.active ? 'On' : 'Off', `#${p.sort_order}`])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} />
</Section> </Section>
<Section title="Pending Reviews"> <Section title="Pending Reviews">
@@ -445,6 +605,36 @@ function Text({ label, value, onChange, type = 'text' }: { label: string; value:
); );
} }
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, onClick, type = 'button', disabled = false }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit'; disabled?: boolean }) {
return ( return (
<button <button
+45
View File
@@ -0,0 +1,45 @@
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 { date, title, type, description, color, active } = body;
const { rows } = await db.query(
`UPDATE calendar_events SET date = $1, title = $2, type = $3, description = $4, color = $5, active = $6
WHERE id = $7 RETURNING *`,
[date, title, type, description || null, color || '#E8A849', active, id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('PUT /api/calendar_events/:id error:', error);
return NextResponse.json({ error: error.message || 'Failed to update 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 calendar_events WHERE id = $1', [id]);
return NextResponse.json({ ok: true });
} catch (error: any) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('DELETE /api/calendar_events/:id error:', error);
return NextResponse.json({ error: error.message || 'Failed to delete event' }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const year = parseInt(request.nextUrl.searchParams.get('year') || '', 10) || new Date().getFullYear();
const start = `${year}-01-01`;
const end = `${year}-12-31`;
const { rows } = await db.query(
`SELECT * FROM calendar_events
WHERE date BETWEEN $1 AND $2 AND active = TRUE
ORDER BY date`,
[start, end]
);
return NextResponse.json({ data: rows });
} catch (error: any) {
console.error('GET /api/calendar_events error:', error);
return NextResponse.json({ error: error.message || 'Failed to fetch events' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { date, title, type, description, color, active } = body;
if (!date || !title || !type) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const { rows } = await db.query(
`INSERT INTO calendar_events (date, title, type, description, color, active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[date, title, type, description || null, color || '#E8A849', active !== false]
);
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('POST /api/calendar_events error:', error);
return NextResponse.json({ error: error.message || 'Failed to create event' }, { status: 500 });
}
}
+45
View File
@@ -0,0 +1,45 @@
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 { title, subtitle, image_id, image_url, active, sort_order } = body;
const { rows } = await db.query(
`UPDATE slides SET title = $1, subtitle = $2, image_id = $3, image_url = $4, active = $5, sort_order = $6
WHERE id = $7 RETURNING *`,
[title || null, subtitle || null, image_id || null, image_url || null, active, sort_order || 0, id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Slide not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('PUT /api/slides/:id error:', error);
return NextResponse.json({ error: error.message || 'Failed to update slide' }, { 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 slides WHERE id = $1', [id]);
return NextResponse.json({ ok: true });
} catch (error: any) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('DELETE /api/slides/:id error:', error);
return NextResponse.json({ error: error.message || 'Failed to delete slide' }, { status: 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
try {
const { rows } = await db.query(
`SELECT s.*, i.data as image_data
FROM slides s
LEFT JOIN images i ON s.image_id = i.id
WHERE s.active = TRUE
ORDER BY s.sort_order, s.id`
);
return NextResponse.json({ data: rows });
} catch (error: any) {
console.error('GET /api/slides error:', error);
return NextResponse.json({ error: error.message || 'Failed to fetch slides' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { title, subtitle, image_id, image_url, active, sort_order } = body;
const { rows } = await db.query(
`INSERT INTO slides (title, subtitle, image_id, image_url, active, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[title || null, subtitle || null, image_id || null, image_url || null, active !== false, sort_order || 0]
);
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('POST /api/slides error:', error);
return NextResponse.json({ error: error.message || 'Failed to create slide' }, { status: 500 });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
// Free public weather API for Otavalo, Ecuador (lat=-0.2329, lon=-78.2603)
const url = 'https://api.open-meteo.com/v1/forecast?latitude=-0.2329&longitude=-78.2603&current_weather=true&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=America/Guayaquil';
const res = await fetch(url, { next: { revalidate: 1800 } });
if (!res.ok) {
throw new Error('Open-Meteo response not OK');
}
const json = await res.json();
const codeMap: Record<number, string> = {
0: 'Clear', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain', 71: 'Light snow', 73: 'Snow',
75: 'Heavy snow', 80: 'Rain showers', 81: 'Rain showers', 82: 'Heavy showers',
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm'
};
const current = json.current_weather || {};
const daily = json.daily || {};
return NextResponse.json({
data: {
current: {
temperature: current.temperature,
windspeed: current.windspeed,
weathercode: current.weathercode,
summary: codeMap[current.weathercode] || 'Unknown',
},
today: {
max: daily.temperature_2m_max?.[0],
min: daily.temperature_2m_min?.[0],
weathercode: daily.weathercode?.[0],
summary: codeMap[daily.weathercode?.[0]] || 'Unknown',
},
},
});
} catch (error: any) {
console.error('Weather API error:', error);
return NextResponse.json(
{
data: {
current: { temperature: 18, windspeed: 6, weathercode: 1, summary: 'Mainly clear' },
today: { max: 22, min: 10, weathercode: 1, summary: 'Mainly clear' },
},
},
{ status: 200 }
);
}
}
+24 -5
View File
@@ -1,3 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import Slideshow from '@/components/Slideshow';
import CalendarStrip from '@/components/CalendarStrip';
import MeasuredText from '@/components/MeasuredText'; import MeasuredText from '@/components/MeasuredText';
const gold = '#E8A849'; const gold = '#E8A849';
@@ -5,13 +10,24 @@ const navy = '#010D1E';
const cream = '#f5f0e8'; const cream = '#f5f0e8';
export default function HomePage() { export default function HomePage() {
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
useEffect(() => {
fetch('/api/site-assets?key=tagline')
.then((r) => r.json())
.then((j) => {
if (j.data) setTagline(j.data);
})
.catch(() => {});
}, []);
return ( return (
<main <main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
<Slideshow />
<section
style={{ style={{
padding: 'clamp(3rem, 9vw, 7rem) 2rem', padding: 'clamp(3rem, 9vw, 7rem) 2rem',
minHeight: 'calc(100vh - 56px)',
background: navy,
color: cream,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
@@ -28,7 +44,7 @@ export default function HomePage() {
margin: '0 0 1rem', margin: '0 0 1rem',
}} }}
> >
Hotel · Restaurant · Vineyard {tagline}
</p> </p>
<h1 <h1
style={{ style={{
@@ -46,6 +62,9 @@ export default function HomePage() {
Welcome to the TypeScript + Next.js version of La Huasca. Welcome to the TypeScript + Next.js version of La Huasca.
</p> </p>
<MeasuredText text="Hello, Pretext!" /> <MeasuredText text="Hello, Pretext!" />
</section>
<CalendarStrip />
</main> </main>
); );
} }
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useRef, useState } from 'react';
interface CalendarEvent {
id: number;
date: string;
title: string;
type: 'holiday' | 'season' | 'weather' | 'event';
description: string | null;
color: string | null;
}
interface WeatherData {
current?: { temperature: number; summary: string };
today?: { max: number; min: number; summary: string };
}
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
const typeEmoji: Record<CalendarEvent['type'], string> = {
holiday: '🎉',
season: '🌿',
weather: '☀️',
event: '📌',
};
function formatMonthDay(dateStr: string) {
const d = new Date(dateStr + 'T00:00:00');
return { month: d.toLocaleString('en-US', { month: 'short' }), day: d.getDate() };
}
function daysUntil(dateStr: string) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(dateStr + 'T00:00:00');
const diff = Math.ceil((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diff === 0) return 'Today';
if (diff === 1) return 'Tomorrow';
if (diff < 0) return 'Past';
return `in ${diff} days`;
}
export default function CalendarStrip() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [weather, setWeather] = useState<WeatherData | null>(null);
const [paused, setPaused] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch('/api/calendar_events')
.then((r) => r.json())
.then((j) => setEvents((j.data || []).slice(0, 12)))
.catch(() => {});
fetch('/api/weather')
.then((r) => r.json())
.then((j) => setWeather(j.data || null))
.catch(() => {});
}, []);
const cards = events.map((e) => ({ ...e, isWeather: false })).concat(
weather
? [
{
id: 9999,
date: new Date().toISOString().split('T')[0],
title: `${weather.current?.temperature ?? '--'}°C ${weather.current?.summary ?? ''}`,
type: 'weather' as const,
description: `Today ${weather.today?.max ?? '--'}° / ${weather.today?.min ?? '--'}°`,
color: '#4FC3F7',
isWeather: true,
},
]
: []
);
if (cards.length === 0) return null;
return (
<section
style={{
background: `linear-gradient(to right, ${navy}, #002436)`,
padding: '2rem 0',
overflow: 'hidden',
borderTop: `1px solid rgba(232,168,73,0.25)`,
borderBottom: `1px solid rgba(232,168,73,0.25)`,
}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div
style={{
display: 'flex',
gap: '1rem',
padding: '0 1rem',
width: 'max-content',
animation: paused ? 'none' : 'marquee 38s linear infinite',
}}
ref={trackRef}
>
{[...cards, ...cards].map((e, i) => {
const { month, day } = formatMonthDay(e.date);
const accent = e.color || gold;
return (
<div
key={`${e.id}-${i}`}
style={{
width: '220px',
flexShrink: 0,
borderRadius: '18px',
padding: '1.25rem',
background: 'rgba(245,240,232,0.06)',
border: '1px solid rgba(245,240,232,0.12)',
backdropFilter: 'blur(6px)',
color: cream,
transition: 'transform 300ms, box-shadow 300ms',
}}
onMouseEnter={(event) => {
event.currentTarget.style.transform = 'scale(1.04) translateY(-4px)';
event.currentTarget.style.boxShadow = `0 12px 30px rgba(0,0,0,0.25)`;
}}
onMouseLeave={(event) => {
event.currentTarget.style.transform = 'scale(1)';
event.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.8rem' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '14px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: accent,
color: navy,
fontWeight: 700,
lineHeight: 1.1,
}}
>
<span style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{month}</span>
<span style={{ fontSize: '20px' }}>{day}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.08em', color: accent, marginBottom: '0.2rem' }}>
{typeEmoji[e.type]} {e.type}
</div>
<div style={{ fontSize: '15px', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.title}</div>
</div>
</div>
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(245,240,232,0.72)', lineHeight: 1.45 }}>
{e.description || ''}
</p>
<div style={{ marginTop: '0.8rem', fontSize: '12px', color: 'rgba(245,240,232,0.5)' }}>
{e.isWeather ? 'Otavalo now' : daysUntil(e.date)}
</div>
</div>
);
})}
</div>
<style jsx global>{`
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
`}</style>
</section>
);
}
+8 -3
View File
@@ -47,9 +47,7 @@ export default function Footer() {
<div> <div>
<h3 style={{ color: gold, fontSize: '18px', margin: '0 0 1rem', fontStyle: 'italic' }}>La Huasca</h3> <h3 style={{ color: gold, fontSize: '18px', margin: '0 0 1rem', fontStyle: 'italic' }}>La Huasca</h3>
<p style={{ color: 'rgba(245,240,232,0.75)', lineHeight: 1.55, margin: 0 }}> <p style={{ color: 'rgba(245,240,232,0.75)', lineHeight: 1.55, margin: 0 }}>
Hotel · Restaurant · Vineyard A quiet corner of the Ecuadorian Andes.
<br />
A quiet corner of the Honduran highlands.
</p> </p>
</div> </div>
@@ -60,6 +58,13 @@ export default function Footer() {
{config.email && <p style={{ margin: 0 }}><a href={`mailto:${config.email}`} style={{ color: cream, textDecoration: 'none' }}>{config.email}</a></p>} {config.email && <p style={{ margin: 0 }}><a href={`mailto:${config.email}`} style={{ color: cream, textDecoration: 'none' }}>{config.email}</a></p>}
</div> </div>
<div>
<h4 style={{ color: gold, fontSize: '14px', letterSpacing: '0.08em', textTransform: 'uppercase', margin: '0 0 1rem' }}>Atmosphere</h4>
<p style={{ color: 'rgba(245,240,232,0.75)', lineHeight: 1.55, margin: 0 }}>
Hotel · Restaurant · Andean Highlands
</p>
</div>
<div> <div>
<h4 style={{ color: gold, fontSize: '14px', letterSpacing: '0.08em', textTransform: 'uppercase', margin: '0 0 1rem' }}>Follow us</h4> <h4 style={{ color: gold, fontSize: '14px', letterSpacing: '0.08em', textTransform: 'uppercase', margin: '0 0 1rem' }}>Follow us</h4>
<div style={{ display: 'flex', gap: '1rem' }}> <div style={{ display: 'flex', gap: '1rem' }}>
+208
View File
@@ -0,0 +1,208 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
interface Slide {
id: number;
title: string | null;
subtitle: string | null;
image_data: string | null;
image_url: string | null;
}
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
export default function Slideshow() {
const [slides, setSlides] = useState<Slide[]>([]);
const [index, setIndex] = useState(0);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
fetch('/api/slides')
.then((r) => r.json())
.then((j) => {
const data = j.data || [];
setSlides(data);
if (data.length > 0) setLoaded(true);
})
.catch(() => {});
}, []);
const next = useCallback(() => {
setIndex((i) => (slides.length ? (i + 1) % slides.length : 0));
}, [slides.length]);
const prev = () => {
setIndex((i) => (slides.length ? (i - 1 + slides.length) % slides.length : 0));
};
useEffect(() => {
if (slides.length <= 1) return;
const id = setInterval(next, 6000);
return () => clearInterval(id);
}, [slides.length, next]);
if (!loaded || slides.length === 0) return null;
const slide = slides[index];
const image = slide.image_data || slide.image_url || '';
return (
<div
style={{
position: 'relative',
width: '100%',
height: 'clamp(380px, 60vh, 720px)',
overflow: 'hidden',
background: navy,
}}
>
{slides.map((s, i) => {
const src = s.image_data || s.image_url || '';
return (
<div
key={s.id}
style={{
position: 'absolute',
inset: 0,
opacity: i === index ? 1 : 0,
transition: 'opacity 900ms ease-in-out',
backgroundImage: src ? `url(${src})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
transform: i === index ? 'scale(1)' : 'scale(1.06)',
transitionProperty: 'opacity, transform',
transitionDuration: '900ms',
transitionTimingFunction: 'ease-in-out',
}}
>
{!src && <div style={{ width: '100%', height: '100%', background: `linear-gradient(135deg, ${navy}, #00334a)` }} />}
</div>
);
})}
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(1,13,30,0.78) 0%, rgba(1,13,30,0.22) 50%, rgba(1,13,30,0.42) 100%)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
padding: '2rem',
}}
>
<h2
style={{
fontSize: 'clamp(32px, 6vw, 64px)',
fontWeight: 400,
fontStyle: 'italic',
color: cream,
margin: '0 0 0.75rem',
opacity: 0,
animation: 'slideUpFade 800ms forwards',
animationDelay: '120ms',
}}
>
{slide.title || 'La Huasca'}
</h2>
{slide.subtitle && (
<p
style={{
fontSize: 'clamp(16px, 2.2vw, 22px)',
color: 'rgba(245,240,232,0.82)',
maxWidth: '42ch',
opacity: 0,
animation: 'slideUpFade 800ms forwards',
animationDelay: '280ms',
}}
>
{slide.subtitle}
</p>
)}
</div>
{slides.length > 1 && (
<>
<button
onClick={prev}
aria-label="Previous slide"
style={{
position: 'absolute',
left: '1rem',
top: '50%',
transform: 'translateY(-50%)',
width: '44px',
height: '44px',
borderRadius: '50%',
border: 'none',
background: 'rgba(245,240,232,0.14)',
color: cream,
fontSize: '20px',
cursor: 'pointer',
backdropFilter: 'blur(4px)',
transition: 'background 200ms, transform 200ms',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.color = navy; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(245,240,232,0.14)'; e.currentTarget.style.color = cream; }}
>
</button>
<button
onClick={next}
aria-label="Next slide"
style={{
position: 'absolute',
right: '1rem',
top: '50%',
transform: 'translateY(-50%)',
width: '44px',
height: '44px',
borderRadius: '50%',
border: 'none',
background: 'rgba(245,240,232,0.14)',
color: cream,
fontSize: '20px',
cursor: 'pointer',
backdropFilter: 'blur(4px)',
transition: 'background 200ms, transform 200ms',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.color = navy; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(245,240,232,0.14)'; e.currentTarget.style.color = cream; }}
>
</button>
<div style={{ position: 'absolute', bottom: '1.5rem', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '0.6rem' }}>
{slides.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
aria-label={`Go to slide ${i + 1}`}
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: i === index ? gold : 'rgba(245,240,232,0.35)',
transition: 'background 300ms',
}}
/>
))}
</div>
</>
)}
<style jsx global>{`
@keyframes slideUpFade {
from { opacity: 0; transform: translateY(28px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
}
+57 -1
View File
@@ -121,6 +121,32 @@ export async function initSchema() {
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
); );
`); `);
await db.query(`
CREATE TABLE IF NOT EXISTS slides (
id SERIAL PRIMARY KEY,
title VARCHAR(255),
subtitle TEXT,
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
image_url VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
title VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('holiday', 'season', 'weather', 'event')),
description TEXT,
color VARCHAR(50) DEFAULT '#E8A849',
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
} }
export async function seed() { export async function seed() {
@@ -196,10 +222,40 @@ export async function seed() {
['user', 'COFFEE5', '$5 off any coffee tasting'] ['user', 'COFFEE5', '$5 off any coffee tasting']
); );
} }
const { rows: calendarRows } = await db.query(`SELECT id FROM calendar_events`);
if (calendarRows.length === 0) {
const thisYear = new Date().getFullYear();
await db.query(
`INSERT INTO calendar_events (date, title, type, description, color) VALUES
($1, $2, $3, $4, $5), ($6, $7, $8, $9, $10), ($11, $12, $13, $14, $15),
($16, $17, $18, $19, $20), ($21, $22, $23, $24, $25), ($26, $27, $28, $29, $30),
($31, $32, $33, $34, $35), ($36, $37, $38, $39, $40), ($41, $42, $43, $44, $45),
($46, $47, $48, $49, $50), ($51, $52, $53, $54, $55)`,
[
`${thisYear}-01-01`, 'New Year', 'holiday', 'Start the year in the Andes.', '#E8A849',
`${thisYear}-02-14`, 'Valentine', 'holiday', 'Special dinner menu.', '#D94B73',
`${thisYear}-03-20`, 'Spring Equinox', 'season', 'Blooming highlands.', '#7CB342',
`${thisYear}-06-21`, 'Summer Solstice', 'season', 'Longest days of the year.', '#E8A849',
`${thisYear}-09-22`, 'Fall Equinox', 'season', 'Harvest colors.', '#A6683C',
`${thisYear}-10-31`, 'Halloween', 'holiday', 'Costume dinner party.', '#9C27B0',
`${thisYear}-11-02`, 'Day of the Dead', 'holiday', 'Traditional altar and meal.', '#FF9800',
`${thisYear}-12-21`, 'Winter Solstice', 'season', 'Cozy fires and clear skies.', '#4FC3F7',
`${thisYear}-12-25`, 'Christmas', 'holiday', 'Holiday feast.', '#C62828',
`${thisYear}-12-31`, 'New Year Eve', 'holiday', 'Countdown dinner.', '#E8A849',
`${thisYear}-06-28`, 'Sunny 22°C', 'weather', 'Typical Otavalo dry season day.', '#FFD54F',
]
);
}
const { rows: siteRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
if (siteRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', 'Hotel · Restaurant · Andean Highlands']);
}
} }
export async function resetDatabase() { export async function resetDatabase() {
await db.query('DROP TABLE IF EXISTS reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users CASCADE'); await db.query('DROP TABLE IF EXISTS reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events CASCADE');
await initSchema(); await initSchema();
await seed(); await seed();
} }
File diff suppressed because one or more lines are too long