diff --git a/src/app/admin/page.tsx.bak b/src/app/admin/page.tsx.bak new file mode 100644 index 0000000..ee6e187 --- /dev/null +++ b/src/app/admin/page.tsx.bak @@ -0,0 +1,1271 @@ +'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 ( +