Admin portal, public pages, footer, site config
This commit is contained in:
+405
-201
@@ -10,6 +10,55 @@ interface ImageRecord {
|
|||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Room {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
photos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomForm {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
photos: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
is_daily_special: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
photo: string | null;
|
||||||
|
distance_km: string | null;
|
||||||
|
visit_time: string | null;
|
||||||
|
suggestion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Review {
|
||||||
|
id: number;
|
||||||
|
author_name: string;
|
||||||
|
rating: number;
|
||||||
|
text: string;
|
||||||
|
approved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
}
|
||||||
|
|
||||||
const gold = '#E8A849';
|
const gold = '#E8A849';
|
||||||
const navy = '#001321';
|
const navy = '#001321';
|
||||||
const warm = '#a6683c';
|
const warm = '#a6683c';
|
||||||
@@ -20,54 +69,67 @@ export default function AdminPage() {
|
|||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = 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 [message, setMessage] = useState<string | null>(null);
|
|
||||||
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
|
const [roomForm, setRoomForm] = useState<RoomForm>({ name: '', description: '', price: '', photos: '' });
|
||||||
|
const [editingRoom, setEditingRoom] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [menu, setMenu] = useState<MenuItem[]>([]);
|
||||||
|
const [menuForm, setMenuForm] = useState<Partial<MenuItem>>({ category: '', name: '', description: '', price: '', is_daily_special: false });
|
||||||
|
const [editingMenu, setEditingMenu] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [places, setPlaces] = useState<Place[]>([]);
|
||||||
|
const [placeForm, setPlaceForm] = useState<Partial<Place>>({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '' });
|
||||||
|
const [editingPlace, setEditingPlace] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([]);
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [userForm, setUserForm] = useState({ username: '', password: '', role: 'user' as 'admin' | 'user' });
|
||||||
|
|
||||||
|
const [footer, setFooter] = useState({
|
||||||
|
facebook_url: '', instagram_url: '', whatsapp_url: '', address: '', phone: '', email: '',
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/images', { credentials: 'same-origin' })
|
fetch('/api/admin/images', { credentials: 'same-origin' })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) { router.push('/login'); return; }
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error('Failed to load images');
|
throw new Error('Failed to load images');
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setImages(data.images || []);
|
setImages(data.images || []);
|
||||||
setCount(data.count || 0);
|
setCount(data.count || 0);
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message));
|
||||||
.finally(() => setLoading(false));
|
|
||||||
|
|
||||||
fetch('/api/site-assets?key=logo')
|
fetch('/api/rooms').then((r) => r.json()).then((j) => setRooms(j.data || [])).catch(() => {});
|
||||||
.then(async (res) => {
|
fetch('/api/menu').then((r) => r.json()).then((j) => setMenu(j.data || [])).catch(() => {});
|
||||||
if (!res.ok) return;
|
fetch('/api/places').then((r) => r.json()).then((j) => setPlaces(j.data || [])).catch(() => {});
|
||||||
const data = await res.json();
|
fetch('/api/admin/reviews', { credentials: 'same-origin' }).then((r) => r.json()).then((j) => setReviews(j.data || [])).catch(() => {});
|
||||||
if (data.data) setLogoUrl(data.data);
|
fetch('/api/admin/users', { credentials: 'same-origin' }).then((r) => r.json()).then((j) => setUsers(j.data || [])).catch(() => {});
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
fetch('/api/site-assets?key=favicon')
|
fetch('/api/site-assets').then((res) => res.json()).then((json) => {
|
||||||
.then(async (res) => {
|
const c = json.data || {};
|
||||||
if (!res.ok) return;
|
setFooter({
|
||||||
const data = await res.json();
|
facebook_url: c.facebook_url || '', instagram_url: c.instagram_url || '', whatsapp_url: c.whatsapp_url || '',
|
||||||
if (data.data) setFaviconUrl(data.data);
|
address: c.address || '', phone: c.phone || '', email: c.email || '',
|
||||||
})
|
});
|
||||||
.catch(() => {});
|
if (c.logo) setLogoUrl(c.logo);
|
||||||
|
if (c.favicon) setFaviconUrl(c.favicon);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
const notify = (msg: string) => {
|
||||||
if (!faviconUrl) return;
|
setMessage(msg);
|
||||||
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null;
|
setTimeout(() => setMessage(null), 4000);
|
||||||
if (!link) {
|
};
|
||||||
link = document.createElement('link');
|
|
||||||
link.rel = 'icon';
|
|
||||||
document.head.appendChild(link);
|
|
||||||
}
|
|
||||||
link.href = faviconUrl;
|
|
||||||
}, [faviconUrl]);
|
|
||||||
|
|
||||||
const updateAsset = async (key: string, value: string) => {
|
const updateAsset = async (key: string, value: string) => {
|
||||||
const res = await fetch('/api/site-assets', {
|
const res = await fetch('/api/site-assets', {
|
||||||
@@ -76,18 +138,24 @@ export default function AdminPage() {
|
|||||||
body: JSON.stringify({ key, value }),
|
body: JSON.stringify({ key, value }),
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) { setError('Failed to save ' + key); return; }
|
||||||
setError('Failed to save ' + key);
|
notify(`${key} updated.`);
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
setMessage(`${key} updated. Refresh the page to see the change.`);
|
const saveFooter = async () => {
|
||||||
setTimeout(() => setMessage(null), 4000);
|
const res = await fetch('/api/site-assets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ values: footer }),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!res.ok) { setError('Failed to save footer'); return; }
|
||||||
|
notify('Footer info updated.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
|
|
||||||
Array.from(files).forEach((file) => {
|
Array.from(files).forEach((file) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
@@ -98,10 +166,7 @@ export default function AdminPage() {
|
|||||||
body: JSON.stringify({ filename: file.name, data }),
|
body: JSON.stringify({ filename: file.name, data }),
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) { setError('Upload failed'); return; }
|
||||||
setError('Upload failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
setImages((prev) => [result.image, ...prev]);
|
setImages((prev) => [result.image, ...prev]);
|
||||||
setCount((prev) => prev + 1);
|
setCount((prev) => prev + 1);
|
||||||
@@ -112,26 +177,108 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
const res = await fetch(`/api/admin/images?id=${id}`, {
|
const res = await fetch(`/api/admin/images?id=${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setImages((prev) => prev.filter((img) => img.id !== id));
|
setImages((prev) => prev.filter((img) => img.id !== id));
|
||||||
setCount((prev) => prev - 1);
|
setCount((prev) => prev - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const api = async (url: string, method: string, body?: object) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || 'Request failed');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRoom = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const photosValue = roomForm.photos || [];
|
||||||
|
const photosArray: string[] = typeof photosValue === 'string' ? (photosValue as unknown as string).split(',').map((s: string) => s.trim()) : photosValue;
|
||||||
|
const payload = { ...roomForm, photos: photosArray };
|
||||||
|
if (editingRoom) {
|
||||||
|
await api(`/api/rooms/${editingRoom}`, 'PUT', payload);
|
||||||
|
setRooms((prev) => prev.map((r) => (r.id === editingRoom ? { ...r, ...payload } as Room : r)));
|
||||||
|
setEditingRoom(null);
|
||||||
|
} else {
|
||||||
|
const json = await api('/api/rooms', 'POST', payload);
|
||||||
|
setRooms((prev) => [...prev, json.data]);
|
||||||
|
}
|
||||||
|
setRoomForm({ name: '', description: '', price: '', photos: [] });
|
||||||
|
notify('Room saved.');
|
||||||
|
};
|
||||||
|
|
||||||
|
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 saveMenu = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editingMenu) {
|
||||||
|
await api(`/api/menu/${editingMenu}`, 'PUT', menuForm);
|
||||||
|
setMenu((prev) => prev.map((m) => (m.id === editingMenu ? { ...m, ...menuForm } as MenuItem : m)));
|
||||||
|
setEditingMenu(null);
|
||||||
|
} else {
|
||||||
|
const json = await api('/api/menu', 'POST', menuForm);
|
||||||
|
setMenu((prev) => [...prev, json.data]);
|
||||||
|
}
|
||||||
|
setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false });
|
||||||
|
notify('Menu item saved.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const editMenu = (m: MenuItem) => { setMenuForm({ ...m }); setEditingMenu(m.id); };
|
||||||
|
const deleteMenu = async (id: number) => { await api(`/api/menu/${id}`, 'DELETE'); setMenu((prev) => prev.filter((m) => m.id !== id)); };
|
||||||
|
|
||||||
|
const savePlace = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editingPlace) {
|
||||||
|
await api(`/api/places/${editingPlace}`, 'PUT', placeForm);
|
||||||
|
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...placeForm } as Place : p)));
|
||||||
|
setEditingPlace(null);
|
||||||
|
} else {
|
||||||
|
const json = await api('/api/places', 'POST', placeForm);
|
||||||
|
setPlaces((prev) => [...prev, json.data]);
|
||||||
|
}
|
||||||
|
setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '' });
|
||||||
|
notify('Place saved.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const editPlace = (p: Place) => { setPlaceForm({ ...p }); setEditingPlace(p.id); };
|
||||||
|
const deletePlace = async (id: number) => { await api(`/api/places/${id}`, 'DELETE'); setPlaces((prev) => prev.filter((p) => p.id !== id)); };
|
||||||
|
|
||||||
|
const approveReview = async (id: number) => {
|
||||||
|
await api(`/api/admin/reviews/${id}`, 'PUT', { approved: true });
|
||||||
|
setReviews((prev) => prev.map((r) => (r.id === id ? { ...r, approved: true } : r)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteReview = async (id: number) => { await api(`/api/admin/reviews/${id}`, 'DELETE'); setReviews((prev) => prev.filter((r) => r.id !== id)); };
|
||||||
|
|
||||||
|
const saveUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const json = await api('/api/admin/users', 'POST', userForm);
|
||||||
|
setUsers((prev) => [...prev, json.user]);
|
||||||
|
setUserForm({ username: '', password: '', role: 'user' });
|
||||||
|
notify('User created.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (id: number) => { await api(`/api/admin/users/${id}`, 'DELETE'); setUsers((prev) => prev.filter((u) => u.id !== id)); };
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
|
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
|
||||||
<p>Loading gallery...</p>
|
<p>Loading admin...</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto' }}>
|
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto' }}>
|
||||||
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>
|
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>
|
||||||
Admin Portal
|
Admin Portal
|
||||||
</h1>
|
</h1>
|
||||||
@@ -141,164 +288,221 @@ export default function AdminPage() {
|
|||||||
{error && <p style={{ color: '#c23b22', marginBottom: '1rem' }}>{error}</p>}
|
{error && <p style={{ color: '#c23b22', marginBottom: '1rem' }}>{error}</p>}
|
||||||
{message && <p style={{ color: '#3b7a22', marginBottom: '1rem' }}>{message}</p>}
|
{message && <p style={{ color: '#3b7a22', marginBottom: '1rem' }}>{message}</p>}
|
||||||
|
|
||||||
<section
|
<Section title="Brand Assets">
|
||||||
style={{
|
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr auto', alignItems: 'end' }}>
|
||||||
marginBottom: '2rem',
|
<Text label="Navbar logo" value={logoUrl} onChange={setLogoUrl} />
|
||||||
padding: '1.5rem',
|
<Button onClick={() => updateAsset('logo', logoUrl)} disabled={!logoUrl}>Save logo</Button>
|
||||||
background: '#f5f0e8',
|
<Text label="Favicon" value={faviconUrl} onChange={setFaviconUrl} />
|
||||||
borderRadius: '16px',
|
<Button onClick={() => updateAsset('favicon', faviconUrl)} disabled={!faviconUrl}>Save favicon</Button>
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ fontSize: '18px', color: navy, margin: 0 }}>Brand Assets</h2>
|
|
||||||
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '13px', color: '#555' }}>
|
|
||||||
Navbar logo
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={logoUrl}
|
|
||||||
onChange={(e) => setLogoUrl(e.target.value)}
|
|
||||||
placeholder="Paste image data URL or upload a small image below"
|
|
||||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => updateAsset('logo', logoUrl)}
|
|
||||||
disabled={!logoUrl}
|
|
||||||
style={{
|
|
||||||
padding: '0.7rem 1.2rem',
|
|
||||||
background: gold,
|
|
||||||
color: navy,
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
cursor: logoUrl ? 'pointer' : 'not-allowed',
|
|
||||||
fontWeight: 600,
|
|
||||||
alignSelf: 'end',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save logo
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '13px', color: '#555' }}>
|
|
||||||
Favicon
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={faviconUrl}
|
|
||||||
onChange={(e) => setFaviconUrl(e.target.value)}
|
|
||||||
placeholder="Paste image data URL or upload a small image below"
|
|
||||||
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => updateAsset('favicon', faviconUrl)}
|
|
||||||
disabled={!faviconUrl}
|
|
||||||
style={{
|
|
||||||
padding: '0.7rem 1.2rem',
|
|
||||||
background: gold,
|
|
||||||
color: navy,
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
cursor: faviconUrl ? 'pointer' : 'not-allowed',
|
|
||||||
fontWeight: 600,
|
|
||||||
alignSelf: 'end',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save favicon
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: 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.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</Section>
|
||||||
|
|
||||||
<label
|
<Section title="Footer / Contact">
|
||||||
style={{
|
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||||
display: 'inline-flex',
|
<Text label="Facebook URL" value={footer.facebook_url} onChange={(v) => setFooter({ ...footer, facebook_url: v })} />
|
||||||
alignItems: 'center',
|
<Text label="Instagram URL" value={footer.instagram_url} onChange={(v) => setFooter({ ...footer, instagram_url: v })} />
|
||||||
gap: '0.5rem',
|
<Text label="WhatsApp URL" value={footer.whatsapp_url} onChange={(v) => setFooter({ ...footer, whatsapp_url: v })} />
|
||||||
padding: '0.75rem 1.25rem',
|
<Text label="Address" value={footer.address} onChange={(v) => setFooter({ ...footer, address: v })} />
|
||||||
background: gold,
|
<Text label="Phone" value={footer.phone} onChange={(v) => setFooter({ ...footer, phone: v })} />
|
||||||
color: navy,
|
<Text label="Email" value={footer.email} onChange={(v) => setFooter({ ...footer, email: v })} />
|
||||||
borderRadius: '999px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: '1.5rem',
|
|
||||||
transition: 'transform .12s, box-shadow .15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 6px 18px rgba(232,168,73,0.35)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ Upload images
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
onChange={handleUpload}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{images.length === 0 ? (
|
|
||||||
<p style={{ color: '#777' }}>No images uploaded yet.</p>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
|
||||||
gap: '1.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{images.map((img) => (
|
|
||||||
<div
|
|
||||||
key={img.id}
|
|
||||||
style={{
|
|
||||||
borderRadius: '16px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: '#f5f0e8',
|
|
||||||
boxShadow: '0 1px 2px rgba(1,13,30,0.08)',
|
|
||||||
transition: 'transform .2s, box-shadow .2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 6px 18px rgba(14,47,71,0.16)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(1,13,30,0.08)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<Button onClick={saveFooter}>Save footer info</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Image Gallery">
|
||||||
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem 1.25rem', background: gold, color: navy, borderRadius: '999px', cursor: 'pointer', fontWeight: 600, marginBottom: '1.5rem' }}>
|
||||||
|
+ Upload images
|
||||||
|
<input type="file" accept="image/*" multiple onChange={handleUpload} style={{ display: 'none' }} />
|
||||||
|
</label>
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<p style={{ color: '#777' }}>No images uploaded yet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '1.25rem' }}>
|
||||||
|
{images.map((img) => (
|
||||||
|
<div key={img.id} style={{ borderRadius: '16px', overflow: 'hidden', display: 'flex', flexDirection: 'column', background: '#f5f0e8' }}>
|
||||||
|
<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>
|
||||||
|
</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 })} />
|
||||||
|
<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="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||||
|
<Button type="submit">{editingRoom ? 'Update room' : 'Add room'}</Button>
|
||||||
|
{editingRoom && <SecondaryButton onClick={() => { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: [] }); }}>Cancel</SecondaryButton>}
|
||||||
|
</div>
|
||||||
|
</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)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Menu">
|
||||||
|
<form onSubmit={saveMenu} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
|
<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="Price" value={menuForm.price || ''} onChange={(v) => setMenuForm({ ...menuForm, price: v })} />
|
||||||
|
<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 })} />
|
||||||
|
Daily special
|
||||||
|
</label>
|
||||||
|
<Text label="Description" value={menuForm.description || ''} onChange={(v) => setMenuForm({ ...menuForm, description: v })} />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||||
|
<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>}
|
||||||
|
</div>
|
||||||
|
</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)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Landscape Places">
|
||||||
|
<form onSubmit={savePlace} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
|
<Text label="Name" value={placeForm.name || ''} onChange={(v) => setPlaceForm({ ...placeForm, name: 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="Photo URL" value={placeForm.photo || ''} onChange={(v) => setPlaceForm({ ...placeForm, photo: 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 })} />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||||
|
<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>}
|
||||||
|
</div>
|
||||||
|
</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)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Pending Reviews">
|
||||||
|
{reviews.length === 0 ? <p style={{ color: '#777' }}>No pending reviews.</p> : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{reviews.map((r) => (
|
||||||
|
<div key={r.id} style={{ background: '#fff', padding: '1rem 1.25rem', borderRadius: '12px', boxShadow: '0 1px 3px rgba(1,13,30,0.06)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||||
|
<strong>{r.author_name}</strong>
|
||||||
|
<span style={{ color: gold }}>{'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 0.75rem', color: '#555' }}>{r.text}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
{!r.approved && <Button onClick={() => approveReview(r.id)}>Approve</Button>}
|
||||||
|
<DangerButton onClick={() => deleteReview(r.id)}>Delete</DangerButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Users">
|
||||||
|
<form onSubmit={saveUser} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
|
<Text label="Username" value={userForm.username} onChange={(v) => setUserForm({ ...userForm, username: v })} />
|
||||||
|
<Text label="Password" value={userForm.password} type="password" onChange={(v) => setUserForm({ ...userForm, password: v })} />
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||||
|
Role
|
||||||
|
<select value={userForm.role} onChange={(e) => setUserForm({ ...userForm, role: e.target.value as 'admin' | 'user' })} style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'end' }}>
|
||||||
|
<Button type="submit">Create user</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ListTable rows={users.map((u) => [u.username, u.role])} onDelete={(i) => deleteUser(users[i].id)} />
|
||||||
|
</Section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{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 Button({ children, onClick, type = 'button', disabled = false }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit'; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
padding: '0.7rem 1.2rem',
|
||||||
|
background: disabled ? '#ccc' : gold,
|
||||||
|
color: navy,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '999px',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#c23b22', color: '#fff', border: 'none', borderRadius: '999px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (index: number) => void; onDelete: (index: number) => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{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>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
{onEdit && <Button onClick={() => onEdit(i)}>Edit</Button>}
|
||||||
|
<DangerButton onClick={() => onDelete(i)}>Delete</DangerButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,27 +2,45 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
const PUBLIC_KEYS = ['logo','favicon','facebook_url','instagram_url','whatsapp_url','address','phone','email','wifi_password'];
|
||||||
const key = request.nextUrl.searchParams.get('key');
|
|
||||||
if (!key) {
|
|
||||||
const { rows } = await db.query(
|
|
||||||
`SELECT key, value FROM config WHERE key IN ('logo','facebook_url','instagram_url','whatsapp_url','address','phone','email','wifi_password')`
|
|
||||||
);
|
|
||||||
const config: Record<string, string> = {};
|
|
||||||
rows.forEach((r: any) => config[r.key] = r.value);
|
|
||||||
return NextResponse.json({ data: config });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows } = await db.query('SELECT value FROM config WHERE key = $1', [key]);
|
export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ data: rows[0]?.value || null });
|
try {
|
||||||
|
const key = request.nextUrl.searchParams.get('key');
|
||||||
|
if (!key) {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT key, value FROM config WHERE key IN (${PUBLIC_KEYS.map((_,i) => `$${i+1}`).join(',')})`,
|
||||||
|
PUBLIC_KEYS
|
||||||
|
);
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
rows.forEach((r: any) => config[r.key] = r.value);
|
||||||
|
return NextResponse.json({ data: config });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await db.query('SELECT value FROM config WHERE key = $1', [key]);
|
||||||
|
return NextResponse.json({ data: rows[0]?.value || null });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { key, value } = body;
|
|
||||||
|
|
||||||
|
if (body.values && typeof body.values === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(body.values)) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO config (key, value) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||||
|
[key, value as string]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key, value } = body;
|
||||||
if (!key || typeof value !== 'string') {
|
if (!key || typeof value !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing key or value' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing key or value' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-3
@@ -1,8 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
is_daily_special: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gold = '#E8A849';
|
||||||
|
const navy = '#010D1E';
|
||||||
|
const cream = '#f5f0e8';
|
||||||
|
const warm = '#a6683c';
|
||||||
|
|
||||||
export default function CoffeePage() {
|
export default function CoffeePage() {
|
||||||
|
const [items, setItems] = useState<MenuItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/menu')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('Failed to load menu');
|
||||||
|
const json = await res.json();
|
||||||
|
setItems(json.data || []);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categories = Array.from(new Set(items.map((i) => i.category)));
|
||||||
|
const daily = items.find((i) => i.is_daily_special);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem' }}>
|
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||||
<h1>Coffee</h1>
|
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Coffee & Kitchen</h1>
|
||||||
<p>Enjoy locally sourced coffee and fresh pastries.</p>
|
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
||||||
|
Locally sourced coffee, fresh pastries, and seasonal plates.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <p style={{ color: warm }}>Loading menu...</p>}
|
||||||
|
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||||
|
|
||||||
|
{daily && (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${navy} 0%, #0e2f47 100%)`,
|
||||||
|
color: cream,
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '1.5rem 2rem',
|
||||||
|
marginBottom: '2rem',
|
||||||
|
boxShadow: '0 6px 20px rgba(1,13,30,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: gold,
|
||||||
|
color: navy,
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
padding: '0.35rem 0.8rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Menu of the Day
|
||||||
|
</span>
|
||||||
|
<h2 style={{ margin: '0 0 0.5rem', fontSize: '26px' }}>{daily.name}</h2>
|
||||||
|
<p style={{ margin: '0 0 1rem', opacity: 0.85 }}>{daily.description}</p>
|
||||||
|
<strong style={{ color: gold, fontSize: '20px' }}>${daily.price}</strong>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 && <p style={{ color: '#777' }}>No menu items yet.</p>}
|
||||||
|
|
||||||
|
{categories.map((category) => (
|
||||||
|
<section key={category} style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
color: navy,
|
||||||
|
borderBottom: '2px solid ' + warm,
|
||||||
|
display: 'inline-block',
|
||||||
|
paddingBottom: '0.3rem',
|
||||||
|
margin: '0 0 1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||||
|
{items
|
||||||
|
.filter((i) => i.category === category)
|
||||||
|
.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
background: cream,
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 1px 3px rgba(1,13,30,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.5rem' }}>
|
||||||
|
<strong style={{ color: navy }}>{item.name}</strong>
|
||||||
|
<span style={{ color: warm, fontWeight: 700 }}>${item.price}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-3
@@ -1,8 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Review {
|
||||||
|
id: number;
|
||||||
|
author_name: string;
|
||||||
|
rating: number;
|
||||||
|
text: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gold = '#E8A849';
|
||||||
|
const navy = '#010D1E';
|
||||||
|
const cream = '#f5f0e8';
|
||||||
|
const warm = '#a6683c';
|
||||||
|
|
||||||
export default function CommentsPage() {
|
export default function CommentsPage() {
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState({ author_name: '', rating: 5, text: '' });
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadReviews();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadReviews = () => {
|
||||||
|
fetch('/api/reviews')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('Failed to load reviews');
|
||||||
|
const json = await res.json();
|
||||||
|
setReviews(json.data || []);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const res = await fetch('/api/reviews', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSubmitted(true);
|
||||||
|
setForm({ author_name: '', rating: 5, text: '' });
|
||||||
|
loadReviews();
|
||||||
|
} else {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
setError(json.error || 'Failed to submit review');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem' }}>
|
<main style={{ padding: '2rem', maxWidth: '60rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||||
<h1>Comments</h1>
|
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Guest Comments</h1>
|
||||||
<p>Read what guests are saying about their stay.</p>
|
<p style={{ color: '#555', marginBottom: '2rem' }}>
|
||||||
|
Share your experience. Reviews appear after admin approval.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitted && <div style={{ padding: '1rem', background: '#e6f4ea', color: '#1e7e34', borderRadius: '12px', marginBottom: '1.5rem' }}>Thank you! Your review has been submitted for approval.</div>}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
style={{
|
||||||
|
background: cream,
|
||||||
|
padding: '1.5rem',
|
||||||
|
borderRadius: '20px',
|
||||||
|
marginBottom: '2.5rem',
|
||||||
|
boxShadow: '0 1px 4px rgba(1,13,30,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.author_name}
|
||||||
|
onChange={(e) => setForm({ ...form, author_name: e.target.value })}
|
||||||
|
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
||||||
|
Rating
|
||||||
|
<select
|
||||||
|
value={form.rating}
|
||||||
|
onChange={(e) => setForm({ ...form, rating: parseInt(e.target.value) })}
|
||||||
|
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}
|
||||||
|
>
|
||||||
|
{[5, 4, 3, 2, 1].map((r) => (
|
||||||
|
<option key={r} value={r}>{r} ★</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555', marginBottom: '1rem' }}>
|
||||||
|
Your review
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
value={form.text}
|
||||||
|
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||||
|
style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: gold,
|
||||||
|
color: navy,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit review
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading && <p style={{ color: warm }}>Loading reviews...</p>}
|
||||||
|
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||||
|
|
||||||
|
{!loading && reviews.length === 0 && <p style={{ color: '#777' }}>No approved reviews yet. Be the first to share yours.</p>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{reviews.map((review) => (
|
||||||
|
<article
|
||||||
|
key={review.id}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 1px 3px rgba(1,13,30,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<strong style={{ color: navy }}>{review.author_name}</strong>
|
||||||
|
<span style={{ color: gold, letterSpacing: '0.1em' }}>{'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, color: '#555', lineHeight: 1.5 }}>{review.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
photo: string | null;
|
||||||
|
distance_km: string | null;
|
||||||
|
visit_time: string | null;
|
||||||
|
suggestion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gold = '#E8A849';
|
||||||
|
const navy = '#010D1E';
|
||||||
|
const cream = '#f5f0e8';
|
||||||
|
const warm = '#a6683c';
|
||||||
|
|
||||||
export default function LandscapePage() {
|
export default function LandscapePage() {
|
||||||
|
const [places, setPlaces] = useState<Place[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/places')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('Failed to load places');
|
||||||
|
const json = await res.json();
|
||||||
|
setPlaces(json.data || []);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem' }}>
|
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||||
<h1>Landscape</h1>
|
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Landscape</h1>
|
||||||
<p>Explore the trails, rivers, and cloud forest around La Huasca.</p>
|
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
||||||
|
Trails, rivers, waterfalls, and villages around La Huasca.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <p style={{ color: warm }}>Loading places...</p>}
|
||||||
|
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||||
|
|
||||||
|
{!loading && places.length === 0 && <p style={{ color: '#777' }}>No places added yet.</p>}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
||||||
|
{places.map((place) => (
|
||||||
|
<article
|
||||||
|
key={place.id}
|
||||||
|
style={{
|
||||||
|
background: cream,
|
||||||
|
borderRadius: '20px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '200px',
|
||||||
|
background: '#ddd',
|
||||||
|
backgroundImage: place.photo ? `url(${place.photo})` : undefined,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: '1.5rem', flex: 1 }}>
|
||||||
|
<h2 style={{ margin: '0 0 0.5rem', color: navy }}>{place.name}</h2>
|
||||||
|
<p style={{ color: '#555', lineHeight: 1.5 }}>{place.description}</p>
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{place.distance_km && <span style={{ fontSize: '13px', background: '#e8eff3', color: navy, padding: '0.3rem 0.7rem', borderRadius: '999px' }}>{place.distance_km} km</span>}
|
||||||
|
{place.visit_time && <span style={{ fontSize: '13px', background: '#e8eff3', color: navy, padding: '0.3rem 0.7rem', borderRadius: '999px' }}>{place.visit_time}</span>}
|
||||||
|
</div>
|
||||||
|
{place.suggestion && (
|
||||||
|
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#fff', borderLeft: '3px solid ' + gold, borderRadius: '0 10px 10px 0' }}>
|
||||||
|
<strong style={{ fontSize: '13px', color: warm }}>Suggestion:</strong>
|
||||||
|
<p style={{ margin: '0.25rem 0 0', fontSize: '14px', color: '#555' }}>{place.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,10 @@
|
|||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'La Huasca',
|
title: 'La Huasca',
|
||||||
description: 'TypeScript + Next.js version of La Huasca',
|
description: 'Hotel, restaurant, and vineyard in the Honduran highlands',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -16,6 +17,7 @@ export default function RootLayout({
|
|||||||
<body>
|
<body>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+80
-3
@@ -1,8 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Room {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
photos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const gold = '#E8A849';
|
||||||
|
const navy = '#010D1E';
|
||||||
|
const cream = '#f5f0e8';
|
||||||
|
|
||||||
export default function RoomsPage() {
|
export default function RoomsPage() {
|
||||||
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/rooms')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('Failed to load rooms');
|
||||||
|
const json = await res.json();
|
||||||
|
setRooms(json.data || []);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem' }}>
|
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||||
<h1>Rooms</h1>
|
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms & Suites</h1>
|
||||||
<p>Discover our comfortable rooms with stunning views.</p>
|
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
||||||
|
Comfortable stays with views of the vineyard and cloud forest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <p style={{ color: '#a6683c' }}>Loading rooms...</p>}
|
||||||
|
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||||
|
|
||||||
|
{!loading && rooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1.5rem' }}>
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<article
|
||||||
|
key={room.id}
|
||||||
|
style={{
|
||||||
|
background: cream,
|
||||||
|
borderRadius: '20px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '220px',
|
||||||
|
background: '#ddd',
|
||||||
|
backgroundImage: room.photos?.length > 0 ? `url(${room.photos[0]})` : undefined,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '22px' }}>{room.name}</h2>
|
||||||
|
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
|
||||||
|
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warm = '#a6683c';
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FacebookIcon, InstagramIcon, WhatsAppIcon } from './icons';
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
facebook_url?: string;
|
||||||
|
instagram_url?: string;
|
||||||
|
whatsapp_url?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gold = '#E8A849';
|
||||||
|
const navy = '#010D1E';
|
||||||
|
const cream = '#f5f0e8';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const [config, setConfig] = useState<Config>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/site-assets')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json) => setConfig(json.data || {}))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
background: navy,
|
||||||
|
color: cream,
|
||||||
|
padding: '3rem 2rem 2rem',
|
||||||
|
borderTop: '1px solid rgba(232,168,73,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '72rem',
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||||
|
gap: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<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 }}>
|
||||||
|
Hotel · Restaurant · Vineyard
|
||||||
|
<br />
|
||||||
|
A quiet corner of the Honduran highlands.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 style={{ color: gold, fontSize: '14px', letterSpacing: '0.08em', textTransform: 'uppercase', margin: '0 0 1rem' }}>Contact</h4>
|
||||||
|
{config.address && <p style={{ margin: '0 0 0.5rem', color: 'rgba(245,240,232,0.8)' }}>{config.address}</p>}
|
||||||
|
{config.phone && <p style={{ margin: '0 0 0.5rem' }}><a href={`tel:${config.phone}`} style={{ color: cream, textDecoration: 'none' }}>{config.phone}</a></p>}
|
||||||
|
{config.email && <p style={{ margin: 0 }}><a href={`mailto:${config.email}`} style={{ color: cream, textDecoration: 'none' }}>{config.email}</a></p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 style={{ color: gold, fontSize: '14px', letterSpacing: '0.08em', textTransform: 'uppercase', margin: '0 0 1rem' }}>Follow us</h4>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
{config.facebook_url && (
|
||||||
|
<a
|
||||||
|
href={config.facebook_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: cream, transition: 'color 0.2s, transform 0.2s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = gold; e.currentTarget.style.transform = 'scale(1.12)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = cream; e.currentTarget.style.transform = 'scale(1)'; }}
|
||||||
|
>
|
||||||
|
<FacebookIcon size={22} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{config.instagram_url && (
|
||||||
|
<a
|
||||||
|
href={config.instagram_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: cream, transition: 'color 0.2s, transform 0.2s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = gold; e.currentTarget.style.transform = 'scale(1.12)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = cream; e.currentTarget.style.transform = 'scale(1)'; }}
|
||||||
|
>
|
||||||
|
<InstagramIcon size={22} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{config.whatsapp_url && (
|
||||||
|
<a
|
||||||
|
href={config.whatsapp_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: cream, transition: 'color 0.2s, transform 0.2s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = gold; e.currentTarget.style.transform = 'scale(1.12)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = cream; e.currentTarget.style.transform = 'scale(1)'; }}
|
||||||
|
>
|
||||||
|
<WhatsAppIcon size={22} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '72rem',
|
||||||
|
margin: '2rem auto 0',
|
||||||
|
paddingTop: '1.5rem',
|
||||||
|
borderTop: '1px solid rgba(245,240,232,0.12)',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(245,240,232,0.55)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
© {new Date().getFullYear()} La Huasca. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const FacebookIcon = ({ size = 20 }: { size?: number }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InstagramIcon = ({ size = 20 }: { size?: number }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.2-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WhatsAppIcon = ({ size = 20 }: { size?: number }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.052 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user