diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 03f5078..35345ce 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -16,6 +16,7 @@ interface Room { description: string; price: string; photos: string[]; + featured_photo: string | null; active: boolean; sort_order: number; } @@ -25,11 +26,32 @@ interface RoomForm { name: string; description: string; price: string; - photos: string | 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; @@ -65,6 +87,9 @@ interface User { id: number; username: string; role: 'admin' | 'user'; + first_name: string | null; + last_name: string | null; + comments_disabled: boolean; } interface Slide { @@ -103,8 +128,12 @@ export default function AdminPage() { const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands'); const [rooms, setRooms] = useState([]); - const [roomForm, setRoomForm] = useState({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 }); + 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 }); @@ -240,9 +269,7 @@ export default function AdminPage() { const saveRoom = async (e: React.FormEvent) => { e.preventDefault(); - const photosValue = roomForm.photos || []; - const photosArray: string[] = typeof photosValue === 'string' ? (photosValue as unknown as string).split(',').map((s: string) => s.trim()).filter(Boolean) : photosValue; - const payload = { ...roomForm, photos: photosArray, active: roomForm.active !== false, sort_order: roomForm.sort_order || 0 }; + 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))); @@ -251,11 +278,11 @@ export default function AdminPage() { const json = await api('/api/rooms', 'POST', payload); 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.'); }; - 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 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 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); @@ -477,24 +533,59 @@ export default function AdminPage() { [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} /> -
setRoomForm({ ...roomForm, name: v })} /> setRoomForm({ ...roomForm, price: v })} /> - setRoomForm({ ...roomForm, photos: v })} /> - setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} /> 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: '', active: true, sort_order: 0 }); }}>Cancel} + {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)} /> + [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 +
+
+ ))} +
+ )} +
+ )}
@@ -576,7 +667,22 @@ export default function AdminPage() { - [u.username, u.role])} onDelete={(i) => deleteUser(users[i].id)} /> + [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 && ( +
+ Edit user {users.find((u) => u.id === editingUser)?.username} + setUserEditForm({ ...userEditForm, first_name: v })} /> + setUserEditForm({ ...userEditForm, last_name: v })} /> + +
+ + setEditingUser(null)}>Cancel +
+
+ )}
); @@ -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 (
{rows.map((row, i) => ( @@ -688,6 +794,7 @@ function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (ind {row.map((cell, j) => {cell})}
+ {onComments && } {onEdit && } onDelete(i)}>Delete
@@ -696,3 +803,72 @@ function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (ind ); } + +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 ( + + ); +} diff --git a/src/app/api/admin/comments/[id]/route.ts b/src/app/api/admin/comments/[id]/route.ts new file mode 100644 index 0000000..d61e4f6 --- /dev/null +++ b/src/app/api/admin/comments/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index d9c3227..3b2cc0c 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -5,7 +5,7 @@ import { db } from '@/lib/db'; export async function GET() { try { 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 }); } catch (err: any) { 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 }); } } + +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 }); + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 194dd5a..c36e8ff 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; export async function GET() { @@ -6,5 +7,18 @@ export async function GET() { if (!session) { 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, + }); } diff --git a/src/app/api/rooms/[id]/comments/route.ts b/src/app/api/rooms/[id]/comments/route.ts new file mode 100644 index 0000000..4cf389d --- /dev/null +++ b/src/app/api/rooms/[id]/comments/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/rooms/[id]/route.ts b/src/app/api/rooms/[id]/route.ts index e407adf..68ee648 100644 --- a/src/app/api/rooms/[id]/route.ts +++ b/src/app/api/rooms/[id]/route.ts @@ -7,12 +7,12 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri await requireRole('admin'); const id = parseInt(params.id, 10); 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( - `UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, updated_at = NOW() - WHERE id = $7 RETURNING *`, - [name, description, price, photos || [], active, sort_order || 0, id] + `UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, featured_photo = $7, updated_at = NOW() + WHERE id = $8 RETURNING *`, + [name, description, price, photos || [], active, sort_order || 0, featured_photo || null, id] ); if (rows.length === 0) { diff --git a/src/app/api/rooms/route.ts b/src/app/api/rooms/route.ts index 9d32684..c8528c0 100644 --- a/src/app/api/rooms/route.ts +++ b/src/app/api/rooms/route.ts @@ -16,17 +16,17 @@ export async function POST(request: NextRequest) { try { await requireRole('admin'); 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) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } const { rows } = await db.query( - `INSERT INTO rooms (name, description, price, photos, sort_order) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [name, description, price, photos || [], sort_order || 0] + [name, description, price, photos || [], sort_order || 0, featured_photo || null] ); return NextResponse.json({ data: rows[0] }); diff --git a/src/app/rooms/page.tsx b/src/app/rooms/page.tsx index 41d01ef..cf503be 100644 --- a/src/app/rooms/page.tsx +++ b/src/app/rooms/page.tsx @@ -8,16 +8,45 @@ interface Room { description: string; price: 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 navy = '#010D1E'; const cream = '#f5f0e8'; +const warm = '#a6683c'; export default function RoomsPage() { const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [user, setUser] = useState(null); + const [commentsByRoom, setCommentsByRoom] = useState>({}); + const [expandedRoom, setExpandedRoom] = useState(null); + const [activePhoto, setActivePhoto] = useState>({}); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); useEffect(() => { fetch('/api/rooms') @@ -25,61 +54,210 @@ export default function RoomsPage() { if (!res.ok) throw new Error('Failed to load rooms'); const json = await res.json(); setRooms(json.data || []); + json.data?.forEach((r: Room) => loadComments(r.id)); }) .catch((err) => setError(err.message)) .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 ( -
+

Rooms & Suites

Comfortable stays with views of the vineyard and cloud forest.

- {loading &&

Loading rooms...

} + {loading &&

Loading rooms...

} {error &&

{error}

} + {!loading && displayedRooms.length === 0 &&

No rooms available yet.

} - {!loading && rooms.length === 0 &&

No rooms available yet.

} +
+ {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; -
- {rooms.map((room) => ( -
{ 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)'; }} - > -
0 ? `url(${room.photos[0]})` : undefined, - backgroundSize: 'cover', - backgroundPosition: 'center', + 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', }} - /> -
-

{room.name}

-

{room.description}

-
- ${room.price} - per night + 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)'; }} + > +
+
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 && ( +
+ {photos.map((p, i) => ( +
+ )}
-
-
- ))} + +
+

{room.name}

+

{room.description}

+
+ ${room.price} + per night +
+ + {photos.length > 1 && ( +
+ {photos.map((p) => ( +
+ )} + + + + {isExpanded && ( +
+ {user ? ( + user.comments_disabled ? ( +

Comments are disabled for your account.

+ ) : ( +
+