'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { formatDisplayDate, formatDisplayDateTime } from '@/lib/date';
type Image = { id: number; filename: string; data: string; category: string; room_id: number | null; location_target: string; uploaded_at: string };
type Slide = { id: number; title: string; subtitle: string; image_url: string | null; image_id: number | null; active: boolean; sort_order: number };
type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number; amenity_ids?: number[] };
type CalendarEvent = { id: number; date: string; title: string; type: string; color: string; description: string | null; active: boolean };
type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number };
type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number; photos: string[]; featured_photo: string | null };
type Place = { id: number; name: string; description: string; photos: string[]; featured_photo: string | null; distance_km: string; visit_time: string; suggestion: string; active: boolean; sort_order: number };
type Review = { id: number; author_name: string; author_email: string | null; rating: number; text: string; approved: boolean };
type FooterConfig = { logo: string; favicon: string; tagline: string; facebook_url: string; instagram_url: string; whatsapp_url: string; address: string; phone: string; email: string };
type SocialRes = { id: number; event_id: number; username: string; event_name: string; guests: number; notes: string; status: string; created_at: string };
type Amenity = { id: number; name: string; icon_svg: string; sort_order: number };
const C = {
gold: '#D4A853', goldLight: 'rgba(212,168,83,0.12)', navy: '#010D1E',
warm: '#7A5A3A', bg: '#F7F4EF', card: '#FFFFFF', border: 'rgba(1,13,30,0.08)',
text: '#444444', textLight: '#888888', danger: '#D32F2F', success: '#388E3C',
shadow: '0 1px 4px rgba(1,13,30,0.06)', shadowHover: '0 6px 20px rgba(1,13,30,0.12)',
};
const NAV = [
{ id: 'brand', label: 'Brand', icon: 'π¨' },
{ id: 'gallery', label: 'Images', icon: 'πΌοΈ' },
{ id: 'slides', label: 'Slideshow', icon: 'ποΈ' },
{ id: 'calendar', label: 'Calendar', icon: 'π
' },
{ id: 'social', label: 'Social Events', icon: 'πͺ' },
{ id: 'rooms', label: 'Rooms', icon: 'π¨' },
{ id: 'amenities', label: 'Amenities', icon: 'β¨' },
{ id: 'trips', label: 'Trips', icon: 'βοΈ' },
{ id: 'messages', label: 'Messages', icon: 'π¬' },
{ id: 'menu', label: 'Menu', icon: 'π½οΈ' },
{ id: 'places', label: 'Places', icon: 'πΊοΈ' },
{ id: 'reviews', label: 'Reviews', icon: 'β' },
{ id: 'users', label: 'Users', icon: 'π₯' },
{ id: 'terms', label: 'Terms', icon: 'π' },
{ id: 'weather', label: 'Weather', icon: 'π€οΈ' },
];
function I({ label, value, onChange, type = 'text', placeholder, style }: any) {
return (
{label && }
onChange(e.target.value)} placeholder={placeholder}
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none', background: '#FAFAFA', ...style }}
onFocus={e => { (e.target.style as any).borderColor = C.gold; (e.target.style as any).background = '#FFFFFF'; }}
onBlur={e => { (e.target.style as any).borderColor = C.border; (e.target.style as any).background = '#FAFAFA'; }} />
);
}
function TA({ label, value, onChange, rows = 3 }: any) {
return (
{label && }
);
}
function S({ label, value, onChange, options, style }: any) {
return (
{label && }
);
}
function Tgl({ label, checked, onChange }: any) {
return (
);
}
function Btn({ children, onClick, variant = 'primary', size = 'md', disabled }: any) {
const sz = size === 'sm' ? { padding: '5px 10px', fontSize: '12px' } : { padding: '8px 16px', fontSize: '14px' };
const bg = variant === 'danger' ? C.danger : variant === 'secondary' ? '#E8E4DF' : C.gold;
const color = variant === 'secondary' ? C.text : '#fff';
return (
);
}
function Badge({ children, color: c }: any) {
return (
{children}
);
}
function Toast({ message, type, onClose }: any) {
useEffect(() => { const t = setTimeout(onClose, 3500); return () => clearTimeout(t); }, [onClose]);
return (
{message}
);
}
function StatCard({ icon, value, label }: any) {
return (
);
}
function Empty({ icon, title, desc }: any) {
return (
{icon}
{title}
{desc &&
{desc}
}
);
}
/* βββ WebP Migration βββ */
function WebPMigration({ onToast, onRefresh }: { onToast: (msg: string, type: string) => void; onRefresh: () => void }) {
const [migrating, setMigrating] = useState(false);
const [stats, setStats] = useState<{ totalImages: number; totalSizeMB: number } | null>(null);
useEffect(() => {
fetch('/api/admin/migrate-images')
.then(res => res.ok ? res.json() : null)
.then(data => data && setStats({ totalImages: data.totalImages, totalSizeMB: data.totalSizeMB }))
.catch(() => {});
}, []);
const runMigration = async () => {
setMigrating(true);
try {
const res = await fetch('/api/admin/migrate-images', { method: 'POST' });
const data = await res.json();
if (res.ok) {
onToast(`Converted ${data.converted} images, saved ${data.totalSavedMB} MB`, 'success');
onRefresh();
} else {
onToast(data.error || 'Migration failed', 'error');
}
} catch {
onToast('Migration failed', 'error');
}
setMigrating(false);
};
return (
π WebP Migration
{stats ? `${stats.totalImages} images Β· ${stats.totalSizeMB} MB total` : 'Checking...'}
{migrating ? 'Migrating...' : 'Convert to WebP'}
);
}
function SecHead({ title, count, action }: any) {
return (
{title} ({count})
{action}
);
}
/* βββ Image helpers βββ */
function readFileBase64(file: File): Promise {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result as string);
r.onerror = rej;
r.readAsDataURL(file);
});
}
/* βββ Entity thumbnail βββ */
function Thumb({ urls, w = 200, h = 140 }: { urls: string[]; w?: number; h?: number }) {
if (!urls?.length) return No image
;
return
;
}
/* βββ Thumbnail grid (inline, small) βββ */
function MiniThumbs({ urls, max = 3 }: { urls: string[]; max?: number }) {
if (!urls?.length) return null;
return (
{urls.slice(0, max).map((u, i) => (

))}
{urls.length > max &&
+{urls.length - max}}
);
}
/* βββ Section uploader (per-section image upload) βββ */
function SectionUploader({ category, room_id, label, onUploaded }: { category: string; room_id: number | null; label: string; onUploaded?: () => void }) {
const ref = useRef(null);
return (
<>
ref.current?.click()} size="sm">{label}
{
for (const file of Array.from(e.target.files || [])) {
const b64 = await readFileBase64(file);
try {
const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, data: b64, category, room_id, location_target: category }),
});
if (res.ok) onUploaded?.();
} catch (err) { console.error(err); }
}
e.target.value = '';
}} />
>
);
}
/* βββ Organized image gallery with sections and borders βββ */
function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: number) => void }) {
const [selectedCategory, setSelectedCategory] = useState('all');
const [hoveredCard, setHoveredCard] = useState(null);
const sectionCategories = [
{ key: 'slides', label: 'ποΈ Slideshow Gallery', filter: (i: Image) => i.category === 'slides' || i.location_target === 'slides' },
{ key: 'calendar', label: 'π
Calendar Events Gallery', filter: (i: Image) => i.category === 'calendar' || i.location_target === 'calendar' },
{ key: 'social', label: 'πͺ Social Events Gallery', filter: (i: Image) => i.category === 'social' || i.location_target === 'social' },
];
// Group room images by room location_target (e.g. "room-1", "room-2")
const roomImages = images.filter(
(i) => i.category === 'rooms' || i.location_target?.startsWith('room-')
);
const roomsByNumber: Record = roomImages.reduce((acc, img) => {
const key = img.location_target?.replace('room-', '') || 'uncategorized';
if (!acc[key]) acc[key] = [];
acc[key].push(img);
return acc;
}, {} as Record);
const filteredImages = images.filter((img) => {
if (selectedCategory === 'all') return true;
if (selectedCategory === 'rooms') return img.category === 'rooms' || img.location_target?.startsWith('room-');
return img.category === selectedCategory || img.location_target === selectedCategory;
});
const ImageCard = ({ img }: { img: Image }) => {
const isHov = hoveredCard === img.id;
return (
setHoveredCard(img.id)}
onMouseLeave={() => setHoveredCard(null)}
>
{img.filename}
{/* Category label overlay on hover */}
{isHov && img.location_target && (
{img.location_target}
)}
);
};
const SectionBox = ({ title, count, children }: { title: string; count: number; children: React.ReactNode }) => (
{title}
{count}
{children}
);
return (
{/* Category filter bar with borders */}
{['all', 'slides', 'calendar', 'social', 'rooms', 'other'].map((cat) => (
setSelectedCategory(cat)}
variant={selectedCategory === cat ? 'primary' : 'secondary'}
size="sm"
>
{cat === 'all' ? 'All Images' : cat.charAt(0).toUpperCase() + cat.slice(1)}
))}
{selectedCategory === 'all' ? (
{/* Static sections */}
{sectionCategories.map((section) => {
const imgs = images.filter(section.filter);
if (imgs.length === 0) return null;
return (
{imgs.map((img) => )}
);
})}
{/* Rooms section with room number subdivision */}
{Object.keys(roomsByNumber).length > 0 && (
{Object.entries(roomsByNumber).map(([roomKey, imgs]) => (
ποΈ Room {roomKey}
{imgs.length} photo{imgs.length !== 1 ? 's' : ''}
{imgs.map((img) => )}
))}
)}
{/* Uncategorized/other */}
{(() => {
const otherImages = images.filter(
(i) => !sectionCategories.some(s => s.filter(i)) &&
!(i.category === 'rooms' || i.location_target?.startsWith('room-'))
);
if (otherImages.length === 0) return null;
return (
{otherImages.map((img) => )}
);
})()}
) : (
{filteredImages.length === 0 ? (
No images in this category
) : (
{filteredImages.map((img) => )}
)}
)}
);
}
/* βββ Entity card βββ */
/* βββ Entity card βββ */
function EntityCard({ title, desc, meta, thumb, actions, photoUrls }: any) {
return (
{ e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = C.shadowHover; }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = C.shadow; }}>
{thumb ? (

) : photoUrls?.length ?
:
π·
}
{title}
{desc &&
{desc}
}
{meta &&
{meta}
}
{photoUrls?.length ?
: null}
{actions &&
{actions}
}
);
}
/* βββ Confirm dialog βββ */
function Confirm({ message, onConfirm, onCancel }: any) {
return (
);
}
/* βββ Slides Section βββ */
function SlidesSection({ slides, setSlides, images, onToast, onRefreshImages }: any) {
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const saveSlide = async (slide: Slide) => {
try {
// If image_id is set, clear image_url (image is in images table)
const payload = {
...slide,
image_url: slide.image_id ? null : slide.image_url
};
const url = payload.id ? `/api/slides/${payload.id}` : '/api/slides';
const res = await fetch(url, {
method: payload.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
const data = await res.json();
setEditing(null);
setSlides((s: any) => payload.id ? s.map((x: Slide) => x.id === payload.id ? data.data : x) : [...s, data.data]);
onToast('Slide saved!', 'success');
} else {
const err = await res.json();
console.error('Slide save error:', err);
onToast(err.error || 'Error saving slide', 'error');
}
} catch (err) {
console.error('Slide save exception:', err);
onToast('Error saving slide', 'error');
}
};
const delSlide = async (id: number) => {
try {
await fetch(`/api/slides/${id}`, { method: 'DELETE' });
setSlides((s: any) => s.filter((x: Slide) => x.id !== id));
onToast('Slide deleted', 'success');
} catch { onToast('Error deleting slide', 'error'); }
setConfirmDel(null);
};
return (
setEditing({ id: 0, title: '', subtitle: '', image_url: null, image_id: null, active: true, sort_order: slides.length })} size="sm">+ New Slide} />
{slides.length === 0 ? : (
{slides.map((s: Slide) => (
i.id === s.image_id)?.data) : null}
actions={
<>
setEditing(s)}>Edit
setConfirmDel(s.id)}>Del
>
} />
))}
)}
{editing && (
setEditing(null)} />
)}
{confirmDel !== null && setConfirmDel(null)} onConfirm={() => delSlide(confirmDel)} />}
);
}
function SlideEditor({ slide, setSlide, images, onRefreshImages, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null);
const handleUpload = async (files: FileList) => {
setUploading(true);
try {
for (const file of Array.from(files)) {
const b64 = await readFileBase64(file);
const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, data: b64, category: 'slides', room_id: null, location_target: 'slides' }),
});
if (res.ok) {
const data = await res.json();
await onRefreshImages?.();
setSlide((prev: any) => ({ ...prev, image_id: data.data.id, image_url: data.data.data }));
} else {
const err = await res.json();
console.error('Image upload error:', err);
alert('Upload failed: ' + (err.error || 'Unknown error'));
}
}
} catch (err) {
console.error('Upload failed:', err);
alert('Upload failed');
} finally {
setUploading(false);
}
};
const s = slide;
return (
{s.id ? 'Edit Slide' : 'New Slide'}
setSlide({ ...s, title: v })} />
setSlide({ ...s, subtitle: v })} />
setShowPicker(!showPicker)} size="sm" variant="secondary">{showPicker ? 'Hide picker' : 'Pick from gallery'}
{ if (e.target.files?.length) handleUpload(e.target.files); e.target.value = ''; }} />
fileInputRef.current?.click()}>{uploading ? 'Uploading...' : 'Upload from computer'}
{s.image_id && images.find((i: Image) => i.id === s.image_id) && (

i.id === s.image_id)!.data} style={{ width: 80, height: 56, objectFit: 'cover', borderRadius: 8, border: `2px solid ${C.gold}` }} />
)}
{showPicker && (
{images.filter((i: Image) => i.category === 'slides').map((img: Image) => (

{ setSlide({ ...s, image_id: img.id, image_url: img.data }); setShowPicker(false); }}
style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, cursor: 'pointer', border: s.image_id === img.id ? `2px solid ${C.gold}` : '1px solid #eee' }} />
))}
{images.filter((i: Image) => i.category === 'slides').length === 0 &&
No images uploaded. Use "Upload from computer" above.}
)}
setSlide({ ...s, active: v })} />
setSlide({ ...s, sort_order: Number(v) })} style={{ width: 80 }} />
Cancel
onSave(s)}>Save
);
}
/* βββ Calendar Section βββ */
function CalendarSection({ events, setEvents, images, onToast }: any) {
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const save = async (ev: CalendarEvent) => {
try {
const url = ev.id ? `/api/calendar_events/${ev.id}` : '/api/calendar_events';
const res = await fetch(url, {
method: ev.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
});
if (res.ok) {
const data = await res.json();
setEditing(null);
setEvents((e: any) => ev.id ? e.map((x: CalendarEvent) => x.id === ev.id ? data.data : x) : [...e, data.data]);
onToast('Event saved!', 'success');
}
} catch { onToast('Error', 'error'); }
};
const del = async (id: number) => {
await fetch(`/api/calendar_events/${id}`, { method: 'DELETE' });
setEvents((e: any) => e.filter((x: CalendarEvent) => x.id !== id));
onToast('Event deleted', 'success');
setConfirmDel(null);
};
return (
setEditing({ id: 0, date: '', title: '', type: 'default', color: '#D4A853', description: null, active: true })} size="sm">+ New Event} />
{events.length === 0 ? : (
{events.map((ev: CalendarEvent) => (
setEditing(ev)}>Edit
setConfirmDel(ev.id)}>Del
>
} />
))}
)}
{editing && setEditing(null)} />}
{confirmDel !== null && setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
);
}
function CalendarEditor({ event, setEvent, onSave, onCancel }: any) {
const e = { ...event };
return (
{e.id ? 'Edit Event' : 'New Event'}
setEvent({ ...e, title: v })} />
setEvent({ ...e, date: v })} />
setEvent({ ...e, type: v })}
options={[{ value: 'default', label: 'Default' }, { value: 'special', label: 'Special' }, { value: 'holiday', label: 'Holiday' }]} />
setEvent({ ...e, color: v })} />
setEvent({ ...e, description: v || null })} />
setEvent({ ...e, active: v })} />
Cancel
onSave(e)}>Save
);
}
/* βββ Social Events Section βββ */
function SocialSection({ events, setEvents, images, onToast }: any) {
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const save = async (ev: SocialEvent) => {
try {
const url = ev.id ? `/api/social_events/${ev.id}` : '/api/social_events';
const res = await fetch(url, {
method: ev.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
});
if (res.ok) {
const data = await res.json();
setEditing(null);
setEvents((e: any) => ev.id ? e.map((x: SocialEvent) => x.id === ev.id ? data.data : x) : [...e, data.data]);
onToast('Event saved!', 'success');
}
} catch { onToast('Error', 'error'); }
};
const del = async (id: number) => {
await fetch(`/api/social_events/${id}`, { method: 'DELETE' });
setEvents((e: any) => e.filter((x: SocialEvent) => x.id !== id));
onToast('Event deleted', 'success');
setConfirmDel(null);
};
return (
setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, sort_order: events.length })} size="sm">+ New Event} />
{events.length === 0 ? : (
{events.map((ev: SocialEvent) => (
setEditing(ev)}>Edit
setConfirmDel(ev.id)}>Del
>
} />
))}
)}
{editing && setEditing(null)} />}
{confirmDel !== null && setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
);
}
function SocialEditor({ event, setEvent, images, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false);
const [localPhotos, setLocalPhotos] = useState(event.photos || []);
const ev = { ...event, photos: localPhotos };
const addPhotos = async (files: FileList) => {
const newPhotos: string[] = [];
for (const f of Array.from(files)) {
const b64 = await readFileBase64(f);
try {
const r = await fetch('/api/admin/images', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: f.name, data: b64, category: 'social', room_id: null, location_target: 'social' }),
});
if (r.ok) { const d = await r.json(); newPhotos.push(d.data.data); }
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
}
setLocalPhotos(prev => [...prev, ...newPhotos]);
};
return (
{ev.id ? 'Edit Event' : 'New Event'}
setEvent({ ...ev, name: v })} />
setEvent({ ...ev, description: v })} />
setEvent({ ...ev, price: v || null })} />
setEvent({ ...ev, date: v || null })} />
document.getElementById('social-photos')?.click()} size="sm" variant="secondary">+ Add Photos
{ addPhotos(e.target.files!); e.target.value = ''; }} />
{localPhotos.map((p, i) => (
))}
setEvent({ ...ev, active: v })} />
Cancel
onSave(ev)}>Save
);
}
/* βββ Rooms Section βββ */
function RoomsSection({ rooms, setRooms, images, onToast }: any) {
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const save = async (room: Room) => {
try {
const url = room.id ? `/api/rooms/${room.id}` : '/api/rooms';
const res = await fetch(url, {
method: room.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(room),
});
if (res.ok) {
const data = await res.json();
setEditing(null);
setRooms((r: any) => room.id ? r.map((x: Room) => x.id === room.id ? data.data : x) : [...r, data.data]);
onToast('Room saved!', 'success');
}
} catch { onToast('Error', 'error'); }
};
const del = async (id: number) => {
await fetch(`/api/rooms/${id}`, { method: 'DELETE' });
setRooms((r: any) => r.filter((x: Room) => x.id !== id));
onToast('Room deleted', 'success');
setConfirmDel(null);
};
return (
setEditing({ id: 0, name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: rooms.length })} size="sm">+ New Room} />
{rooms.length === 0 ? : (
{rooms.map((r: Room) => (
setEditing(r)}>Edit
setConfirmDel(r.id)}>Del
>
} />
))}
)}
{editing && setEditing(null)} />}
{confirmDel !== null && setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
);
}
function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false);
const [roomImages, setRoomImages] = useState<{id: number; data: string; thumbnail: string}[]>([]);
const [allAmenities, setAllAmenities] = useState([]);
const [selectedAmenities, setSelectedAmenities] = useState(room.amenity_ids || []);
const r = { ...room, photos: roomImages.map(img => img.thumbnail || img.data), amenity_ids: selectedAmenities };
useEffect(() => {
fetch('/api/amenities')
.then(res => res.json())
.then(d => setAllAmenities(d.data || []))
.catch(() => {});
// Load existing images for this room from images table
if (room.id) {
fetch(`/api/admin/images?room_id=${room.id}`)
.then(res => res.json())
.then(d => setRoomImages(d.data || []))
.catch(() => {});
}
}, [room.id]);
const toggleAmenity = (id: number) => {
setSelectedAmenities(prev =>
prev.includes(id) ? prev.filter(a => a !== id) : [...prev, id]
);
};
const addPhotos = async (files: FileList) => {
const newImages: {id: number; data: string; thumbnail: string}[] = [];
for (const f of Array.from(files)) {
const b64 = await readFileBase64(f);
try {
const res = await fetch('/api/admin/images', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: f.name, data: b64, category: 'rooms', room_id: r.id || null, location_target: `room-${r.id || 'new'}` }),
});
if (res.ok) {
const d = await res.json();
newImages.push({ id: d.data.id, data: d.data.data, thumbnail: d.data.thumbnail || d.data.data });
}
} catch { /* ignore */ }
}
setRoomImages(prev => [...prev, ...newImages]);
};
const removeImage = async (imageId: number) => {
try {
await fetch(`/api/admin/images?id=${imageId}`, { method: 'DELETE' });
setRoomImages(prev => prev.filter(img => img.id !== imageId));
} catch { /* ignore */ }
};
return (
{r.id ? 'Edit Room' : 'New Room'}
setRoom({ ...r, name: v })} />
setRoom({ ...r, description: v })} />
setRoom({ ...r, price: v })} />
document.getElementById(`room-photos-${r.id || 'new'}`)?.click()} size="sm" variant="secondary">+ Add Photos
{ addPhotos(e.target.files!); e.target.value = ''; }} />
{roomImages.map((img, i) => (
))}
setRoom({ ...r, active: v })} />
{allAmenities.map(a => (
))}
Cancel
onSave(r)}>Save
);
}
/* βββ Amenities Section βββ */
function AmenitiesSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [amenities, setAmenities] = useState([]);
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
useEffect(() => {
fetch('/api/amenities')
.then(r => r.json())
.then(d => setAmenities(d.data || []))
.catch(() => onToast('Failed to load amenities', 'error'));
}, []);
const save = async (amenity: Amenity) => {
try {
const url = amenity.id ? `/api/amenities?id=${amenity.id}` : '/api/amenities';
const res = await fetch(url, {
method: amenity.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(amenity),
});
if (res.ok) {
const data = await res.json();
if (amenity.id) {
setAmenities(prev => prev.map(a => a.id === amenity.id ? data.data : a));
} else {
setAmenities(prev => [...prev, data.data]);
}
setEditing(null);
onToast('Amenity saved!', 'success');
} else {
const err = await res.json();
onToast(err.error || 'Failed to save', 'error');
}
} catch { onToast('Error', 'error'); }
};
const del = async (id: number) => {
await fetch(`/api/amenities?id=${id}`, { method: 'DELETE' });
setAmenities(prev => prev.filter(a => a.id !== id));
onToast('Amenity deleted', 'success');
setConfirmDel(null);
};
return (
setEditing({ id: 0, name: '', icon_svg: '', sort_order: amenities.length })} size="sm">+ New Amenity} />
{amenities.length === 0 ? : (
{amenities.sort((a, b) => a.sort_order - b.sort_order).map((a: Amenity) => (
{a.name}
setEditing(a)}>Edit
setConfirmDel(a.id)}>Del
))}
)}
{editing && setEditing(null)} />}
{confirmDel !== null && setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
);
}
function AmenityEditor({ amenity, setAmenity, onSave, onCancel }: any) {
const defaultIcons = [
{ name: 'Wi-Fi', svg: '' },
{ name: 'TV', svg: '' },
{ name: 'Shower', svg: '' },
{ name: 'Bathtub', svg: '' },
{ name: 'Fireplace', svg: '' },
{ name: 'Minibar', svg: '' },
{ name: 'Coffee', svg: '' },
{ name: 'Safe', svg: '' },
{ name: 'Heating', svg: '' },
{ name: 'Balcony', svg: '' },
{ name: 'King Bed', svg: '' },
{ name: 'Mountain View', svg: '' },
{ name: 'Parking', svg: '' },
{ name: 'Restaurant', svg: '' },
];
return (
{amenity.id ? 'Edit Amenity' : 'New Amenity'}
setAmenity({ ...amenity, name: v })} placeholder="e.g., Wi-Fi, TV, Shower" />
{defaultIcons.map(icon => (
))}
setAmenity({ ...amenity, sort_order: parseInt(v) || 0 })} />
Cancel
onSave(amenity)}>Save
);
}
/* βββ Trips Section βββ */
type Trip = { id: number; user_id: number; username: string; room_id: number | null; room_name: string | null; check_in: string; check_out: string; notes: string | null; status: string };
function TripsSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [trips, setTrips] = useState([]);
const [users, setUsers] = useState<{id: number; username: string}[]>([]);
const [rooms, setRooms] = useState<{id: number; name: string}[]>([]);
const [editing, setEditing] = useState(null);
const [creating, setCreating] = useState(false);
useEffect(() => {
const load = async () => {
const [tripsRes, usersRes, roomsRes] = await Promise.all([
fetch('/api/admin/trips'),
fetch('/api/admin/users'),
fetch('/api/rooms'),
]);
if (tripsRes.ok) { const d = await tripsRes.json(); setTrips(d.trips || []); }
if (usersRes.ok) { const d = await usersRes.json(); setUsers(d.users || []); }
if (roomsRes.ok) { const d = await roomsRes.json(); setRooms(Array.isArray(d) ? d : (d.data || [])); }
};
load();
}, []);
const save = async (trip: Trip) => {
try {
const method = trip.id ? 'PUT' : 'POST';
const res = await fetch('/api/admin/trips', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(trip),
});
if (res.ok) {
const d = await res.json();
if (trip.id) {
setTrips(prev => prev.map(t => t.id === trip.id ? d.trip : t));
} else {
setTrips(prev => [...prev, d.trip]);
}
setEditing(null);
setCreating(false);
onToast('Trip saved', 'success');
} else {
const err = await res.json();
onToast(err.error || 'Failed to save trip', 'error');
}
} catch { onToast('Error saving trip', 'error'); }
};
const del = async (id: number) => {
if (!confirm('Delete this trip?')) return;
try {
await fetch('/api/admin/trips', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
setTrips(prev => prev.filter(t => t.id !== id));
onToast('Trip deleted', 'success');
} catch { onToast('Error deleting trip', 'error'); }
};
const emptyTrip: Trip = { id: 0, user_id: 0, username: '', room_id: null, room_name: null, check_in: '', check_out: '', notes: '', status: 'confirmed' };
return (
{ setCreating(true); setEditing(emptyTrip); }} />
{creating && editing && !editing.id && (
New Trip
setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
setEditing({ ...editing, check_in: v })} />
setEditing({ ...editing, check_out: v })} />
setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
setEditing({ ...editing, notes: v })} rows={2} />
save(editing)}>Save Trip
{ setCreating(false); setEditing(null); }}>Cancel
)}
{trips.map(t => (
{t.username || `User ${t.user_id}`}
{t.room_name || 'No room assigned'}
In: {formatDisplayDate(t.check_in)} Β· Out: {formatDisplayDate(t.check_out)}
{t.status.replace('_', ' ')}
{ setCreating(false); setEditing(t); }}>Edit
del(t.id)}>Delete
))}
{!creating && editing && editing.id && (
setEditing(null)}>
e.stopPropagation()}>
Edit Trip
setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
setEditing({ ...editing, check_in: v })} />
setEditing({ ...editing, check_out: v })} />
setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
setEditing({ ...editing, notes: v })} rows={2} />
save(editing)}>Save
setEditing(null)}>Cancel
)}
);
}
/* βββ Messages Section βββ */
type MsgThread = { user_id: number; username: string; message: string; created_at: string };
type Msg = { id: number; sender_role: string; message: string; created_at: string };
function MessagesSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [threads, setThreads] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [messages, setMessages] = useState([]);
const [reply, setReply] = useState('');
const messagesEndRef = useRef(null);
useEffect(() => {
const load = async () => {
const res = await fetch('/api/admin/messages');
if (res.ok) { const d = await res.json(); setThreads(d.threads || []); }
};
load();
}, []);
useEffect(() => {
if (selectedUser) {
const load = async () => {
const res = await fetch(`/api/admin/messages?user_id=${selectedUser}`);
if (res.ok) { const d = await res.json(); setMessages(d.messages || []); }
};
load();
}
}, [selectedUser]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendReply = async () => {
if (!reply.trim() || !selectedUser) return;
try {
const res = await fetch('/api/admin/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: selectedUser, message: reply.trim() }),
});
if (res.ok) {
const d = await res.json();
setMessages(prev => [...prev, d.message]);
setReply('');
onToast('Reply sent', 'success');
}
} catch { onToast('Error sending reply', 'error'); }
};
return (
Conversations
{threads.length === 0 ? (
No conversations yet
) : (
threads.map(t => (
setSelectedUser(t.user_id)} style={{ padding: '12px 16px', cursor: 'pointer', background: selectedUser === t.user_id ? C.goldLight : 'transparent', borderBottom: `1px solid ${C.border}` }}
onMouseEnter={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = '#f5f5f5'; }}
onMouseLeave={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = 'transparent'; }}>
{t.username}
{t.message}
))
)}
{selectedUser ? (
<>
{threads.find(t => t.user_id === selectedUser)?.username || `User ${selectedUser}`}
{messages.map(m => (
{m.sender_role === 'admin' ? 'Staff' : 'Guest'}
{m.message}
{formatDisplayDateTime(m.created_at)}
))}
setReply(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') sendReply(); }} placeholder="Type a reply..." style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none' }} />
Send
>
) : (
Select a conversation to view messages
)}
);
}
/* βββ Menu Section βββ */
function MenuSection({ items, setItems, images, onToast }: any) {
const [editing, setEditing] = useState