diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 4f002d1..9b713a3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10,6 +10,55 @@ interface ImageRecord { 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 navy = '#001321'; const warm = '#a6683c'; @@ -20,54 +69,67 @@ export default function AdminPage() { const [count, setCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [message, setMessage] = useState(null); const [logoUrl, setLogoUrl] = useState(''); const [faviconUrl, setFaviconUrl] = useState(''); - const [message, setMessage] = useState(null); + + const [rooms, setRooms] = useState([]); + const [roomForm, setRoomForm] = useState({ name: '', description: '', price: '', photos: '' }); + const [editingRoom, setEditingRoom] = useState(null); + + const [menu, setMenu] = useState([]); + const [menuForm, setMenuForm] = useState>({ category: '', name: '', description: '', price: '', is_daily_special: false }); + const [editingMenu, setEditingMenu] = useState(null); + + const [places, setPlaces] = useState([]); + const [placeForm, setPlaceForm] = useState>({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '' }); + const [editingPlace, setEditingPlace] = useState(null); + + const [reviews, setReviews] = useState([]); + + const [users, setUsers] = useState([]); + 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(() => { fetch('/api/admin/images', { credentials: 'same-origin' }) .then(async (res) => { if (!res.ok) { - if (res.status === 401) { - router.push('/login'); - return; - } + if (res.status === 401) { router.push('/login'); return; } throw new Error('Failed to load images'); } const data = await res.json(); setImages(data.images || []); setCount(data.count || 0); }) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)); + .catch((err) => setError(err.message)); - fetch('/api/site-assets?key=logo') - .then(async (res) => { - if (!res.ok) return; - const data = await res.json(); - if (data.data) setLogoUrl(data.data); - }) - .catch(() => {}); + fetch('/api/rooms').then((r) => r.json()).then((j) => setRooms(j.data || [])).catch(() => {}); + fetch('/api/menu').then((r) => r.json()).then((j) => setMenu(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/users', { credentials: 'same-origin' }).then((r) => r.json()).then((j) => setUsers(j.data || [])).catch(() => {}); - fetch('/api/site-assets?key=favicon') - .then(async (res) => { - if (!res.ok) return; - const data = await res.json(); - if (data.data) setFaviconUrl(data.data); - }) - .catch(() => {}); + fetch('/api/site-assets').then((res) => res.json()).then((json) => { + const c = json.data || {}; + setFooter({ + facebook_url: c.facebook_url || '', instagram_url: c.instagram_url || '', whatsapp_url: c.whatsapp_url || '', + address: c.address || '', phone: c.phone || '', email: c.email || '', + }); + if (c.logo) setLogoUrl(c.logo); + if (c.favicon) setFaviconUrl(c.favicon); + }).catch(() => {}); + + setLoading(false); }, [router]); - useEffect(() => { - if (!faviconUrl) return; - let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null; - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = faviconUrl; - }, [faviconUrl]); + const notify = (msg: string) => { + setMessage(msg); + setTimeout(() => setMessage(null), 4000); + }; const updateAsset = async (key: string, value: string) => { const res = await fetch('/api/site-assets', { @@ -76,18 +138,24 @@ export default function AdminPage() { body: JSON.stringify({ key, value }), credentials: 'same-origin', }); - if (!res.ok) { - setError('Failed to save ' + key); - return; - } - setMessage(`${key} updated. Refresh the page to see the change.`); - setTimeout(() => setMessage(null), 4000); + if (!res.ok) { setError('Failed to save ' + key); return; } + notify(`${key} updated.`); + }; + + const saveFooter = async () => { + 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) => { const files = e.target.files; if (!files) return; - Array.from(files).forEach((file) => { const reader = new FileReader(); reader.onload = async () => { @@ -98,10 +166,7 @@ export default function AdminPage() { body: JSON.stringify({ filename: file.name, data }), credentials: 'same-origin', }); - if (!res.ok) { - setError('Upload failed'); - return; - } + if (!res.ok) { setError('Upload failed'); return; } const result = await res.json(); setImages((prev) => [result.image, ...prev]); setCount((prev) => prev + 1); @@ -112,26 +177,108 @@ export default function AdminPage() { }; const handleDelete = async (id: number) => { - const res = await fetch(`/api/admin/images?id=${id}`, { - method: 'DELETE', - credentials: 'same-origin', - }); + const res = await fetch(`/api/admin/images?id=${id}`, { method: 'DELETE', credentials: 'same-origin' }); if (res.ok) { setImages((prev) => prev.filter((img) => img.id !== id)); 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) { return (
-

Loading gallery...

+

Loading admin...

); } return ( -
+

Admin Portal

@@ -141,164 +288,221 @@ export default function AdminPage() { {error &&

{error}

} {message &&

{message}

} -
-

Brand Assets

-
- - - - - +
+
+ + + +
-

+

Tip: click an uploaded gallery image to preview, copy its data URL, and paste it above.

-
+
- - {images.length === 0 ? ( -

No images uploaded yet.

- ) : ( -
- {images.map((img) => ( -
{ - 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.filename} - -
- ))} +
+
+ setFooter({ ...footer, facebook_url: v })} /> + setFooter({ ...footer, instagram_url: v })} /> + setFooter({ ...footer, whatsapp_url: v })} /> + setFooter({ ...footer, address: v })} /> + setFooter({ ...footer, phone: v })} /> + setFooter({ ...footer, email: v })} />
- )} +
+ +
+
+ +
+ + {images.length === 0 ? ( +

No images uploaded yet.

+ ) : ( +
+ {images.map((img) => ( +
+ {img.filename} + +
+ ))} +
+ )} +
+ +
+
+ setRoomForm({ ...roomForm, name: v })} /> + setRoomForm({ ...roomForm, price: v })} /> + setRoomForm({ ...roomForm, photos: v })} /> + setRoomForm({ ...roomForm, description: v })} /> +
+ + {editingRoom && { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: [] }); }}>Cancel} +
+ + [r.name, `$${r.price}`, r.description.slice(0, 60)])} onEdit={(i) => editRoom(rooms[i])} onDelete={(i) => deleteRoom(rooms[i].id)} /> +
+ +
+
+ setMenuForm({ ...menuForm, category: v })} /> + setMenuForm({ ...menuForm, name: v })} /> + setMenuForm({ ...menuForm, price: v })} /> + + setMenuForm({ ...menuForm, description: v })} /> +
+ + {editingMenu && { setEditingMenu(null); setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false }); }}>Cancel} +
+ + [m.category, m.name, `$${m.price}`, m.is_daily_special ? '★ Daily' : ''])} onEdit={(i) => editMenu(menu[i])} onDelete={(i) => deleteMenu(menu[i].id)} /> +
+ +
+
+ setPlaceForm({ ...placeForm, name: v })} /> + setPlaceForm({ ...placeForm, distance_km: v })} /> + setPlaceForm({ ...placeForm, visit_time: v })} /> + setPlaceForm({ ...placeForm, photo: v })} /> + setPlaceForm({ ...placeForm, description: v })} /> + setPlaceForm({ ...placeForm, suggestion: v })} /> +
+ + {editingPlace && { setEditingPlace(null); setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '' }); }}>Cancel} +
+ + [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60)])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} /> +
+ +
+ {reviews.length === 0 ?

No pending reviews.

: ( +
+ {reviews.map((r) => ( +
+
+ {r.author_name} + {'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)} +
+

{r.text}

+
+ {!r.approved && } + deleteReview(r.id)}>Delete +
+
+ ))} +
+ )} +
+ +
+
+ setUserForm({ ...userForm, username: v })} /> + setUserForm({ ...userForm, password: v })} /> + +
+ +
+ + [u.username, u.role])} onDelete={(i) => deleteUser(users[i].id)} /> +
); } + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Text({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (v: string) => void; type?: string }) { + return ( + + ); +} + +function Button({ children, onClick, type = 'button', disabled = false }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit'; disabled?: boolean }) { + return ( + + ); +} + +function SecondaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { + return ( + + ); +} + +function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { + return ( + + ); +} + +function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (index: number) => void; onDelete: (index: number) => void }) { + return ( +
+ {rows.map((row, i) => ( +
+
+ {row.map((cell, j) => {cell})} +
+
+ {onEdit && } + onDelete(i)}>Delete +
+
+ ))} +
+ ); +} diff --git a/src/app/api/site-assets/route.ts b/src/app/api/site-assets/route.ts index e4b0900..e2d5ccb 100644 --- a/src/app/api/site-assets/route.ts +++ b/src/app/api/site-assets/route.ts @@ -2,27 +2,45 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireRole } from '@/lib/auth'; import { db } from '@/lib/db'; -export async function GET(request: NextRequest) { - 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 = {}; - rows.forEach((r: any) => config[r.key] = r.value); - return NextResponse.json({ data: config }); - } +const PUBLIC_KEYS = ['logo','favicon','facebook_url','instagram_url','whatsapp_url','address','phone','email','wifi_password']; - const { rows } = await db.query('SELECT value FROM config WHERE key = $1', [key]); - return NextResponse.json({ data: rows[0]?.value || null }); +export async function GET(request: NextRequest) { + 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 = {}; + 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) { try { await requireRole('admin'); 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') { return NextResponse.json({ error: 'Missing key or value' }, { status: 400 }); } diff --git a/src/app/coffee/page.tsx b/src/app/coffee/page.tsx index 1440882..5d82d1c 100644 --- a/src/app/coffee/page.tsx +++ b/src/app/coffee/page.tsx @@ -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() { + const [items, setItems] = useState([]); + 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 ( -
-

Coffee

-

Enjoy locally sourced coffee and fresh pastries.

+
+

Coffee & Kitchen

+

+ Locally sourced coffee, fresh pastries, and seasonal plates. +

+ + {loading &&

Loading menu...

} + {error &&

{error}

} + + {daily && ( +
+ + Menu of the Day + +

{daily.name}

+

{daily.description}

+ ${daily.price} +
+ )} + + {!loading && items.length === 0 &&

No menu items yet.

} + + {categories.map((category) => ( +
+

+ {category} +

+
+ {items + .filter((i) => i.category === category) + .map((item) => ( +
+
+ {item.name} + ${item.price} +
+

{item.description}

+
+ ))} +
+
+ ))}
); } diff --git a/src/app/comments/page.tsx b/src/app/comments/page.tsx index e79405f..9de5d70 100644 --- a/src/app/comments/page.tsx +++ b/src/app/comments/page.tsx @@ -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() { + const [reviews, setReviews] = useState([]); + 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 ( -
-

Comments

-

Read what guests are saying about their stay.

+
+

Guest Comments

+

+ Share your experience. Reviews appear after admin approval. +

+ + {submitted &&
Thank you! Your review has been submitted for approval.
} + +
+
+ + + +
+ +