From 288767f6c042954c7a9e2cb9f180d7c90162cc0c Mon Sep 17 00:00:00 2001 From: muken Date: Fri, 17 Jul 2026 14:24:48 -0500 Subject: [PATCH] Security: remove hardcoded credentials, require DB_PASSWORD, delete .bak file, warn on secrets template --- k8s/bootstrap-secrets.yaml | 1 + scripts/migrate-images.js | 6 +- src/app/admin/page.tsx.bak | 1271 ------------------------------------ src/hooks/useAuth.ts | 11 +- 4 files changed, 7 insertions(+), 1282 deletions(-) delete mode 100644 src/app/admin/page.tsx.bak diff --git a/k8s/bootstrap-secrets.yaml b/k8s/bootstrap-secrets.yaml index 6e1b3e2..6708230 100644 --- a/k8s/bootstrap-secrets.yaml +++ b/k8s/bootstrap-secrets.yaml @@ -1,3 +1,4 @@ +# WARNING: Replace CHANGE_ME_* values before applying apiVersion: v1 kind: Secret metadata: diff --git a/scripts/migrate-images.js b/scripts/migrate-images.js index 4b3733c..6d046a5 100644 --- a/scripts/migrate-images.js +++ b/scripts/migrate-images.js @@ -11,7 +11,11 @@ const pool = new Pool({ port: 5432, database: 'lahuasca', user: 'lahuasca', - password: process.env.DB_PASSWORD || 'lahuasca123', + password: (() => { + const p = process.env.DB_PASSWORD; + if (!p) throw new Error('DB_PASSWORD env var is required'); + return p; + })(), }); async function convertToWebP(base64Data) { diff --git a/src/app/admin/page.tsx.bak b/src/app/admin/page.tsx.bak deleted file mode 100644 index ee6e187..0000000 --- a/src/app/admin/page.tsx.bak +++ /dev/null @@ -1,1271 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; - -interface ImageRecord { - id: number; - filename: string; - data: string; - uploaded_at: string; -} - -interface Room { - id: number; - name: string; - description: string; - price: string; - photos: string[]; - featured_photo: string | null; - active: boolean; - sort_order: number; -} - -interface RoomForm { - id?: number; - name: string; - description: string; - price: string; - photos: string[]; - featured_photo: string | null; - active: boolean; - sort_order: number; -} - -interface RoomComment { - id: number; - room_id: number; - photo_url: string | null; - user_id: number; - first_name: string; - last_initial: string | null; - text: string; - created_at: string; -} - -interface User { - id: number; - username: string; - role: 'admin' | 'user'; - first_name: string | null; - last_name: string | null; - comments_disabled: boolean; -} - -interface MenuItem { - id: number; - category: string; - name: string; - description: string; - price: string; - is_daily_special: boolean; - active: boolean; - sort_order: number; -} - -interface Place { - id: number; - name: string; - description: string; - photos: string[]; - featured_photo: string | null; - distance_km: string | null; - visit_time: string | null; - suggestion: string | null; - active: boolean; - sort_order: number; -} - -interface Review { - id: number; - author_name: string; - rating: number; - text: string; - approved: boolean; -} - -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; -} - -interface SocialEvent { - id: number; - name: string; - description: string; - price: string | null; - date: string | null; - photos: string[]; - featured_photo: string | null; - active: boolean; - sort_order: number; - reservation_count: number; -} - -interface SocialEventReservation { - id: number; - social_event_id: number; - user_id: number; - username: string; - event_name: string; - guests: number; - notes: string; - status: 'pending' | 'confirmed' | 'cancelled'; - created_at: string; -} - -const gold = '#E8A849'; -const navy = '#001321'; -const warm = '#a6683c'; - -export default function AdminPage() { - const router = useRouter(); - const [images, setImages] = useState([]); - 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 [tagline, setTagline] = useState('Hotel ยท Restaurant ยท Andean Highlands'); - - const [rooms, setRooms] = useState([]); - const [roomForm, setRoomForm] = useState({ name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); - const [editingRoom, setEditingRoom] = useState(null); - const [roomComments, setRoomComments] = useState([]); - const [roomCommentRoomId, setRoomCommentRoomId] = useState(null); - const [editingUser, setEditingUser] = useState(null); - const [userEditForm, setUserEditForm] = useState({ first_name: '', last_name: '', comments_disabled: false }); - - const [menu, setMenu] = useState([]); - const [menuForm, setMenuForm] = useState>({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 }); - const [editingMenu, setEditingMenu] = useState(null); - - const [places, setPlaces] = useState([]); - const [placeForm, setPlaceForm] = useState>({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 }); - 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 [slides, setSlides] = useState([]); - const [slideForm, setSlideForm] = useState>({ title: '', subtitle: '', image_id: null, image_url: '', active: true, sort_order: 0 }); - const [editingSlide, setEditingSlide] = useState(null); - - const [events, setEvents] = useState([]); - const [eventForm, setEventForm] = useState>({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true }); - const [editingEvent, setEditingEvent] = useState(null); - - const [socialEvents, setSocialEvents] = useState([]); - const [socialEventForm, setSocialEventForm] = useState>({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); - const [editingSocialEvent, setEditingSocialEvent] = useState(null); - const [socialEventReservations, setSocialEventReservations] = useState([]); - const [socialEventReservationsFor, setSocialEventReservationsFor] = useState(null); - - 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; } - throw new Error('Failed to load images'); - } - const data = await res.json(); - setImages(data.images || []); - setCount(data.count || 0); - }) - .catch((err) => setError(err.message)); - - 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/slides').then((r) => r.json()).then((j) => setSlides(j.data || [])).catch(() => {}); - fetch('/api/calendar_events').then((r) => r.json()).then((j) => setEvents(j.data || [])).catch(() => {}); - fetch('/api/social_events').then((r) => r.json()).then((j) => setSocialEvents(j.data || [])).catch(() => {}); - - fetch('/api/site-assets').then((res) => res.json()).then((json) => { - const c = json.data || {}; - 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); - if (c.tagline) setTagline(c.tagline); - }).catch(() => {}); - - setLoading(false); - }, [router]); - - const notify = (msg: string) => { - setMessage(msg); - setTimeout(() => setMessage(null), 4000); - }; - - const updateAsset = async (key: string, value: string) => { - const res = await fetch('/api/site-assets', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value }), - credentials: 'same-origin', - }); - 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 [uploadCategory, setUploadCategory] = useState('other'); - const [uploadRoomId, setUploadRoomId] = useState(''); - const [uploadLocationTarget, setUploadLocationTarget] = useState('gallery'); - - 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 () => { - const data = reader.result as string; - const body: Record = { filename: file.name, data, category: uploadCategory, location_target: uploadLocationTarget }; - if (uploadCategory === 'rooms' && uploadRoomId) body.room_id = Number(uploadRoomId); - const res = await fetch('/api/admin/images', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - credentials: 'same-origin', - }); - if (!res.ok) { setError('Upload failed'); return; } - const result = await res.json(); - setImages((prev) => [{ ...result.image, data, category: uploadCategory }, ...prev]); - setCount((prev) => prev + 1); - }; - reader.readAsDataURL(file); - }); - }; - - const handleDelete = async (id: number) => { - 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 payload = { ...roomForm, photos: roomForm.photos || [], featured_photo: roomForm.featured_photo || null, active: roomForm.active !== false, sort_order: roomForm.sort_order || 0 }; - 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: [], featured_photo: null, active: true, sort_order: 0 }); - notify('Room saved.'); - }; - - const editRoom = (r: Room) => { setRoomForm({ ...r, photos: r.photos || [], featured_photo: r.featured_photo || null }); 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(); - const payload = { ...menuForm, active: menuForm.active !== false, sort_order: menuForm.sort_order || 0 }; - if (editingMenu) { - await api(`/api/menu/${editingMenu}`, 'PUT', payload); - setMenu((prev) => prev.map((m) => (m.id === editingMenu ? { ...m, ...payload } as MenuItem : m))); - setEditingMenu(null); - } else { - const json = await api('/api/menu', 'POST', payload); - setMenu((prev) => [...prev, json.data]); - } - setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 }); - 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(); - const payload = { - ...placeForm, - photos: Array.isArray(placeForm.photos) ? placeForm.photos : [], - featured_photo: placeForm.featured_photo || null, - active: placeForm.active === true, - sort_order: placeForm.sort_order || 0, - }; - if (editingPlace) { - const r = await api(`/api/places/${editingPlace}`, 'PUT', payload); - setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? ({ ...p, ...payload } as Place) : p))); - setEditingPlace(null); - } else { - const r = await api('/api/places', 'POST', payload); - setPlaces((prev) => [...prev, r.data]); - } - setPlaceForm({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 }); - notify('Place saved.'); - }; - - const editPlace = (p: Place) => { setPlaceForm({ ...p, photos: p.photos || [], featured_photo: p.featured_photo || null }); 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 loadRoomComments = async (roomId: number) => { - setRoomCommentRoomId(roomId); - const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' }); - const json = await res.json().catch(() => ({ data: [] })); - setRoomComments(json.data || []); - }; - - const deleteRoomComment = async (commentId: number) => { - await api(`/api/admin/comments/${commentId}`, 'DELETE'); - setRoomComments((prev) => prev.filter((c) => c.id !== commentId)); - }; - - const toggleUserComments = async (user: User) => { - const next = !user.comments_disabled; - await api(`/api/admin/users/${user.id}`, 'PUT', { comments_disabled: next }); - setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, comments_disabled: next } : u))); - notify(`User ${next ? 'disabled' : 'enabled'} for comments.`); - }; - - const saveUserNames = async (user: User) => { - await api(`/api/admin/users/${user.id}`, 'PUT', { - first_name: userEditForm.first_name, - last_name: userEditForm.last_name, - }); - setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, first_name: userEditForm.first_name, last_name: userEditForm.last_name } : u))); - setEditingUser(null); - notify('User updated.'); - }; - - 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)); }; - - 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)); }; - - const saveSocialEvent = async (e: React.FormEvent) => { - e.preventDefault(); - const payload = { - name: socialEventForm.name || '', - description: socialEventForm.description || '', - price: socialEventForm.price || null, - date: socialEventForm.date || null, - photos: socialEventForm.photos || [], - featured_photo: socialEventForm.featured_photo || null, - active: socialEventForm.active !== false, - sort_order: socialEventForm.sort_order || 0, - }; - if (editingSocialEvent) { - await api(`/api/social_events/${editingSocialEvent}`, 'PUT', payload); - setSocialEvents((prev) => prev.map((se) => (se.id === editingSocialEvent ? { ...se, ...payload } as SocialEvent : se))); - setEditingSocialEvent(null); - } else { - const json = await api('/api/social_events', 'POST', payload); - setSocialEvents((prev) => [...prev, json.data]); - } - setSocialEventForm({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); - notify('Social event saved.'); - }; - - const editSocialEvent = (se: SocialEvent) => { setSocialEventForm({ ...se, photos: se.photos || [], featured_photo: se.featured_photo || null }); setEditingSocialEvent(se.id); }; - const deleteSocialEvent = async (id: number) => { await api(`/api/social_events/${id}`, 'DELETE'); setSocialEvents((prev) => prev.filter((se) => se.id !== id)); }; - - const loadSocialEventReservations = async (eventId: number) => { - setSocialEventReservationsFor(eventId); - const res = await fetch(`/api/social_event_reservations?social_event_id=${eventId}`, { credentials: 'same-origin' }); - const json = await res.json().catch(() => ({ data: [] })); - setSocialEventReservations(json.data || []); - }; - - const updateReservationStatus = async (reservationId: number, status: SocialEventReservation['status']) => { - await api(`/api/admin/social_event_reservations/${reservationId}`, 'PUT', { status }); - setSocialEventReservations((prev) => prev.map((r) => (r.id === reservationId ? { ...r, status } : r))); - }; - - const deleteReservation = async (reservationId: number) => { - await api(`/api/admin/social_event_reservations/${reservationId}`, 'DELETE'); - setSocialEventReservations((prev) => prev.filter((r) => r.id !== reservationId)); - }; - - if (loading) { - return ( -
-

Loading admin...

-
- ); - } - - return ( -
-

- Admin Portal -

-

- Total uploaded images: {count} -

- {error &&

{error}

} - {message &&

{message}

} - - -
- - - - - - -
-

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

-
- - -
- 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 })} /> -
-
- -
-
- - -
-
- - - {uploadCategory === 'rooms' && ( - - )} - - -
- - -
- - {images.length === 0 ? ( -

No images uploaded yet.

- ) : ( -
- {images.map((img) => ( -
{ 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.filename} - -
- ))} -
- )} -
- - -
- setSlideForm({ ...slideForm, title: v })} /> - setSlideForm({ ...slideForm, subtitle: v })} /> - setSlideForm({ ...slideForm, sort_order: parseInt(v || '0', 10) })} /> - { - const imageId = v ? parseInt(v, 10) : null; - const image = imageId ? images.find((img) => img.id === imageId) : null; - setSlideForm({ ...slideForm, image_id: imageId, image_url: image?.data || '' }); - }} /> - {slideForm.image_url && ( -
- Slide preview -
- )} - -
- - {editingSlide && { setEditingSlide(null); setSlideForm({ title: '', subtitle: '', image_id: null, image_url: '', active: true, sort_order: 0 }); }}>Cancel} -
- - [s.title || '(untitled)', s.image_url ? '๐Ÿ–ผ๏ธ image' : 'no image', s.active ? 'On' : 'Off', `#${s.sort_order}`])} onEdit={(i) => editSlide(slides[i])} onDelete={(i) => deleteSlide(slides[i].id)} /> -
- - -
- setEventForm({ ...eventForm, date: v })} /> - setEventForm({ ...eventForm, title: v })} /> - - - setEventForm({ ...eventForm, description: v })} /> - -
- - {editingEvent && { setEditingEvent(null); setEventForm({ date: '', title: '', type: 'event', description: '', color: '#E8A849', active: true }); }}>Cancel} -
- - [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} /> -
- - -
- setSocialEventForm({ ...socialEventForm, name: v })} /> - setSocialEventForm({ ...socialEventForm, price: v })} /> - setSocialEventForm({ ...socialEventForm, date: v })} /> - setSocialEventForm({ ...socialEventForm, sort_order: parseInt(v || '0', 10) })} /> - setSocialEventForm({ ...socialEventForm, photos, featured_photo: featured })} - /> - setSocialEventForm({ ...socialEventForm, description: v })} /> - -
- - {editingSocialEvent && { setEditingSocialEvent(null); setSocialEventForm({ name: '', description: '', price: '', date: '', photos: [], featured_photo: null, active: true, sort_order: 0 }); }}>Cancel} -
- - [se.name, se.date || '', se.active ? 'On' : 'Off', `#${se.sort_order}`, `${se.reservation_count || 0} reservations`])} - onEdit={(i) => editSocialEvent(socialEvents[i])} - onDelete={(i) => deleteSocialEvent(socialEvents[i].id)} - onComments={(i) => loadSocialEventReservations(socialEvents[i].id)} - /> - {socialEventReservationsFor !== null && ( -
-
- Reservations for event #{socialEventReservationsFor} - { setSocialEventReservationsFor(null); setSocialEventReservations([]); }}>Close -
- {socialEventReservations.length === 0 ? ( -

No reservations yet.

- ) : ( -
- {socialEventReservations.map((r) => ( -
-
- {r.username || r.event_name} - {new Date(r.created_at).toLocaleDateString()} -
-

{`${r.guests} guest${r.guests === 1 ? '' : 's'}${r.notes ? ' โ€” ' + r.notes : ''}`}

-
- - deleteReservation(r.id)}>Delete -
-
- ))} -
- )} -
- )} -
- - -
- setRoomForm({ ...roomForm, name: v })} /> - setRoomForm({ ...roomForm, price: v })} /> - setRoomForm({ ...roomForm, description: v })} /> - setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} /> - setRoomForm({ ...roomForm, photos, featured_photo: featured })} - /> - -
- - {editingRoom && { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: ([] as string[]), featured_photo: null, active: true, sort_order: 0 }); }}>Cancel} -
- - [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)} - onComments={(i) => loadRoomComments(rooms[i].id)} - /> - {roomCommentRoomId !== null && ( -
-
- Comments for room #{roomCommentRoomId} - { setRoomCommentRoomId(null); setRoomComments([]); }}>Close -
- {roomComments.length === 0 ? ( -

No comments yet.

- ) : ( -
- {roomComments.map((c) => ( -
-
- {c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''} - {new Date(c.created_at).toLocaleDateString()} -
-

{c.text}

-
- deleteRoomComment(c.id)}>Remove comment -
-
- ))} -
- )} -
- )} -
- - -
- setMenuForm({ ...menuForm, category: v })} /> - setMenuForm({ ...menuForm, name: v })} /> - setMenuForm({ ...menuForm, price: v })} /> - setMenuForm({ ...menuForm, sort_order: parseInt(v || '0', 10) })} /> - - - setMenuForm({ ...menuForm, description: v })} /> -
- - {editingMenu && { setEditingMenu(null); setMenuForm({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 }); }}>Cancel} -
- - [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)} /> -
- - -
- setPlaceForm({ ...placeForm, name: v })} /> - setPlaceForm({ ...placeForm, distance_km: v })} /> - setPlaceForm({ ...placeForm, visit_time: v })} /> - setPlaceForm({ ...placeForm, sort_order: parseInt(v || '0', 10) })} /> - setPlaceForm({ ...placeForm, photos, featured_photo: featured })} - /> - setPlaceForm({ ...placeForm, description: v })} /> - setPlaceForm({ ...placeForm, suggestion: v })} /> - -
- - {editingPlace && { setEditingPlace(null); setPlaceForm({ name: '', description: '', photos: ([] as string[]), featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 }); }}>Cancel} -
- - [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.photos.length > 0 ? `๐Ÿ–ผ๏ธ ${p.photos.length}` : 'no image', p.active ? 'On' : 'Off', `#${p.sort_order}`])} 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 -
-
- ))} - function AccordionSection({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) { - const [open, setOpen] = useState(defaultOpen); - return ( -
-
setOpen((v) => !v)}> -

{title}

- {open ? 'Minimize' : 'Maximize'} -
-
- ); - } - {open &&
{children}
} - - ); - } - - function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) { - return ( - - ); - } - - function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) { - return ( - - ); - } - - function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { - return ( - - ); -} - -function Text({ label, value, type = 'text', onChange }: { label: string; value: string; type?: string; onChange: (v: string) => void }) { - return ( - - ); -} - -function Number({ label, value, onChange }: { label: string; value: number; onChange: (v: string) => void }) { - return ( - - ); -} - -function ImageSelect({ label, images, value, onChange }: { label: string; images: ImageRecord[]; value: string | number; onChange: (v: string) => void }) { - return ( - - ); -} - -function PhotoPicker({ label, images, photos, featuredPhoto, onChange }: { label: string; images: ImageRecord[]; photos: string[]; featuredPhoto: string | null; onChange: (photos: string[], featured: string | null) => void }) { - return ( -
- -
- {images.map((img) => { - const selected = photos.includes(img.data); - const featured = featuredPhoto === img.data; - return ( -
{ - const next = selected ? photos.filter((p) => p !== img.data) : [...photos, img.data]; - const nextFeatured = featured ? null : img.data; - onChange(next, nextFeatured); - }} - style={{ - border: selected ? `3px solid ${gold}` : '3px solid transparent', - borderRadius: '12px', - overflow: 'hidden', - cursor: 'pointer', - position: 'relative', - transition: 'transform 150ms', - }} - onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.03)'; }} - onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} - > - {featured && FEATURED} - {selected && !featured && โœ“} - {img.filename} -
- ); - })} -
-
- ); -} - -function ListTable({ rows, onEdit, onDelete, onComments }: { rows: (string | number)[][]; onEdit?: (i: number) => void; onDelete?: (i: number) => void; onComments?: (i: number) => void }) { - if (rows.length === 0) return

No items yet.

; - return ( -
- {rows.map((row, i) => ( -
-
- {row.map((cell, j) => ( - {cell} - ))} -
-
- {onEdit && onEdit(i)}>Edit} - {onComments && onComments(i)}>Reservations} - {onDelete && onDelete(i)}>Delete} -
-
- ))} -
- ); -} - -function Textarea({ label, value, onChange, rows = 3 }: { label: string; value: string; onChange: (v: string) => void; rows?: number }) { - return ( -