feat: room photo gallery, featured photo, and comments moderation
This commit is contained in:
+190
-14
@@ -16,6 +16,7 @@ interface Room {
|
|||||||
description: string;
|
description: string;
|
||||||
price: string;
|
price: string;
|
||||||
photos: string[];
|
photos: string[];
|
||||||
|
featured_photo: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
@@ -25,11 +26,32 @@ interface RoomForm {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
price: string;
|
price: string;
|
||||||
photos: string | string[];
|
photos: string[];
|
||||||
|
featured_photo: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
sort_order: number;
|
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 {
|
interface MenuItem {
|
||||||
id: number;
|
id: number;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -65,6 +87,9 @@ interface User {
|
|||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
role: 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
comments_disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Slide {
|
interface Slide {
|
||||||
@@ -103,8 +128,12 @@ export default function AdminPage() {
|
|||||||
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
|
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
|
||||||
|
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
const [roomForm, setRoomForm] = useState<RoomForm>({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 });
|
const [roomForm, setRoomForm] = useState<RoomForm>({ name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: 0 });
|
||||||
const [editingRoom, setEditingRoom] = useState<number | null>(null);
|
const [editingRoom, setEditingRoom] = useState<number | null>(null);
|
||||||
|
const [roomComments, setRoomComments] = useState<RoomComment[]>([]);
|
||||||
|
const [roomCommentRoomId, setRoomCommentRoomId] = useState<number | null>(null);
|
||||||
|
const [editingUser, setEditingUser] = useState<number | null>(null);
|
||||||
|
const [userEditForm, setUserEditForm] = useState({ first_name: '', last_name: '', comments_disabled: false });
|
||||||
|
|
||||||
const [menu, setMenu] = useState<MenuItem[]>([]);
|
const [menu, setMenu] = useState<MenuItem[]>([]);
|
||||||
const [menuForm, setMenuForm] = useState<Partial<MenuItem>>({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 });
|
const [menuForm, setMenuForm] = useState<Partial<MenuItem>>({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 });
|
||||||
@@ -240,9 +269,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const saveRoom = async (e: React.FormEvent) => {
|
const saveRoom = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const photosValue = roomForm.photos || [];
|
const payload = { ...roomForm, photos: roomForm.photos || [], featured_photo: roomForm.featured_photo || null, active: roomForm.active !== false, sort_order: roomForm.sort_order || 0 };
|
||||||
const photosArray: string[] = typeof photosValue === 'string' ? (photosValue as unknown as string).split(',').map((s: string) => s.trim()).filter(Boolean) : photosValue;
|
|
||||||
const payload = { ...roomForm, photos: photosArray, active: roomForm.active !== false, sort_order: roomForm.sort_order || 0 };
|
|
||||||
if (editingRoom) {
|
if (editingRoom) {
|
||||||
await api(`/api/rooms/${editingRoom}`, 'PUT', payload);
|
await api(`/api/rooms/${editingRoom}`, 'PUT', payload);
|
||||||
setRooms((prev) => prev.map((r) => (r.id === editingRoom ? { ...r, ...payload } as Room : r)));
|
setRooms((prev) => prev.map((r) => (r.id === editingRoom ? { ...r, ...payload } as Room : r)));
|
||||||
@@ -251,11 +278,11 @@ export default function AdminPage() {
|
|||||||
const json = await api('/api/rooms', 'POST', payload);
|
const json = await api('/api/rooms', 'POST', payload);
|
||||||
setRooms((prev) => [...prev, json.data]);
|
setRooms((prev) => [...prev, json.data]);
|
||||||
}
|
}
|
||||||
setRoomForm({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 });
|
setRoomForm({ name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: 0 });
|
||||||
notify('Room saved.');
|
notify('Room saved.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const editRoom = (r: Room) => { setRoomForm({ ...r, photos: r.photos?.join(', ') || '' }); setEditingRoom(r.id); };
|
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 deleteRoom = async (id: number) => { await api(`/api/rooms/${id}`, 'DELETE'); setRooms((prev) => prev.filter((r) => r.id !== id)); };
|
||||||
|
|
||||||
const saveMenu = async (e: React.FormEvent) => {
|
const saveMenu = async (e: React.FormEvent) => {
|
||||||
@@ -301,6 +328,35 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const deleteReview = async (id: number) => { await api(`/api/admin/reviews/${id}`, 'DELETE'); setReviews((prev) => prev.filter((r) => r.id !== id)); };
|
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) => {
|
const saveUser = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const json = await api('/api/admin/users', 'POST', userForm);
|
const json = await api('/api/admin/users', 'POST', userForm);
|
||||||
@@ -477,24 +533,59 @@ export default function AdminPage() {
|
|||||||
</form>
|
</form>
|
||||||
<ListTable rows={events.map((ev) => [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
|
<ListTable rows={events.map((ev) => [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Rooms">
|
<Section title="Rooms">
|
||||||
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
<Text label="Name" value={roomForm.name || ''} onChange={(v) => setRoomForm({ ...roomForm, name: v })} />
|
<Text label="Name" value={roomForm.name || ''} onChange={(v) => setRoomForm({ ...roomForm, name: v })} />
|
||||||
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
|
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
|
||||||
<Text label="Photos (comma-separated URLs)" value={typeof roomForm.photos === 'string' ? roomForm.photos : roomForm.photos.join(', ')} onChange={(v) => setRoomForm({ ...roomForm, photos: v })} />
|
|
||||||
<Number label="Sort order" value={roomForm.sort_order ?? 0} onChange={(v) => setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} />
|
|
||||||
<Text label="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} />
|
<Text label="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} />
|
||||||
|
<Number label="Sort order" value={roomForm.sort_order ?? 0} onChange={(v) => setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} />
|
||||||
|
<RoomPhotoPicker
|
||||||
|
label="Room photos"
|
||||||
|
images={images}
|
||||||
|
photos={roomForm.photos || []}
|
||||||
|
featuredPhoto={roomForm.featured_photo}
|
||||||
|
onChange={(photos: string[], featured: string | null) => setRoomForm({ ...roomForm, photos, featured_photo: featured })}
|
||||||
|
/>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
||||||
<input type="checkbox" checked={roomForm.active !== false} onChange={(e) => setRoomForm({ ...roomForm, active: e.target.checked })} />
|
<input type="checkbox" checked={roomForm.active !== false} onChange={(e) => setRoomForm({ ...roomForm, active: e.target.checked })} />
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||||
<Button type="submit">{editingRoom ? 'Update room' : 'Add room'}</Button>
|
<Button type="submit">{editingRoom ? 'Update room' : 'Add room'}</Button>
|
||||||
{editingRoom && <SecondaryButton onClick={() => { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
|
{editingRoom && <SecondaryButton onClick={() => { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: ([] as string[]), featured_photo: null, active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<ListTable rows={rooms.map((r) => [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)} />
|
<ListTable rows={rooms.map((r) => [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 && (
|
||||||
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#fff', borderRadius: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||||
|
<strong style={{ color: navy }}>Comments for room #{roomCommentRoomId}</strong>
|
||||||
|
<SecondaryButton onClick={() => { setRoomCommentRoomId(null); setRoomComments([]); }}>Close</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
{roomComments.length === 0 ? (
|
||||||
|
<p style={{ color: '#777' }}>No comments yet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{roomComments.map((c) => (
|
||||||
|
<div key={c.id} style={{ padding: '0.75rem', background: '#f5f0e8', borderRadius: '10px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.3rem' }}>
|
||||||
|
<strong>{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}</strong>
|
||||||
|
<span style={{ fontSize: '12px', color: '#777' }}>{new Date(c.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, color: '#555' }}>{c.text}</p>
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<DangerButton onClick={() => deleteRoomComment(c.id)}>Remove comment</DangerButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Menu">
|
<Section title="Menu">
|
||||||
@@ -576,7 +667,22 @@ export default function AdminPage() {
|
|||||||
<Button type="submit">Create user</Button>
|
<Button type="submit">Create user</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<ListTable rows={users.map((u) => [u.username, u.role])} onDelete={(i) => deleteUser(users[i].id)} />
|
<ListTable rows={users.map((u) => [u.username, u.role, u.first_name || '', u.last_name || '', u.comments_disabled ? 'comments disabled' : ''])} onDelete={(i) => deleteUser(users[i].id)} onEdit={(i) => { setEditingUser(users[i].id); setUserEditForm({ first_name: users[i].first_name || '', last_name: users[i].last_name || '', comments_disabled: users[i].comments_disabled }); }} />
|
||||||
|
{editingUser !== null && (
|
||||||
|
<div style={{ marginTop: '1rem', padding: '1rem', background: '#fff', borderRadius: '12px', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<strong>Edit user {users.find((u) => u.id === editingUser)?.username}</strong>
|
||||||
|
<Text label="First name" value={userEditForm.first_name} onChange={(v) => setUserEditForm({ ...userEditForm, first_name: v })} />
|
||||||
|
<Text label="Last name" value={userEditForm.last_name} onChange={(v) => setUserEditForm({ ...userEditForm, last_name: v })} />
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
||||||
|
<input type="checkbox" checked={userEditForm.comments_disabled} onChange={(e) => setUserEditForm({ ...userEditForm, comments_disabled: e.target.checked })} />
|
||||||
|
Disable comments
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<Button onClick={() => { const u = users.find((u) => u.id === editingUser)!; saveUserNames(u); toggleUserComments(u); }}>Save</Button>
|
||||||
|
<SecondaryButton onClick={() => setEditingUser(null)}>Cancel</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -679,7 +785,7 @@ function DangerButton({ children, onClick }: { children: React.ReactNode; onClic
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (index: number) => void; onDelete: (index: number) => void }) {
|
function ListTable({ rows, onEdit, onDelete, onComments }: { rows: string[][]; onEdit?: (index: number) => void; onDelete: (index: number) => void; onComments?: (index: number) => void }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
{rows.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
@@ -688,6 +794,7 @@ function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (ind
|
|||||||
{row.map((cell, j) => <span key={j}>{cell}</span>)}
|
{row.map((cell, j) => <span key={j}>{cell}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
{onComments && <Button onClick={() => onComments(i)}>Comments</Button>}
|
||||||
{onEdit && <Button onClick={() => onEdit(i)}>Edit</Button>}
|
{onEdit && <Button onClick={() => onEdit(i)}>Edit</Button>}
|
||||||
<DangerButton onClick={() => onDelete(i)}>Delete</DangerButton>
|
<DangerButton onClick={() => onDelete(i)}>Delete</DangerButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -696,3 +803,72 @@ function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (ind
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoomPhotoPicker({
|
||||||
|
label, images, photos, featuredPhoto, onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
images: ImageRecord[];
|
||||||
|
photos: string[];
|
||||||
|
featuredPhoto: string | null;
|
||||||
|
onChange: (photos: string[], featured: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const allPhotos = Array.from(new Set([...photos, ...images.map((i) => i.data)]));
|
||||||
|
return (
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', fontSize: '14px', color: '#555', gridColumn: '1 / -1' }}>
|
||||||
|
{label}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: '0.75rem' }}>
|
||||||
|
{allPhotos.map((url) => {
|
||||||
|
const isSelected = photos.includes(url);
|
||||||
|
const isFeatured = featuredPhoto === url;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={url}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
const next = photos.filter((p) => p !== url);
|
||||||
|
onChange(next, featuredPhoto === url ? null : featuredPhoto);
|
||||||
|
} else {
|
||||||
|
onChange([...photos, url], featuredPhoto || url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: `2px solid ${isFeatured ? gold : isSelected ? navy : 'transparent'}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: isSelected ? 1 : 0.55,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={url} alt="" style={{ width: '100%', height: '90px', objectFit: 'cover', display: 'block' }} />
|
||||||
|
{isFeatured && (
|
||||||
|
<span style={{ position: 'absolute', top: 4, left: 4, background: gold, color: navy, fontSize: '10px', fontWeight: 700, padding: '2px 6px', borderRadius: '8px' }}>FEATURED</span>
|
||||||
|
)}
|
||||||
|
{!isSelected && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}>+</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{allPhotos.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '13px', color: '#777' }}>Featured photo:</span>
|
||||||
|
<select
|
||||||
|
value={featuredPhoto || ''}
|
||||||
|
onChange={(e) => onChange(photos, e.target.value || null)}
|
||||||
|
style={{ padding: '0.5rem 0.75rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{photos.map((url) => (
|
||||||
|
<option key={url} value={url}>
|
||||||
|
{url.slice(0, 40)}...
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { requireRole } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
await requireRole('admin');
|
||||||
|
const commentId = parseInt(params.id, 10);
|
||||||
|
await db.query('UPDATE room_comments SET deleted_by_admin = TRUE WHERE id = $1', [commentId]);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Failed to delete comment' },
|
||||||
|
{ status: err.message === 'Unauthorized' ? 401 : 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { db } from '@/lib/db';
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const { rows } = await db.query('SELECT id, username, role, created_at FROM users ORDER BY created_at DESC');
|
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, created_at FROM users ORDER BY created_at DESC');
|
||||||
return NextResponse.json({ data: rows });
|
return NextResponse.json({ data: rows });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||||
@@ -32,3 +32,39 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requireRole('admin');
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, first_name, last_name, comments_disabled } = body;
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
'UPDATE users SET first_name = $1, last_name = $2, comments_disabled = $3 WHERE id = $4 RETURNING id, username, role, first_name, last_name, comments_disabled, created_at',
|
||||||
|
[first_name || null, last_name || null, comments_disabled === true, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: rows[0] });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: err.message || 'Failed to update user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requireRole('admin');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
await db.query('DELETE FROM users WHERE id = $1', [id]);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: err.message || 'Failed to delete user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -6,5 +7,18 @@ export async function GET() {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ authenticated: true, role: session.role, username: session.username });
|
const { rows } = await db.query(
|
||||||
|
'SELECT id, username, role, first_name, last_name, comments_disabled FROM users WHERE id = $1',
|
||||||
|
[session.id]
|
||||||
|
);
|
||||||
|
const user = rows[0] || {};
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
role: session.role,
|
||||||
|
username: session.username,
|
||||||
|
id: session.id,
|
||||||
|
first_name: user.first_name || null,
|
||||||
|
last_name: user.last_name || null,
|
||||||
|
comments_disabled: user.comments_disabled === true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { requireAuth, canComment } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const roomId = parseInt(params.id, 10);
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const photoUrl = searchParams.get('photo_url') || null;
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin
|
||||||
|
FROM room_comments
|
||||||
|
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[roomId, photoUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ data: rows });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: err.message || 'Failed to fetch comments' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const allowed = await canComment(session.id);
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.json({ error: 'Comments are disabled for your account' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = parseInt(params.id, 10);
|
||||||
|
const body = await request.json();
|
||||||
|
const { photo_url, text } = body;
|
||||||
|
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return NextResponse.json({ error: 'Comment text is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT first_name, last_name FROM users WHERE id = $1`,
|
||||||
|
[session.id]
|
||||||
|
);
|
||||||
|
const user = rows[0] || {};
|
||||||
|
const firstName = user.first_name || session.username || 'Guest';
|
||||||
|
const lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null;
|
||||||
|
|
||||||
|
const { rows: inserted } = await db.query(
|
||||||
|
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
|
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ data: inserted[0] });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Failed to post comment' },
|
||||||
|
{ status: err.message === 'Unauthorized' ? 401 : 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,12 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
|||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const id = parseInt(params.id, 10);
|
const id = parseInt(params.id, 10);
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, description, price, photos, active, sort_order } = body;
|
const { name, description, price, photos, active, sort_order, featured_photo } = body;
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, updated_at = NOW()
|
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, featured_photo = $7, updated_at = NOW()
|
||||||
WHERE id = $7 RETURNING *`,
|
WHERE id = $8 RETURNING *`,
|
||||||
[name, description, price, photos || [], active, sort_order || 0, id]
|
[name, description, price, photos || [], active, sort_order || 0, featured_photo || null, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, description, price, photos, sort_order } = body;
|
const { name, description, price, photos, sort_order, featured_photo } = body;
|
||||||
|
|
||||||
if (!name || !description || price === undefined) {
|
if (!name || !description || price === undefined) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO rooms (name, description, price, photos, sort_order)
|
`INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[name, description, price, photos || [], sort_order || 0]
|
[name, description, price, photos || [], sort_order || 0, featured_photo || null]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ data: rows[0] });
|
return NextResponse.json({ data: rows[0] });
|
||||||
|
|||||||
+215
-37
@@ -8,16 +8,45 @@ interface Room {
|
|||||||
description: string;
|
description: string;
|
||||||
price: string;
|
price: string;
|
||||||
photos: string[];
|
photos: string[];
|
||||||
|
featured_photo: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gold = '#E8A849';
|
const gold = '#E8A849';
|
||||||
const navy = '#010D1E';
|
const navy = '#010D1E';
|
||||||
const cream = '#f5f0e8';
|
const cream = '#f5f0e8';
|
||||||
|
const warm = '#a6683c';
|
||||||
|
|
||||||
export default function RoomsPage() {
|
export default function RoomsPage() {
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [commentsByRoom, setCommentsByRoom] = useState<Record<number, RoomComment[]>>({});
|
||||||
|
const [expandedRoom, setExpandedRoom] = useState<number | null>(null);
|
||||||
|
const [activePhoto, setActivePhoto] = useState<Record<number, string>>({});
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/rooms')
|
fetch('/api/rooms')
|
||||||
@@ -25,61 +54,210 @@ export default function RoomsPage() {
|
|||||||
if (!res.ok) throw new Error('Failed to load rooms');
|
if (!res.ok) throw new Error('Failed to load rooms');
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setRooms(json.data || []);
|
setRooms(json.data || []);
|
||||||
|
json.data?.forEach((r: Room) => loadComments(r.id));
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
|
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((j) => setUser(j.user || null))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadComments = async (roomId: number) => {
|
||||||
|
const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' });
|
||||||
|
const json = await res.json().catch(() => ({ data: [] }));
|
||||||
|
setCommentsByRoom((prev) => ({ ...prev, [roomId]: json.data || [] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const postComment = async (roomId: number) => {
|
||||||
|
if (!draft.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
const photo = activePhoto[roomId] || null;
|
||||||
|
await fetch(`/api/rooms/${roomId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ text: draft.trim(), photo_url: photo }),
|
||||||
|
});
|
||||||
|
setDraft('');
|
||||||
|
await loadComments(roomId);
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteComment = async (roomId: number, commentId: number) => {
|
||||||
|
await fetch(`/api/admin/comments/${commentId}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
await loadComments(roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayedRooms = rooms.filter((r) => r.active !== false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||||
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms & Suites</h1>
|
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms & Suites</h1>
|
||||||
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
||||||
Comfortable stays with views of the vineyard and cloud forest.
|
Comfortable stays with views of the vineyard and cloud forest.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && <p style={{ color: '#a6683c' }}>Loading rooms...</p>}
|
{loading && <p style={{ color: warm }}>Loading rooms...</p>}
|
||||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||||
|
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
|
||||||
|
|
||||||
{!loading && rooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '2rem' }}>
|
||||||
|
{displayedRooms.map((room) => {
|
||||||
|
const photos = room.photos?.length ? room.photos : [];
|
||||||
|
const featured = room.featured_photo || photos[0];
|
||||||
|
const currentPhoto = activePhoto[room.id] || featured || photos[0];
|
||||||
|
const comments = commentsByRoom[room.id] || [];
|
||||||
|
const isExpanded = expandedRoom === room.id;
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1.5rem' }}>
|
return (
|
||||||
{rooms.map((room) => (
|
<article
|
||||||
<article
|
key={room.id}
|
||||||
key={room.id}
|
|
||||||
style={{
|
|
||||||
background: cream,
|
|
||||||
borderRadius: '20px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
height: '220px',
|
background: cream,
|
||||||
background: '#ddd',
|
borderRadius: '20px',
|
||||||
backgroundImage: room.photos?.length > 0 ? `url(${room.photos[0]})` : undefined,
|
overflow: 'hidden',
|
||||||
backgroundSize: 'cover',
|
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
|
||||||
backgroundPosition: 'center',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||||
}}
|
}}
|
||||||
/>
|
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
||||||
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
||||||
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '22px' }}>{room.name}</h2>
|
>
|
||||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
|
<div style={{ position: 'relative' }}>
|
||||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div
|
||||||
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
|
style={{
|
||||||
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
|
height: '260px',
|
||||||
|
background: '#ddd',
|
||||||
|
backgroundImage: currentPhoto ? `url(${currentPhoto})` : undefined,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
cursor: photos.length > 1 ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (photos.length > 1) {
|
||||||
|
const idx = photos.indexOf(currentPhoto);
|
||||||
|
setActivePhoto((prev) => ({ ...prev, [room.id]: photos[(idx + 1) % photos.length] }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div style={{ position: 'absolute', bottom: 10, right: 10, display: 'flex', gap: '0.4rem' }}>
|
||||||
|
{photos.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
|
||||||
|
style={{
|
||||||
|
width: 10, height: 10, borderRadius: '50%', border: 'none',
|
||||||
|
background: p === currentPhoto ? gold : 'rgba(255,255,255,0.7)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
aria-label={`Photo ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
))}
|
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{room.name}</h2>
|
||||||
|
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
|
||||||
|
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', overflowX: 'auto' }}>
|
||||||
|
{photos.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
|
||||||
|
style={{
|
||||||
|
width: 64, height: 48, flexShrink: 0, borderRadius: 8, border: `2px solid ${p === currentPhoto ? gold : 'transparent'}`,
|
||||||
|
backgroundImage: `url(${p})`, backgroundSize: 'cover', backgroundPosition: 'center', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
aria-label="View photo"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem', padding: '0.6rem 1rem', borderRadius: '999px', border: 'none',
|
||||||
|
background: navy, color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? 'Hide comments' : `Comments (${comments.length})`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{user ? (
|
||||||
|
user.comments_disabled ? (
|
||||||
|
<p style={{ color: '#c23b22', fontSize: '14px' }}>Comments are disabled for your account.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder="Share your thoughts about this room..."
|
||||||
|
rows={3}
|
||||||
|
style={{ padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontFamily: 'inherit', fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: '#777' }}>Posting as {user.first_name || user.username}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => postComment(room.id)}
|
||||||
|
disabled={sending || !draft.trim()}
|
||||||
|
style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: 'none', background: gold, color: navy, fontWeight: 700, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{sending ? 'Sending...' : 'Post'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#777', fontSize: '14px' }}>Log in to leave a comment.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p style={{ color: '#777', fontSize: '14px' }}>No comments yet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{comments.map((c) => (
|
||||||
|
<div key={c.id} style={{ background: '#fff', padding: '0.75rem', borderRadius: '10px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.3rem' }}>
|
||||||
|
<strong style={{ color: navy, fontSize: '14px' }}>
|
||||||
|
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
|
||||||
|
</strong>
|
||||||
|
<span style={{ fontSize: '12px', color: '#777' }}>{new Date(c.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => deleteComment(room.id, c.id)}
|
||||||
|
style={{ marginTop: '0.5rem', fontSize: '12px', color: '#c23b22', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const warm = '#a6683c';
|
|
||||||
|
|||||||
@@ -71,3 +71,8 @@ export async function requireAuth() {
|
|||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function canComment(userId: number) {
|
||||||
|
const { rows } = await db.query('SELECT comments_disabled FROM users WHERE id = $1', [userId]);
|
||||||
|
return rows.length === 0 || !rows[0].comments_disabled;
|
||||||
|
}
|
||||||
|
|||||||
+34
-8
@@ -136,14 +136,40 @@ export async function initSchema() {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
CREATE TABLE IF NOT EXISTS room_comments (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
date DATE NOT NULL,
|
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
title VARCHAR(255) NOT NULL,
|
photo_url VARCHAR(500),
|
||||||
type VARCHAR(50) NOT NULL CHECK (type IN ('holiday', 'season', 'weather', 'event')),
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
description TEXT,
|
first_name VARCHAR(100) NOT NULL,
|
||||||
color VARCHAR(50) DEFAULT '#E8A849',
|
last_initial VARCHAR(10),
|
||||||
active BOOLEAN DEFAULT TRUE,
|
text TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
deleted_by_admin BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
@@ -255,7 +281,7 @@ export async function seed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resetDatabase() {
|
export async function resetDatabase() {
|
||||||
await db.query('DROP TABLE IF EXISTS reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events CASCADE');
|
await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events CASCADE');
|
||||||
await initSchema();
|
await initSchema();
|
||||||
await seed();
|
await seed();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user