Compare commits

..

21 Commits

Author SHA1 Message Date
muken dc0c5fd289 feat: add security hardening for reservation APIs
- Add rate limiting (5/hr public, 100/min admin endpoints)
- Add honeypot bot protection on public POST
- Add audit logging for status changes and deletions
- Add input validation (email, phone, date, length limits)
- Fix missing auth on private event GET endpoints
- Add audit_log table to schema
- Fix failing tests (auth.test, rooms-route.test)
2026-07-02 20:56:36 -05:00
muken 0ca4f961f6 feat: add reservation system for public and private events
- Add private_event_reservations table with contact info fields
- Add status column to social_event_reservations (pending/confirmed/cancelled)
- Create /api/private-event-reservations CRUD endpoints
- Update social_event_reservations API to support guest submissions
- Add Reservations tab to Public Events admin section
- Add full reservation management UI for Private Events
- Support filtering by status (pending/confirmed/cancelled/all)
- Allow confirm/cancel/reactivate/delete actions
2026-07-02 20:29:17 -05:00
muken 361fd67e49 feat: add Spa/Pool gallery tabs, rename Social to Public Events, add Private Events
- Add 'Spa Gallery' (🧖) section in Image Gallery
- Add 'Pool Gallery' (🏊) section in Image Gallery
- Rename 'Social Events' to 'Public Events' (🎪) in sidebar
- Add 'Private Events' (🔒) placeholder section in sidebar
- Update category filters for spa/pool/public/private images
2026-07-02 20:26:16 -05:00
muken 5b30d0ca24 feat: add Food Gallery category to image gallery
- Add 'food' category filter in ImageGallery component
- Add '🍽️ Food Gallery' section in sectionCategories
- Update filteredImages logic to handle food category
- Clean up duplicate logo/favicon images in database
2026-07-02 20:22:13 -05:00
muken d730961c96 fix: SVG icons now scale properly with CSS instead of fixed width/height
- Remove hardcoded width/height attributes from SVGs
- Use CSS (width:100%;height:100%) for proper scaling
- Fixes amenity icons appearing compacted/overlapping
- viewBox maintains aspect ratio, CSS controls display size
2026-07-01 21:59:22 -05:00
muken 09bbe0a441 refactor: migrate room photos from rooms.photos to images table
- Photos now stored exclusively in images table (no duplication)
- rooms.photos array cleared, freed 17 MB storage
- Updated room APIs to fetch images by room_id
- Added PUT endpoint for room updates
- Updated RoomEditor to load/delete images from images table
- Fixed images API to return 'data' key consistently
2026-07-01 21:54:39 -05:00
muken ee68dc2897 feat: add WebP migration UI and API endpoint
- Add /api/admin/migrate-images endpoint to convert JPEG/PNG to WebP
- Add WebPMigration component in admin gallery showing stats
- Upload endpoint already converts new images to WebP
- Migration saves ~30-50% storage space per image
2026-07-01 21:47:16 -05:00
muken d0be23551a fix: use Union Jack (UK flag) instead of England flag for English language 2026-07-01 21:21:02 -05:00
muken 223b6e4670 fix: apply navy color to amenity SVG icons via stroke attribute 2026-07-01 21:19:04 -05:00
muken b71f66a177 fix: improve amenity icon styling - larger icons, better spacing 2026-07-01 21:18:02 -05:00
muken f7bb5ed752 feat: food ordering on coffee page
- Add 'Place Order' button to coffee page
- Order modal with menu item selection by category
- Order type options: pickup, dine in, room delivery (+10%)
- Room delivery requires login (disabled for guests)
- Calculate subtotal, service fee, and total
- Special instructions field
- Admin receives new orders in database
- Create orders and order_items tables
2026-07-01 00:12:34 -05:00
muken d9049d2294 feat: guest reservations and messages with proper table names
- Remove Reserve button from rooms page
- Calendar modal now supports direct reservation for guests
- Message modal works for guests (asks for name, contact method, contact)
- Create room_reservations table (separate from restaurant reservations)
- Create messages table for room inquiries
- LEFT JOIN users in queries to include guest reservations/messages
- Auto-fill form for logged-in users, show input fields for guests
2026-07-01 00:09:25 -05:00
muken ae6ea0ea46 feat: guest reservations and messages
- Remove Reserve button from rooms page
- Calendar modal now supports direct reservation for guests
- Message modal works for guests (asks for name, contact method, contact)
- Add guest_name, guest_contact_method, guest_contact to reservations and messages tables
- LEFT JOIN users in reservations GET to include guest reservations
- Auto-fill form for logged-in users, show input fields for guests
2026-07-01 00:05:39 -05:00
muken 72b507e434 fix: handle base64 images and fix amenity icon rendering
- Main images: check for base64 data URLs and render directly
- Thumbnails: same base64 handling for thumbnail strip
- Amenity icons: add null check and use global regex for SVG attrs
- All SVGs now properly sized at 14x14 with navy color
2026-06-30 23:58:06 -05:00
muken a8df420f36 fix: consolidate hearts to single like button with count
- Remove duplicate LikeButton component from room info section
- Remove separate like count badge from bottom-right
- Single unified like button (top-left) shows heart + count
- Cleaner UI: one heart, one tap, clear feedback
2026-06-30 23:41:37 -05:00
muken 4fb97e1f10 fix: enhance image loading animation with cream-colored shimmer
- Match shimmer colors to site's cream/warm palette (#f5f0e8, #e8e4dc)
- Add ease-in-out timing for smoother animation
- Proper z-index layering (shimmer below image)
- Longer fade-in transition (0.4s) for polished feel
2026-06-30 23:38:51 -05:00
muken c98824c8d7 fix: improve amenity icon display on room cards
- Icons now render with proper size (14px) inside pill badges
- Show amenity name next to icon for clarity
- Use regex for robust width/height replacement
- Compact layout with smaller gaps and font sizes
- Better text wrapping with whiteSpace: nowrap
2026-06-30 23:36:30 -05:00
muken 81c12ace7b feat: image optimization with multiple sizes and lazy loading
- Generate thumbnail (150px), medium (800px), full size on upload
- OptimizedImage component with Intersection Observer lazy loading
- Blur placeholder while loading
- Fetches appropriate size based on props (thumbnail/medium/full)
- Async image loading reduces initial page load time
- Added medium and thumbnail columns to images table
2026-06-30 23:34:23 -05:00
muken 73be5d2f22 fix: navbar refreshes auth state on navigation
Login button now correctly changes to Logout after login
2026-06-30 23:28:47 -05:00
muken 2353198924 feat: add room likes feature
- Users can like/unlike rooms
- Like count displayed on room cards
- LikeButton component with heart icon
- room_likes table with unique constraint
2026-06-30 21:57:28 -05:00
muken 7eae73eefb feat: room booking system with 4 action buttons
- Display amenity icons on room cards
- Reserve button: date picker, availability check, booking
- Message button: contact admins via platform
- Calendar button: view room availability
- Comment button: submit reviews for admin approval
- Admin comment queue for approve/reject/edit
- Reservations, availability, and messages tables
2026-06-30 21:54:46 -05:00
36 changed files with 4337 additions and 175 deletions
+504
View File
@@ -27,6 +27,7 @@
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "^5.4.5",
"vitest": "^4.1.9"
}
@@ -318,6 +319,448 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
@@ -1914,6 +2357,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -3051,6 +3536,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "^5.4.5",
"vitest": "^4.1.9"
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Migration script to convert existing JPEG/PNG images to WebP
* Run with: npx ts-node scripts/migrate-images-to-webp.ts
*/
import { Pool } from 'pg';
import sharp from 'sharp';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function convertToWebP(base64Data: string): Promise<string> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
console.log(' - Already WebP or invalid format, skipping');
return base64Data;
}
const ext = matches[2] ? matches[1] : 'unknown';
const buffer = Buffer.from(matches[2], 'base64');
// Convert to WebP
const webpBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
}
async function main() {
console.log('Starting image migration to WebP...\n');
const client = await pool.connect();
try {
// Get all images
const { rows: images } = await client.query(
'SELECT id, filename, data, thumbnail, medium FROM images'
);
console.log(`Found ${images.length} images to process\n`);
let converted = 0;
let skipped = 0;
let errors = 0;
for (const img of images) {
console.log(`Processing image ${img.id}: ${img.filename}`);
try {
let needsUpdate = false;
const updates: { data?: string; thumbnail?: string; medium?: string } = {};
// Check if already WebP
if (img.data && !img.data.includes('image/webp')) {
console.log(' - Converting full image...');
updates.data = await convertToWebP(img.data);
needsUpdate = true;
}
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
console.log(' - Converting thumbnail...');
updates.thumbnail = await convertToWebP(img.thumbnail);
needsUpdate = true;
}
if (img.medium && !img.medium.includes('image/webp')) {
console.log(' - Converting medium...');
updates.medium = await convertToWebP(img.medium);
needsUpdate = true;
}
if (needsUpdate) {
// Calculate size savings
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
const newSize = (updates.data?.length || img.data?.length || 0) +
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
(updates.medium?.length || img.medium?.length || 0);
const savings = ((oldSize - newSize) / oldSize * 100).toFixed(1);
const savedKB = ((oldSize - newSize) / 1024).toFixed(1);
console.log(` - Size reduction: ${savings}% (${savedKB} KB saved)`);
await client.query(
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
);
converted++;
} else {
console.log(' - Already WebP, skipping');
skipped++;
}
} catch (err) {
console.error(` - Error: ${(err as Error).message}`);
errors++;
}
console.log('');
}
console.log('Migration complete!\n');
console.log(`Converted: ${converted}`);
console.log(`Skipped: ${skipped}`);
console.log(`Errors: ${errors}`);
} finally {
client.release();
await pool.end();
}
}
main().catch(console.error);
+111
View File
@@ -0,0 +1,111 @@
/**
* Migration script to convert existing JPEG/PNG images to WebP
* Run from the k8s cluster directly
*/
const { Pool } = require('pg');
const sharp = require('sharp');
const pool = new Pool({
host: 'postgres.lahuasca',
port: 5432,
database: 'lahuasca',
user: 'lahuasca',
password: process.env.DB_PASSWORD || 'lahuasca123',
});
async function convertToWebP(base64Data) {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
return base64Data;
}
const buffer = Buffer.from(matches[2], 'base64');
const webpBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
}
async function main() {
console.log('Starting image migration to WebP...\n');
const client = await pool.connect();
try {
const { rows: images } = await client.query(
'SELECT id, filename, data, thumbnail, medium FROM images'
);
console.log(`Found ${images.length} images to process\n`);
let converted = 0;
let skipped = 0;
let errors = 0;
let totalSaved = 0;
for (const img of images) {
console.log(`Processing image ${img.id}: ${img.filename}`);
try {
let needsUpdate = false;
const updates = {};
if (img.data && !img.data.includes('image/webp')) {
console.log(' Converting full image...');
updates.data = await convertToWebP(img.data);
needsUpdate = true;
}
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
console.log(' Converting thumbnail...');
updates.thumbnail = await convertToWebP(img.thumbnail);
needsUpdate = true;
}
if (img.medium && !img.medium.includes('image/webp')) {
console.log(' Converting medium...');
updates.medium = await convertToWebP(img.medium);
needsUpdate = true;
}
if (needsUpdate) {
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
const newSize = (updates.data?.length || img.data?.length || 0) +
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
(updates.medium?.length || img.medium?.length || 0);
const savedKB = Math.round((oldSize - newSize) / 1024);
totalSaved += savedKB;
console.log(` Saved: ${savedKB} KB`);
await client.query(
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
);
converted++;
} else {
console.log(' Already WebP, skipping');
skipped++;
}
} catch (err) {
console.error(` Error: ${err.message}`);
errors++;
}
}
console.log('\n========================================');
console.log(`Converted: ${converted}`);
console.log(`Skipped: ${skipped}`);
console.log(`Errors: ${errors}`);
console.log(`Total saved: ${Math.round(totalSaved / 1024)} MB`);
console.log('========================================');
} finally {
client.release();
await pool.end();
}
}
main().catch(console.error);
+51
View File
@@ -0,0 +1,51 @@
-- Database Index Optimization (fixed)
-- Run with: psql -U lahuasca -d lahuasca -f add-indexes.sql
-- Images table indexes (most queried)
CREATE INDEX IF NOT EXISTS idx_images_room_id ON images(room_id);
CREATE INDEX IF NOT EXISTS idx_images_category ON images(category);
CREATE INDEX IF NOT EXISTS idx_images_location_target ON images(location_target);
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
-- Room amenity links (join table)
CREATE INDEX IF NOT EXISTS idx_room_amenity_links_amenity_id ON room_amenity_links(amenity_id);
-- Orders table
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at DESC);
-- Room reservations
CREATE INDEX IF NOT EXISTS idx_room_reservations_room_id ON room_reservations(room_id);
CREATE INDEX IF NOT EXISTS idx_room_reservations_dates ON room_reservations(check_in, check_out);
CREATE INDEX IF NOT EXISTS idx_room_reservations_user_id ON room_reservations(user_id);
-- Restaurant reservations
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
CREATE INDEX IF NOT EXISTS idx_reservations_user_id ON reservations(user_id);
-- Social event reservations
CREATE INDEX IF NOT EXISTS idx_social_event_reservations_event_id ON social_event_reservations(social_event_id);
-- Contact messages
CREATE INDEX IF NOT EXISTS idx_contact_messages_created_at ON contact_messages(created_at DESC);
-- Room comments
CREATE INDEX IF NOT EXISTS idx_room_comments_room_id ON room_comments(room_id);
CREATE INDEX IF NOT EXISTS idx_room_comments_created_at ON room_comments(created_at DESC);
-- Messages (chat)
CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(room_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
-- Enable TOAST compression for large TEXT columns in images
ALTER TABLE images ALTER COLUMN data SET STORAGE EXTERNAL;
ALTER TABLE images ALTER COLUMN thumbnail SET STORAGE EXTERNAL;
ALTER TABLE images ALTER COLUMN medium SET STORAGE EXTERNAL;
-- Vacuum analyze to update statistics
ANALYZE images;
ANALYZE rooms;
ANALYZE orders;
ANALYZE room_reservations;
ANALYZE reservations;
+76
View File
@@ -0,0 +1,76 @@
-- Migration: Move room photos from rooms.photos to images table
-- Run with: psql -U lahuasca -d lahuasca -f migrate-room-photos.sql
BEGIN;
-- First, check which rooms have photos in rooms.photos but not in images
-- For rooms 7 and 8, we need to copy their photos to images table
DO $$
DECLARE
rec RECORD;
photo_data TEXT;
room_id_val INTEGER;
photo_index INTEGER;
BEGIN
-- Loop through rooms that have photos in the array but not in images table
FOR rec IN
SELECT r.id, r.name, r.photos
FROM rooms r
WHERE array_length(r.photos, 1) > 0
AND NOT EXISTS (
SELECT 1 FROM images i WHERE i.room_id = r.id
)
LOOP
room_id_val := rec.id;
-- Insert each photo from the array into images
FOR photo_index IN 1..array_length(rec.photos, 1) LOOP
photo_data := rec.photos[photo_index];
INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target)
VALUES (
CONCAT('room-', room_id_val, '-photo-', photo_index, '.webp'),
photo_data,
photo_data, -- thumbnail (will be regenerated by WebP migration if different)
NULL,
'rooms',
room_id_val,
CONCAT('room-', room_id_val)
);
END LOOP;
RAISE NOTICE 'Migrated % photos for room % (%)', array_length(rec.photos, 1), rec.name, room_id_val;
END LOOP;
END $$;
-- Now clear the photos arrays in rooms table (images table is the source of truth)
UPDATE rooms SET photos = '{}' WHERE array_length(photos, 1) > 0;
-- For rooms that already have images, sync the photo count
-- This is informational only
DO $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT r.id, r.name,
array_length(r.photos, 1) as old_count,
(SELECT COUNT(*) FROM images i WHERE i.room_id = r.id) as new_count
FROM rooms r
LOOP
RAISE NOTICE 'Room % (%): photos array=%, images table=%',
rec.name, rec.id, rec.old_count, rec.new_count;
END LOOP;
END $$;
COMMIT;
-- Verify the migration
SELECT
r.id,
r.name,
array_length(r.photos, 1) as room_photos_count,
(SELECT COUNT(*) FROM images i WHERE i.room_id = r.id) as images_count
FROM rooms r
ORDER BY r.id;
+20
View File
@@ -0,0 +1,20 @@
-- Remove duplicate images, keeping only the most recent one per (filename, location_target, data length)
-- This handles upload accidents where same image was uploaded multiple times
-- First, identify duplicates
-- SELECT filename, location_target, COUNT(*) as dupes, array_agg(id) as ids
-- FROM images GROUP BY filename, location_target, LENGTH(data) HAVING COUNT(*) > 1;
-- Delete duplicates, keeping the latest (highest id) per group
DELETE FROM images a
USING images b
WHERE a.id < b.id
AND a.filename = b.filename
AND a.location_target = b.location_target
AND LENGTH(a.data) = LENGTH(b.data);
-- Verify cleanup
SELECT filename, location_target, COUNT(*) as count
FROM images
GROUP BY filename, location_target, LENGTH(data)
HAVING COUNT(*) > 1;
+307 -23
View File
@@ -27,7 +27,8 @@ const NAV = [
{ id: 'gallery', label: 'Images', icon: '🖼️' },
{ id: 'slides', label: 'Slideshow', icon: '🎞️' },
{ id: 'calendar', label: 'Calendar', icon: '📅' },
{ id: 'social', label: 'Social Events', icon: '🎪' },
{ id: 'social', label: 'Public Events', icon: '🎪' },
{ id: 'private-events', label: 'Private Events', icon: '🔒' },
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
{ id: 'amenities', label: 'Amenities', icon: '✨' },
{ id: 'trips', label: 'Trips', icon: '✈️' },
@@ -145,6 +146,61 @@ function Empty({ icon, title, desc }: any) {
);
}
/* ─── 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 (
<div style={{
marginBottom: '16px',
padding: '12px 16px',
background: '#fff',
borderRadius: '12px',
border: `1px solid ${C.border}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '12px',
}}>
<div>
<div style={{ fontWeight: 600, color: C.navy, fontSize: '14px' }}>🔄 WebP Migration</div>
<div style={{ fontSize: '12px', color: C.textLight }}>
{stats ? `${stats.totalImages} images · ${stats.totalSizeMB} MB total` : 'Checking...'}
</div>
</div>
<Btn onClick={runMigration} disabled={migrating} size="sm">
{migrating ? 'Migrating...' : 'Convert to WebP'}
</Btn>
</div>
);
}
function SecHead({ title, count, action }: any) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
@@ -217,8 +273,12 @@ function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: nu
const sectionCategories = [
{ key: 'slides', label: '🎞️ Slideshow Gallery', filter: (i: Image) => i.category === 'slides' || i.location_target === 'slides' },
{ key: 'food', label: '🍽️ Food Gallery', filter: (i: Image) => i.category === 'food' || i.location_target === 'food' },
{ key: 'spa', label: '🧖 Spa Gallery', filter: (i: Image) => i.category === 'spa' || i.location_target === 'spa' },
{ key: 'pool', label: '🏊 Pool Gallery', filter: (i: Image) => i.category === 'pool' || i.location_target === 'pool' },
{ 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' },
{ key: 'public', label: '🎪 Public Events Gallery', filter: (i: Image) => i.category === 'public' || i.location_target === 'public' || i.category === 'social' || i.location_target === 'social' },
{ key: 'private', label: '🔒 Private Events Gallery', filter: (i: Image) => i.category === 'private' || i.location_target === 'private' },
];
// Group room images by room location_target (e.g. "room-1", "room-2")
@@ -235,6 +295,11 @@ function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: nu
const filteredImages = images.filter((img) => {
if (selectedCategory === 'all') return true;
if (selectedCategory === 'rooms') return img.category === 'rooms' || img.location_target?.startsWith('room-');
if (selectedCategory === 'food') return img.category === 'food' || img.location_target === 'food';
if (selectedCategory === 'spa') return img.category === 'spa' || img.location_target === 'spa';
if (selectedCategory === 'pool') return img.category === 'pool' || img.location_target === 'pool';
if (selectedCategory === 'public') return img.category === 'public' || img.location_target === 'public' || img.category === 'social' || img.location_target === 'social';
if (selectedCategory === 'private') return img.category === 'private' || img.location_target === 'private';
return img.category === selectedCategory || img.location_target === selectedCategory;
});
@@ -332,7 +397,7 @@ function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: nu
border: `1px solid ${C.border}`, background: '#fff',
boxShadow: C.shadow,
}}>
{['all', 'slides', 'calendar', 'social', 'rooms', 'other'].map((cat) => (
{['all', 'slides', 'food', 'spa', 'pool', 'calendar', 'public', 'private', 'rooms', 'other'].map((cat) => (
<Btn
key={cat}
onClick={() => setSelectedCategory(cat)}
@@ -702,6 +767,44 @@ function CalendarEditor({ event, setEvent, onSave, onCancel }: any) {
function SocialSection({ events, setEvents, images, onToast }: any) {
const [editing, setEditing] = useState<SocialEvent | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const [tab, setTab] = useState<'events' | 'reservations'>('events');
const [reservations, setReservations] = useState<any[]>([]);
const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (tab === 'reservations') loadReservations();
}, [tab, statusFilter]);
const loadReservations = async () => {
setLoading(true);
try {
const url = statusFilter === 'all' ? '/api/social_event_reservations' : `/api/social_event_reservations?status=${statusFilter}`;
const res = await fetch(url);
const data = await res.json();
setReservations(data.data || []);
} catch { onToast('Error loading reservations', 'error'); }
setLoading(false);
};
const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => {
try {
await fetch(`/api/social_event_reservations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
setReservations(r => r.map(x => x.id === id ? { ...x, status } : x));
onToast(`Reservation ${status}`, 'success');
} catch { onToast('Error updating reservation', 'error'); }
};
const delReservation = async (id: number) => {
await fetch(`/api/social_event_reservations/${id}`, { method: 'DELETE' });
setReservations(r => r.filter(x => x.id !== id));
onToast('Reservation deleted', 'success');
setConfirmDel(null);
};
const save = async (ev: SocialEvent) => {
try {
@@ -727,11 +830,21 @@ function SocialSection({ events, setEvents, images, onToast }: any) {
setConfirmDel(null);
};
const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : '-';
const contactMethodIcon = (m: string) => m === 'email' ? '✉️' : m === 'whatsapp' ? '📱' : '📞';
return (
<div>
<SecHead title="🎪 Social Events" count={events.length}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', borderBottom: '1px solid #e5e7eb', paddingBottom: '12px' }}>
<Btn size="sm" variant={tab === 'events' ? 'primary' : 'secondary'} onClick={() => setTab('events')}>🎪 Events</Btn>
<Btn size="sm" variant={tab === 'reservations' ? 'primary' : 'secondary'} onClick={() => setTab('reservations')}>📅 Reservations</Btn>
</div>
{tab === 'events' ? (
<>
<SecHead title="🎪 Public Events" count={events.length}
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, sort_order: events.length })} size="sm">+ New Event</Btn>} />
{events.length === 0 ? <Empty icon="🎪" title="No social events" /> : (
{events.length === 0 ? <Empty icon="🎪" title="No public events" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{events.map((ev: SocialEvent) => (
<EntityCard key={ev.id} title={ev.name} desc={ev.description || ''} meta={`${ev.price || 'Free'}${ev.date ? ' • ' + ev.date : ''}`}
@@ -748,6 +861,57 @@ function SocialSection({ events, setEvents, images, onToast }: any) {
)}
{editing && <SocialEditor event={editing} setEvent={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDel !== null && <Confirm message="Delete this event?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</>
) : (
<>
<SecHead title="📅 Public Event Reservations" count={reservations.length} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
{(['pending', 'confirmed', 'cancelled', 'all'] as const).map(s => (
<Btn key={s} size="sm" variant={statusFilter === s ? 'primary' : 'secondary'} onClick={() => setStatusFilter(s)}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</Btn>
))}
</div>
{loading ? <Empty icon="⏳" title="Loading..." /> : reservations.length === 0 ? <Empty icon="📅" title="No reservations" desc={`No ${statusFilter === 'all' ? '' : statusFilter} reservations`} /> : (
<div style={{ display: 'grid', gap: '12px' }}>
{reservations.map((r: any) => (
<div key={r.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${r.status === 'pending' ? '#f59e0b' : r.status === 'confirmed' ? '#10b981' : '#ef4444'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{r.event_name}</div>
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>{r.guests} guests</div>
</div>
<span style={{
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
background: r.status === 'confirmed' ? '#d1fae5' : r.status === 'cancelled' ? '#fee2e2' : '#fef3c7',
color: r.status === 'confirmed' ? '#047857' : r.status === 'cancelled' ? '#b91c1c' : '#b45309'
}}>{r.status}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '13px', marginBottom: '12px' }}>
<div><strong>Contact:</strong> {r.contact_name || r.first_name || r.username || '-'}</div>
<div><strong>Method:</strong> {r.contact_method ? contactMethodIcon(r.contact_method) : '-'} {r.contact_method || '-'}</div>
{r.contact_email && <div><strong>Email:</strong> {r.contact_email}</div>}
{r.contact_phone && <div><strong>Phone:</strong> {r.contact_phone}</div>}
</div>
{r.notes && <div style={{ fontSize: '13px', color: '#666', marginBottom: '12px', fontStyle: 'italic' }}>"{r.notes}"</div>}
<div style={{ display: 'flex', gap: '8px' }}>
{r.status === 'pending' && (
<>
<Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}> Confirm</Btn>
<Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}> Cancel</Btn>
</>
)}
{r.status === 'confirmed' && <Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}> Cancel</Btn>}
{r.status === 'cancelled' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}> Reactivate</Btn>}
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(r.id)}>🗑 Delete</Btn>
</div>
</div>
))}
</div>
)}
{confirmDel !== null && <Confirm message="Delete this reservation?" onCancel={() => setConfirmDel(null)} onConfirm={() => delReservation(confirmDel)} />}
</>
)}
</div>
);
}
@@ -811,6 +975,102 @@ function SocialEditor({ event, setEvent, images, onSave, onCancel }: any) {
}
/* ─── Rooms Section ─── */
function PrivateEventsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
const [reservations, setReservations] = useState<any[]>([]);
const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending');
const [loading, setLoading] = useState(true);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
useEffect(() => {
loadReservations();
}, [statusFilter]);
const loadReservations = async () => {
setLoading(true);
try {
const url = statusFilter === 'all' ? '/api/private-event-reservations' : `/api/private-event-reservations?status=${statusFilter}`;
const res = await fetch(url);
const data = await res.json();
setReservations(data.data || []);
} catch { onToast('Error loading reservations', 'error'); }
setLoading(false);
};
const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => {
try {
await fetch(`/api/private-event-reservations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
setReservations(r => r.map(x => x.id === id ? { ...x, status } : x));
onToast(`Reservation ${status}`, 'success');
} catch { onToast('Error updating reservation', 'error'); }
};
const del = async (id: number) => {
await fetch(`/api/private-event-reservations/${id}`, { method: 'DELETE' });
setReservations(r => r.filter(x => x.id !== id));
onToast('Reservation deleted', 'success');
setConfirmDel(null);
};
const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : '-';
const formatTime = (t: string | null) => t || '-';
const contactMethodIcon = (m: string) => m === 'email' ? '✉️' : m === 'whatsapp' ? '📱' : '📞';
return (
<div>
<SecHead title="🔒 Private Event Reservations" count={reservations.length} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
{(['pending', 'confirmed', 'cancelled', 'all'] as const).map(s => (
<Btn key={s} size="sm" variant={statusFilter === s ? 'primary' : 'secondary'} onClick={() => setStatusFilter(s)}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</Btn>
))}
</div>
{loading ? <Empty icon="⏳" title="Loading..." /> : reservations.length === 0 ? <Empty icon="🔒" title="No reservations" desc={`No ${statusFilter === 'all' ? '' : statusFilter} reservations`} /> : (
<div style={{ display: 'grid', gap: '12px' }}>
{reservations.map((r: any) => (
<div key={r.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${r.status === 'pending' ? '#f59e0b' : r.status === 'confirmed' ? '#10b981' : '#ef4444'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{r.event_name}</div>
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>{r.guests} guests {formatDate(r.event_date)} {formatTime(r.event_time)}</div>
</div>
<span style={{
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
background: r.status === 'confirmed' ? '#d1fae5' : r.status === 'cancelled' ? '#fee2e2' : '#fef3c7',
color: r.status === 'confirmed' ? '#047857' : r.status === 'cancelled' ? '#b91c1c' : '#b45309'
}}>{r.status}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '13px', marginBottom: '12px' }}>
<div><strong>Contact:</strong> {r.contact_name}</div>
<div><strong>Method:</strong> {contactMethodIcon(r.contact_method)} {r.contact_method}</div>
{r.contact_email && <div><strong>Email:</strong> {r.contact_email}</div>}
{r.contact_phone && <div><strong>Phone:</strong> {r.contact_phone}</div>}
</div>
{r.notes && <div style={{ fontSize: '13px', color: '#666', marginBottom: '12px', fontStyle: 'italic' }}>"{r.notes}"</div>}
<div style={{ display: 'flex', gap: '8px' }}>
{r.status === 'pending' && (
<>
<Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}> Confirm</Btn>
<Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}> Cancel</Btn>
</>
)}
{r.status === 'confirmed' && <Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}> Cancel</Btn>}
{r.status === 'cancelled' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}> Reactivate</Btn>}
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(r.id)}>🗑 Delete</Btn>
</div>
</div>
))}
</div>
)}
{confirmDel !== null && <Confirm message="Delete this reservation?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</div>
);
}
function RoomsSection({ rooms, setRooms, images, onToast }: any) {
const [editing, setEditing] = useState<Room | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
@@ -866,17 +1126,25 @@ function RoomsSection({ rooms, setRooms, images, onToast }: any) {
function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false);
const [localPhotos, setLocalPhotos] = useState<string[]>(room.photos || []);
const [roomImages, setRoomImages] = useState<{id: number; data: string; thumbnail: string}[]>([]);
const [allAmenities, setAllAmenities] = useState<Amenity[]>([]);
const [selectedAmenities, setSelectedAmenities] = useState<number[]>(room.amenity_ids || []);
const r = { ...room, photos: localPhotos, amenity_ids: selectedAmenities };
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 =>
@@ -885,7 +1153,7 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
};
const addPhotos = async (files: FileList) => {
const newPhotos: string[] = [];
const newImages: {id: number; data: string; thumbnail: string}[] = [];
for (const f of Array.from(files)) {
const b64 = await readFileBase64(f);
try {
@@ -893,10 +1161,20 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
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(); newPhotos.push(d.data.data); }
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
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 });
}
setLocalPhotos(prev => [...prev, ...newPhotos]);
} 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 (
@@ -910,15 +1188,15 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Btn onClick={() => document.getElementById(`room-photos-${r.id}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
<input id={`room-photos-${r.id}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
<Btn onClick={() => document.getElementById(`room-photos-${r.id || 'new'}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
<input id={`room-photos-${r.id || 'new'}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{localPhotos.map((p, i) => (
<div key={i} style={{ position: 'relative' }}>
<img src={p} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== i))}
{roomImages.map((img, i) => (
<div key={img.id || i} style={{ position: 'relative' }}>
<img src={img.thumbnail || img.data} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
<button onClick={() => removeImage(img.id)}
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}>×</button>
</div>
))}
@@ -937,7 +1215,7 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
cursor: 'pointer', fontSize: '12px', transition: 'all 150ms',
color: selectedAmenities.includes(a.id) ? C.gold : C.text
}}>
<span style={{ width: 16, height: 16 }} dangerouslySetInnerHTML={{ __html: a.icon_svg.replace(/width="24"/g, 'width="16"').replace(/height="24"/g, 'height="16"') }} />
<span style={{ width: 16, height: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} dangerouslySetInnerHTML={{ __html: a.icon_svg.replace(/width="[^"]*"/g, '').replace(/height="[^"]*"/g, '').replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
{a.name}
</button>
))}
@@ -1005,8 +1283,12 @@ function AmenitiesSection({ onToast }: { onToast: (msg: string, type: string) =>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '12px' }}>
{amenities.sort((a, b) => a.sort_order - b.sort_order).map((a: Amenity) => (
<div key={a.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
<div style={{ width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.navy }}
dangerouslySetInnerHTML={{ __html: a.icon_svg.replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/width="24"/g, 'width="32"').replace(/height="24"/g, 'height="32"') }} />
<div style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.navy }}
dangerouslySetInnerHTML={{ __html: a.icon_svg
.replace(/width="[^"]*"/g, '')
.replace(/height="[^"]*"/g, '')
.replace(/stroke="currentColor"/g, `stroke="${C.navy}"`)
.replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
<div style={{ fontWeight: 600, color: C.navy, textAlign: 'center' }}>{a.name}</div>
<div style={{ display: 'flex', gap: '8px' }}>
<Btn size="sm" variant="secondary" onClick={() => setEditing(a)}>Edit</Btn>
@@ -1053,7 +1335,7 @@ function AmenityEditor({ amenity, setAmenity, onSave, onCancel }: any) {
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '13px', fontFamily: 'monospace', outline: 'none', background: '#FAFAFA', resize: 'vertical' }} />
{amenity.icon_svg && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 64, height: 64, background: C.bg, borderRadius: 8, marginTop: '8px' }}
dangerouslySetInnerHTML={{ __html: amenity.icon_svg.replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/width="24"/g, 'width="48"').replace(/height="24"/g, 'height="48"') }} />
dangerouslySetInnerHTML={{ __html: amenity.icon_svg.replace(/width="[^"]*"/g, '').replace(/height="[^"]*"/g, '').replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
@@ -1064,7 +1346,7 @@ function AmenityEditor({ amenity, setAmenity, onSave, onCancel }: any) {
style={{ padding: '6px 10px', borderRadius: 6, border: `1px solid ${C.border}`, background: '#fff', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px', transition: 'all 150ms' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = C.gold; e.currentTarget.style.background = C.goldLight; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = C.border; e.currentTarget.style.background = '#fff'; }}>
<span style={{ width: 18, height: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }} dangerouslySetInnerHTML={{ __html: icon.svg.replace(/width="24"/g, 'width="18"').replace(/height="24"/g, 'height="18"') }} />
<span style={{ width: 18, height: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }} dangerouslySetInnerHTML={{ __html: icon.svg.replace(/width="[^"]*"/g, '').replace(/height="[^"]*"/g, '').replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
{icon.name}
</button>
))}
@@ -2462,12 +2744,14 @@ export default function AdminPage() {
case 'gallery': return (
<div>
<SecHead title="🖼️ Image Gallery" count={images.length} />
<WebPMigration onToast={showToast} onRefresh={loadImages} />
<ImageGallery images={images} onDelete={deleteImage} />
</div>
);
case 'slides': return <SlidesSection slides={slides} setSlides={setSlides} images={images} onToast={showToast} onRefreshImages={loadImages} />;
case 'calendar': return <CalendarSection events={events} setEvents={setEvents} images={images} onToast={showToast} />;
case 'social': return <SocialSection events={socialEvents} setEvents={setSocialEvents} images={images} onToast={showToast} />;
case 'private-events': return <PrivateEventsSection onToast={showToast} />;
case 'rooms': return <RoomsSection rooms={rooms} setRooms={setRooms} images={images} onToast={showToast} />;
case 'amenities': return <AmenitiesSection onToast={showToast} />;
case 'menu': return <MenuSection items={menuItems} setItems={setMenuItems} images={images} onToast={showToast} />;
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
// GET - list pending comments for admin review
export async function GET(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'pending';
const { rows } = await db.query(`
SELECT rc.*, r.name as room_name, u.username
FROM room_comments rc
LEFT JOIN rooms r ON rc.room_id = r.id
LEFT JOIN users u ON rc.user_id = u.id
WHERE rc.status = $1
ORDER BY rc.created_at DESC
`, [status]);
return NextResponse.json({ comments: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch comments' }, { status: 500 });
}
}
// PATCH - approve, reject, or edit a comment
export async function PATCH(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const { comment_id, action, text } = await request.json();
if (!comment_id || !action) {
return NextResponse.json({ error: 'comment_id and action required' }, { status: 400 });
}
let result;
if (action === 'approve') {
result = await db.query(`
UPDATE room_comments SET status = 'approved' WHERE id = $1 RETURNING *
`, [comment_id]);
} else if (action === 'reject') {
result = await db.query(`
UPDATE room_comments SET status = 'rejected' WHERE id = $1 RETURNING *
`, [comment_id]);
} else if (action === 'edit') {
if (!text) {
return NextResponse.json({ error: 'text required for edit action' }, { status: 400 });
}
result = await db.query(`
UPDATE room_comments SET text = $1, status = 'approved' WHERE id = $2 RETURNING *
`, [text, comment_id]);
} else if (action === 'delete') {
result = await db.query(`
UPDATE room_comments SET deleted_by_admin = TRUE WHERE id = $1 RETURNING *
`, [comment_id]);
} else {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
return NextResponse.json({ success: true, comment: result?.rows?.[0] });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to update comment' }, { status: 500 });
}
}
+40 -11
View File
@@ -3,17 +3,46 @@ import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
import sharp from 'sharp';
function generateThumbnail(base64Data: string, width = 300, height = 200): Promise<string> {
interface ImageSizes {
thumbnail: string;
medium: string;
data: string;
}
async function generateImageSizes(base64Data: string): Promise<ImageSizes> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) return Promise.resolve(base64Data);
if (!matches) {
return { thumbnail: base64Data, medium: base64Data, data: base64Data };
}
const ext = matches[1];
const buffer = Buffer.from(matches[2], 'base64');
return sharp(buffer)
.resize(width, height, { fit: 'cover' })
.toBuffer()
.then(resized => `data:image/${ext};base64,${resized.toString('base64')}`);
// Convert to WebP for better compression
const toBase64 = (buf: Buffer) => `data:image/webp;base64,${buf.toString('base64')}`;
// Generate multiple sizes in parallel
const [thumbnail, medium] = await Promise.all([
sharp(buffer)
.resize(400, 300, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer(),
sharp(buffer)
.resize(800, 600, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer(),
]);
// Also convert full image to WebP
const fullImage = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return {
thumbnail: toBase64(thumbnail),
medium: toBase64(medium),
data: toBase64(fullImage),
};
}
export async function GET(request: NextRequest) {
@@ -42,7 +71,7 @@ export async function GET(request: NextRequest) {
}
const { rows } = await db.query(query, params);
return NextResponse.json({ images: rows, count: rows.length });
return NextResponse.json({ data: rows, count: rows.length });
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -62,12 +91,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
}
// Generate thumbnail for faster loading
const thumbnail = await generateThumbnail(data, 400, 300);
// Generate multiple sizes for responsive images (thumbnail, medium, full)
const sizes = await generateImageSizes(data);
const { rows } = await db.query(
'INSERT INTO images (filename, data, thumbnail, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, filename, data, thumbnail, category, room_id, location_target, uploaded_at',
[filename, data, thumbnail, category || 'other', room_id || null, location_target || 'gallery']
'INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, filename, category, room_id, location_target, uploaded_at',
[filename, sizes.data, sizes.thumbnail, sizes.medium, category || 'other', room_id || null, location_target || 'gallery']
);
return NextResponse.json({ data: rows[0] });
+131
View File
@@ -0,0 +1,131 @@
import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
import sharp from 'sharp';
export const maxDuration = 300; // 5 minutes for large migrations
async function convertToWebP(base64Data: string): Promise<string> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
return base64Data;
}
const buffer = Buffer.from(matches[2], 'base64');
const webpBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
}
export async function POST() {
try {
await requireRole('admin');
console.log('Starting WebP migration...');
const { rows: images } = await db.query(
'SELECT id, filename, data, thumbnail, medium FROM images'
);
console.log(`Found ${images.length} images to process`);
let converted = 0;
let skipped = 0;
let errors: number[] = [];
let totalSavedKB = 0;
for (const img of images) {
try {
let needsUpdate = false;
const updates: { data?: string; thumbnail?: string; medium?: string } = {};
if (img.data && !img.data.includes('image/webp')) {
console.log(`Converting image ${img.id}: ${img.filename} (full)`);
updates.data = await convertToWebP(img.data);
needsUpdate = true;
}
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
console.log(`Converting image ${img.id}: ${img.filename} (thumbnail)`);
updates.thumbnail = await convertToWebP(img.thumbnail);
needsUpdate = true;
}
if (img.medium && !img.medium.includes('image/webp')) {
console.log(`Converting image ${img.id}: ${img.filename} (medium)`);
updates.medium = await convertToWebP(img.medium);
needsUpdate = true;
}
if (needsUpdate) {
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
const newSize = (updates.data?.length || img.data?.length || 0) +
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
(updates.medium?.length || img.medium?.length || 0);
const savedKB = Math.round((oldSize - newSize) / 1024);
totalSavedKB += savedKB;
await db.query(
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
);
converted++;
} else {
skipped++;
}
} catch (err) {
console.error(`Error processing image ${img.id}:`, err);
errors.push(img.id);
}
}
return NextResponse.json({
success: true,
total: images.length,
converted,
skipped,
errors,
totalSavedMB: Math.round(totalSavedKB / 1024 * 10) / 10,
});
} catch (error) {
console.error('Migration error:', error);
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ error: 'Migration failed', details: (error as Error).message }, { status: 500 });
}
}
export async function GET() {
try {
await requireRole('admin');
const { rows: images } = await db.query(
'SELECT id, filename, LENGTH(data) as data_size, LENGTH(thumbnail) as thumb_size, LENGTH(medium) as medium_size FROM images'
);
let totalSize = 0;
let webpCount = 0;
let jpegCount = 0;
for (const img of images) {
totalSize += (img.data_size || 0) + (img.thumb_size || 0) + (img.medium_size || 0);
if (img.data_size) {
// We'd need to fetch the actual data to check format, so estimate based on size
}
}
return NextResponse.json({
totalImages: images.length,
totalSizeKB: Math.round(totalSize / 1024),
totalSizeMB: Math.round(totalSize / 1024 / 1024 * 10) / 10,
});
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to get stats' }, { status: 500 });
}
}
+14 -5
View File
@@ -6,12 +6,19 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail
const medium = searchParams.get('medium'); // ?medium=1 for medium size
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const { rows } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
// Select appropriate column based on size
const column = thumb ? 'thumbnail' : medium ? 'medium' : 'data';
const { rows } = await db.query(
`SELECT ${column} as data FROM images WHERE id = $1`,
[id]
);
if (!rows.length) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
@@ -19,10 +26,12 @@ export async function GET(request: NextRequest) {
const imageData = rows[0].data;
// If thumbnail requested and we have thumbnail data stored
if (thumb) {
// For now, return the full image (thumbnail generation happens on upload)
// Future: store thumbnail separately in thumbnail column
if (!imageData) {
// Fallback to full image if variant doesn't exist
const { rows: fallback } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
if (!fallback.length || !fallback[0].data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
// Parse base64 data
+91
View File
@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const user = await getSession();
const { room_id, message, guest_name, guest_contact_method, guest_contact } = await request.json();
if (!message || !message.trim()) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
}
// For guests, require name and contact info
if (!user && (!guest_name || !guest_contact)) {
return NextResponse.json({ error: 'Name and contact info are required' }, { status: 400 });
}
// Insert message - for guests, user_id is NULL
const { rows } = await db.query(`
INSERT INTO messages (room_id, user_id, message, guest_name, guest_contact_method, guest_contact)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [room_id || null, user?.id || null, message.trim(), guest_name || null, guest_contact_method || null, guest_contact || null]);
// Get admin emails for notification
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND (email IS NOT NULL OR phone IS NOT NULL)`);
// TODO: Send email/SMS notifications to admins
return NextResponse.json({ success: true, message: 'Message sent successfully' });
} catch (error) {
console.error('Message error:', error);
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const unreadOnly = searchParams.get('unread') === 'true';
let query = `
SELECT m.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email, u.phone
FROM messages m
LEFT JOIN rooms r ON m.room_id = r.id
LEFT JOIN users u ON m.user_id = u.id
`;
if (unreadOnly) {
query += ' WHERE m.read_by_admins = FALSE';
}
query += ' ORDER BY m.created_at DESC LIMIT 100';
const { rows } = await db.query(query);
return NextResponse.json({ messages: rows });
} catch (error) {
console.error('Get messages error:', error);
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { message_ids } = await request.json();
if (!message_ids || !Array.isArray(message_ids)) {
return NextResponse.json({ error: 'message_ids array is required' }, { status: 400 });
}
await db.query(`
UPDATE messages SET read_by_admins = TRUE WHERE id = ANY($1)
`, [message_ids]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Mark messages read error:', error);
return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 });
}
}
+194
View File
@@ -0,0 +1,194 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const user = await getSession();
const { items, order_type, room_id, notes } = await request.json();
// items is array of { menu_item_id, quantity }
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: 'Order items are required' }, { status: 400 });
}
if (!order_type || !['pickup', 'dine_in', 'room_delivery'].includes(order_type)) {
return NextResponse.json({ error: 'Valid order type is required (pickup, dine_in, room_delivery)' }, { status: 400 });
}
// Room delivery requires login
if (order_type === 'room_delivery' && !user) {
return NextResponse.json({ error: 'Room delivery requires login. Please log in to continue.' }, { status: 401 });
}
// Room delivery requires room_id
if (order_type === 'room_delivery' && !room_id) {
return NextResponse.json({ error: 'Room selection is required for room delivery' }, { status: 400 });
}
// Get menu item prices
const menuItemIds = items.map((i: { menu_item_id: number }) => i.menu_item_id);
const { rows: menuItems } = await db.query(
'SELECT id, name, price FROM menu_items WHERE id = ANY($1)',
[menuItemIds]
);
if (menuItems.length !== menuItemIds.length) {
return NextResponse.json({ error: 'One or more menu items not found' }, { status: 400 });
}
// Calculate total
const priceMap = new Map(menuItems.map((m: { id: number; price: string }) => [m.id, parseFloat(m.price)]));
let subtotal = 0;
const orderItems = items.map((i: { menu_item_id: number; quantity: number }) => {
const price = priceMap.get(i.menu_item_id) || 0;
const itemTotal = price * i.quantity;
subtotal += itemTotal;
return {
menu_item_id: i.menu_item_id,
quantity: i.quantity,
unit_price: price,
total_price: itemTotal,
};
});
// Room delivery adds 10% service fee
const serviceFee = order_type === 'room_delivery' ? subtotal * 0.1 : 0;
const total = subtotal + serviceFee;
// Create order
const { rows: orderRows } = await db.query(`
INSERT INTO orders (user_id, order_type, room_id, subtotal, service_fee, total, status, notes)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)
RETURNING *
`, [user?.id || null, order_type, order_type === 'room_delivery' ? room_id : null, subtotal, serviceFee, total, notes || null]);
const orderId = orderRows[0].id;
// Insert order items
for (const item of orderItems) {
await db.query(`
INSERT INTO order_items (order_id, menu_item_id, quantity, unit_price, total_price)
VALUES ($1, $2, $3, $4, $5)
`, [orderId, item.menu_item_id, item.quantity, item.unit_price, item.total_price]);
}
// Get full order with items
const { rows: fullOrder } = await db.query(`
SELECT o.*,
json_agg(json_build_object(
'menu_item_id', oi.menu_item_id,
'quantity', oi.quantity,
'unit_price', oi.unit_price,
'total_price', oi.total_price,
'name', m.name
)) as items
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN menu_items m ON oi.menu_item_id = m.id
WHERE o.id = $1
GROUP BY o.id
`, [orderId]);
// Get admin notification targets
const { rows: admins } = await db.query(
'SELECT email, phone FROM users WHERE role = \'admin\' AND (email IS NOT NULL OR phone IS NOT NULL)'
);
// TODO: Send notification to admins (email/SMS)
return NextResponse.json({
success: true,
order: fullOrder[0],
message: order_type === 'room_delivery'
? 'Order placed! We\'ll deliver it to your room shortly.'
: order_type === 'dine_in'
? 'Order placed! We\'ll prepare it for dine-in.'
: 'Order placed! We\'ll have it ready for pickup.'
});
} catch (error) {
console.error('Order error:', error);
return NextResponse.json({ error: 'Failed to create order' }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'all';
const limit = parseInt(searchParams.get('limit') || '50');
let query = `
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
FROM orders o
LEFT JOIN rooms r ON o.room_id = r.id
LEFT JOIN users u ON o.user_id = u.id
`;
const params: any[] = [];
if (status !== 'all') {
query += ' WHERE o.status = $1';
params.push(status);
}
query += ' ORDER BY o.created_at DESC LIMIT $' + (params.length + 1);
params.push(limit);
const { rows } = await db.query(query, params);
// Get items for each order
for (const order of rows) {
const { rows: items } = await db.query(`
SELECT oi.*, m.name, m.description
FROM order_items oi
JOIN menu_items m ON oi.menu_item_id = m.id
WHERE oi.order_id = $1
`, [order.id]);
order.items = items;
}
return NextResponse.json({ orders: rows });
} catch (error) {
console.error('Get orders error:', error);
return NextResponse.json({ error: 'Failed to get orders' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { order_id, status } = await request.json();
if (!order_id || !status) {
return NextResponse.json({ error: 'order_id and status are required' }, { status: 400 });
}
const validStatuses = ['pending', 'preparing', 'ready', 'delivered', 'cancelled'];
if (!validStatuses.includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
}
const { rows } = await db.query(
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[status, order_id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
}
return NextResponse.json({ success: true, order: rows[0] });
} catch (error) {
console.error('Update order error:', error);
return NextResponse.json({ error: 'Failed to update order' }, { status: 500 });
}
}
@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit';
import { withRateLimit } from '@/lib/rate-limit';
// GET - Get a single private event reservation (admin only)
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const { rows } = await db.query(
'SELECT * FROM private_event_reservations WHERE id = $1',
[params.id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error fetching reservation:', error);
return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 });
}
}
// PUT - Update reservation status (admin only)
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const body = await request.json();
const { status } = body;
if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'Invalid status. Must be pending, confirmed, or cancelled' }, { status: 400 });
}
// Get current status for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM private_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
const oldStatus = beforeRows[0].status;
const { rows } = await db.query(
`UPDATE private_event_reservations SET status = $1 WHERE id = $2 RETURNING *`,
[status, params.id]
);
// Audit log
await auditLog({
userId: session.id,
action: 'status_change',
entityType: 'private_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: { status: oldStatus },
newValues: { status },
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error updating reservation:', error);
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
}
}
// DELETE - Delete a reservation (admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
// Get the reservation before deleting for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM private_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
await db.query('DELETE FROM private_event_reservations WHERE id = $1', [params.id]);
// Audit log
await auditLog({
userId: session.id,
action: 'delete',
entityType: 'private_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: beforeRows[0],
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting reservation:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { withRateLimit, getClientIp } from '@/lib/rate-limit';
// Honeypot field name - bots often autofill all fields
// This field should remain empty for legitimate submissions
const HONEYPOT_FIELD = 'website_url';
// GET - List all private event reservations (admin only)
export async function GET(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting for admin endpoint
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
let query = 'SELECT * FROM private_event_reservations';
const params: any[] = [];
if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) {
query += ' WHERE status = $1 ORDER BY created_at DESC';
params.push(status);
} else {
query += ' ORDER BY status = \'pending\' DESC, created_at DESC';
}
const { rows } = await db.query(query, params);
return NextResponse.json({ data: rows });
} catch (error) {
console.error('Error fetching private event reservations:', error);
return NextResponse.json({ error: 'Failed to fetch reservations' }, { status: 500 });
}
}
// POST - Create a new private event reservation (public endpoint)
export async function POST(request: NextRequest) {
try {
// Rate limiting
const rateCheck = withRateLimit(request, 'private-event-reservation');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const body = await request.json();
const {
event_name,
contact_name,
contact_email,
contact_phone,
contact_method,
event_date,
event_time,
guests,
notes,
// Honeypot field - should be empty
[HONEYPOT_FIELD]: honeypot
} = body;
// Honeypot check - reject if filled (bot detection)
if (honeypot && honeypot.trim() !== '') {
// Silently reject but return success to confuse bots
console.log('Honeypot triggered, rejecting submission from:', getClientIp(request));
return NextResponse.json({
data: { id: Date.now(), status: 'pending' }
});
}
// Validation
if (!event_name || !contact_name || !contact_method || !guests) {
return NextResponse.json({ error: 'Missing required fields: event_name, contact_name, contact_method, guests' }, { status: 400 });
}
if (!['email', 'phone', 'whatsapp'].includes(contact_method)) {
return NextResponse.json({ error: 'Invalid contact method. Must be email, phone, or whatsapp' }, { status: 400 });
}
// Validate email format if provided
if (contact_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact_email)) {
return NextResponse.json({ error: 'Invalid email format' }, { status: 400 });
}
// Validate phone format if provided (basic international format)
if (contact_phone && !/^[\d\s\-+()]{7,20}$/.test(contact_phone)) {
return NextResponse.json({ error: 'Invalid phone format' }, { status: 400 });
}
// Validate guests is positive
const guestsNum = parseInt(guests, 10);
if (isNaN(guestsNum) || guestsNum < 1 || guestsNum > 1000) {
return NextResponse.json({ error: 'Guests must be between 1 and 1000' }, { status: 400 });
}
// Length limits (prevent abuse)
if (event_name.length > 255 || contact_name.length > 255) {
return NextResponse.json({ error: 'Event name and contact name must be under 255 characters' }, { status: 400 });
}
if (contact_email && contact_email.length > 255) {
return NextResponse.json({ error: 'Email must be under 255 characters' }, { status: 400 });
}
if (contact_phone && contact_phone.length > 50) {
return NextResponse.json({ error: 'Phone must be under 50 characters' }, { status: 400 });
}
if (notes && notes.length > 2000) {
return NextResponse.json({ error: 'Notes must be under 2000 characters' }, { status: 400 });
}
// Validate event_date is in the future if provided
if (event_date) {
const date = new Date(event_date);
if (isNaN(date.getTime())) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
if (date < new Date()) {
return NextResponse.json({ error: 'Event date must be in the future' }, { status: 400 });
}
}
const { rows } = await db.query(
`INSERT INTO private_event_reservations
(event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guests, notes, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')
RETURNING id, event_name, guests, event_date, event_time, status, created_at`,
[event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guestsNum, notes]
);
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error creating private event reservation:', error);
return NextResponse.json({ error: 'Failed to create reservation' }, { status: 500 });
}
}
+142
View File
@@ -0,0 +1,142 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const user = await getSession();
const body = await request.json();
const { room_id, check_in, check_out, guest_name, guest_contact_method, guest_contact } = body;
// Require either logged-in user or guest info
if (!user && (!guest_name || !guest_contact)) {
return NextResponse.json({ error: 'Please provide your name and contact information' }, { status: 400 });
}
if (!room_id || !check_in || !check_out) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Check if room exists
const { rows: roomRows } = await db.query('SELECT * FROM rooms WHERE id = $1', [room_id]);
if (roomRows.length === 0) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
}
const room = roomRows[0];
// Check for conflicting reservations
const { rows: conflicts } = await db.query(`
SELECT * FROM room_reservations
WHERE room_id = $1
AND status != 'cancelled'
AND (
(check_in <= $2 AND check_out > $2) OR
(check_in < $3 AND check_out >= $3) OR
(check_in >= $2 AND check_out <= $3)
)
`, [room_id, check_in, check_out]);
if (conflicts.length > 0) {
return NextResponse.json({ error: 'Room is not available for selected dates' }, { status: 409 });
}
// Check for blocked dates
const { rows: blocked } = await db.query(`
SELECT * FROM room_availability
WHERE room_id = $1
AND date >= $2
AND date < $3
AND status != 'available'
`, [room_id, check_in, check_out]);
if (blocked.length > 0) {
return NextResponse.json({ error: 'Some dates are not available' }, { status: 409 });
}
// Calculate total price
const nights = Math.ceil((new Date(check_out).getTime() - new Date(check_in).getTime()) / (1000 * 60 * 60 * 24));
const pricePerNight = parseFloat(room.price) || 0;
const total_price = nights * pricePerNight;
// Create reservation - for guests, user_id is NULL
const { rows } = await db.query(`
INSERT INTO room_reservations (room_id, user_id, check_in, check_out, status, total_price, guest_name, guest_contact_method, guest_contact)
VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
RETURNING *
`, [
room_id,
user?.id || null,
check_in,
check_out,
total_price,
guest_name || null,
guest_contact_method || null,
guest_contact || null,
]);
// Block the dates
const insertPromises = [];
const checkInDate = new Date(check_in);
const checkOutDate = new Date(check_out);
for (let d = new Date(checkInDate); d < checkOutDate; d.setDate(d.getDate() + 1)) {
insertPromises.push(
db.query(`
INSERT INTO room_availability (room_id, date, status, reason)
VALUES ($1, $2, 'booked', $3)
ON CONFLICT (room_id, date) DO UPDATE SET status = 'booked', reason = $3
`, [room_id, d.toISOString().split('T')[0], `Reservation #${rows[0].id}`])
);
}
await Promise.all(insertPromises);
// Get admin emails for notification
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND email IS NOT NULL`);
// TODO: Send email notifications to admins
// TODO: Send welcome email to user with WiFi password and instructions
return NextResponse.json({
success: true,
reservation: rows[0],
message: 'Reservation request submitted. You will receive confirmation shortly.'
});
} catch (error) {
console.error('Reservation error:', error);
return NextResponse.json({ error: 'Failed to create reservation' }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'all';
let query = `
SELECT r.*, rm.name as room_name,
u.username, u.first_name, u.last_name, u.email, u.phone
FROM room_reservations r
JOIN rooms rm ON r.room_id = rm.id
LEFT JOIN users u ON r.user_id = u.id
`;
const params: any[] = [];
if (status !== 'all') {
query += ' WHERE r.status = $1';
params.push(status);
}
query += ' ORDER BY r.created_at DESC';
const { rows } = await db.query(query, params);
return NextResponse.json({ reservations: rows });
} catch (error) {
console.error('Get reservations error:', error);
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
}
}
@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id);
const { searchParams } = new URL(request.url);
const start = searchParams.get('start');
const end = searchParams.get('end');
if (!start || !end) {
return NextResponse.json({ error: 'start and end dates required' }, { status: 400 });
}
// Get availability status for the date range
const { rows: availability } = await db.query(`
SELECT date, status, reason
FROM room_availability
WHERE room_id = $1 AND date >= $2 AND date <= $3
`, [roomId, start, end]);
// Get reservations in the date range
const { rows: reservations } = await db.query(`
SELECT check_in, check_out, status
FROM reservations
WHERE room_id = $1
AND status != 'cancelled'
AND check_in <= $3
AND check_out >= $2
`, [roomId, start, end]);
// Build day-by-day status
const days: Array<{ date: string; status: 'available' | 'booked' | 'maintenance' }> = [];
const startDate = new Date(start);
const endDate = new Date(end);
const availabilityMap = new Map(availability.map(a => [a.date, a]));
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const avail = availabilityMap.get(dateStr);
if (avail) {
days.push({ date: dateStr, status: avail.status as 'available' | 'booked' | 'maintenance' });
} else {
// Check if date falls within any reservation
const isBooked = reservations.some(r => {
const checkIn = new Date(r.check_in);
const checkOut = new Date(r.check_out);
return d >= checkIn && d < checkOut;
});
days.push({ date: dateStr, status: isBooked ? 'booked' : 'available' });
}
}
return NextResponse.json({ days });
} catch (error) {
console.error('Availability error:', error);
return NextResponse.json({ error: 'Failed to get availability' }, { status: 500 });
}
}
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id);
const { date, status, reason } = await request.json();
if (!date || !status) {
return NextResponse.json({ error: 'date and status required' }, { status: 400 });
}
const { rows } = await db.query(`
INSERT INTO room_availability (room_id, date, status, reason)
VALUES ($1, $2, $3, $4)
ON CONFLICT (room_id, date) DO UPDATE SET status = $3, reason = $4
RETURNING *
`, [roomId, date, status, reason || null]);
return NextResponse.json({ success: true, availability: rows[0] });
} catch (error) {
console.error('Set availability error:', error);
return NextResponse.json({ error: 'Failed to set availability' }, { status: 500 });
}
}
+5 -5
View File
@@ -9,9 +9,9 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
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
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin, status
FROM room_comments
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE AND status = 'approved'
ORDER BY created_at DESC`,
[roomId, photoUrl]
);
@@ -47,12 +47,12 @@ export async function POST(request: NextRequest, { params }: { params: { id: str
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 *`,
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING *`,
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()]
);
return NextResponse.json({ data: inserted[0] });
return NextResponse.json({ data: inserted[0], message: 'Comment submitted for review' });
} catch (err: any) {
return NextResponse.json(
{ error: err.message || 'Failed to post comment' },
+65 -29
View File
@@ -1,53 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
// GET - check if user liked a room and get total likes
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id, 10);
if (isNaN(roomId)) {
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
const session = await getSession();
// Get total likes
const { rows: countRows } = await db.query(
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
[roomId]
);
const totalLikes = parseInt(countRows[0].count) || 0;
// Check if current user liked this room
let likedByUser = false;
if (session) {
const { rows: likeRows } = await db.query(
'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, session.id]
);
likedByUser = likeRows.length > 0;
}
const [countResult, userLikeResult] = await Promise.all([
db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]),
db.query('SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2', [roomId, 1]),
]);
return NextResponse.json({
count: parseInt(countResult.rows[0].count, 10),
user_liked: userLikeResult.rows.length > 0,
});
return NextResponse.json({ totalLikes, likedByUser });
} catch (err: any) {
// Table may not exist yet — return safe defaults
return NextResponse.json({ count: 0, user_liked: false });
return NextResponse.json({ error: err.message || 'Failed to get likes' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
// POST - toggle like (add if not liked, remove if already liked)
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id, 10);
if (isNaN(roomId)) {
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// TODO: get user_id from session/auth instead of hardcoded
const userId = 1;
const roomId = parseInt(params.id, 10);
// Toggle: if already liked, unlike; otherwise like
const existing = await db.query(
'SELECT id FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, userId]
// Check if already liked
const { rows: existing } = await db.query(
'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, session.id]
);
if (existing.rows.length > 0) {
await db.query('DELETE FROM room_likes WHERE id = $1', [existing.rows[0].id]);
let liked: boolean;
if (existing.length > 0) {
// Unlike - remove the like
await db.query(
'DELETE FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, session.id]
);
liked = false;
} else {
await db.query('INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)', [roomId, userId]);
// Like - add the like
await db.query(
'INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)',
[roomId, session.id]
);
liked = true;
}
const countResult = await db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]);
// Get updated count
const { rows: countRows } = await db.query(
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
[roomId]
);
const totalLikes = parseInt(countRows[0].count) || 0;
return NextResponse.json({ count: parseInt(countResult.rows[0].count, 10) });
// Update the rooms table for quick access
await db.query(
'UPDATE rooms SET likes_count = $1 WHERE id = $2',
[totalLikes, roomId]
);
return NextResponse.json({ liked, totalLikes });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to toggle like' }, { status: 500 });
}
+12 -4
View File
@@ -29,15 +29,16 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const body = await request.json();
const { name, description, price, photos, sort_order, featured_photo, active, amenity_ids } = body;
const { name, description, price, sort_order, featured_photo, active, amenity_ids } = body;
if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Photos are stored in images table, not rooms.photos
const { rows } = await db.query(
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`,
[name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id]
`UPDATE rooms SET name = $1, description = $2, price = $3, sort_order = $4, featured_photo = $5, active = $6 WHERE id = $7 RETURNING *`,
[name, description, price, sort_order || 0, featured_photo || null, active !== false, id]
);
// Update amenities
@@ -47,7 +48,14 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
}
return NextResponse.json({ data: rows[0] });
// Get photos from images table
const { rows: imageRows } = await db.query(
'SELECT id, data, thumbnail FROM images WHERE room_id = $1 ORDER BY id',
[id]
);
const photos = imageRows.map((r: any) => r.thumbnail || r.data);
return NextResponse.json({ data: { ...rows[0], photos, amenity_ids: amenity_ids || [] } });
} catch (error: any) {
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
console.error('PUT /api/rooms/[id]:', error);
+64 -10
View File
@@ -6,17 +6,39 @@ export async function GET() {
try {
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id');
// Get amenity links for all rooms
const { rows: amenityLinks } = await db.query('SELECT room_id, amenity_id FROM room_amenity_links');
const amenityMap = new Map<number, number[]>();
for (const link of amenityLinks) {
if (!amenityMap.has(link.room_id)) amenityMap.set(link.room_id, []);
amenityMap.get(link.room_id)!.push(link.amenity_id);
// Get amenity details with icons for all rooms
const { rows: amenityRows } = await db.query(`
SELECT ral.room_id, ra.id, ra.name, ra.icon_svg
FROM room_amenity_links ral
JOIN room_amenities ra ON ral.amenity_id = ra.id
ORDER BY ra.sort_order
`);
const amenityMap = new Map<number, Array<{id: number; name: string; icon_svg: string}>>();
for (const row of amenityRows) {
if (!amenityMap.has(row.room_id)) amenityMap.set(row.room_id, []);
amenityMap.get(row.room_id)!.push({ id: row.id, name: row.name, icon_svg: row.icon_svg });
}
// Get photos from images table for all rooms
const { rows: imageRows } = await db.query(`
SELECT room_id, data, thumbnail, medium
FROM images
WHERE room_id IS NOT NULL
ORDER BY room_id, id
`);
const imageMap = new Map<number, string[]>();
for (const row of imageRows) {
if (!imageMap.has(row.room_id)) imageMap.set(row.room_id, []);
// Prefer thumbnail for display, fallback to data
imageMap.get(row.room_id)!.push(row.thumbnail || row.data);
}
const roomsWithAmenities = rows.map(r => ({
...r,
amenity_ids: amenityMap.get(r.id) || []
photos: imageMap.get(r.id) || [],
amenities: amenityMap.get(r.id) || []
}));
return NextResponse.json({ data: roomsWithAmenities });
@@ -30,17 +52,18 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { name, description, price, photos, sort_order, featured_photo, amenity_ids } = body;
const { name, description, price, sort_order, featured_photo, amenity_ids } = body;
if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Photos are now stored in images table, not rooms.photos
const { rows } = await db.query(
`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, featured_photo || null]
[name, description, price, [], sort_order || 0, featured_photo || null]
);
const roomId = rows[0].id;
@@ -51,9 +74,40 @@ export async function POST(request: NextRequest) {
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
}
return NextResponse.json({ data: { ...rows[0], amenity_ids: amenity_ids || [] } });
return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } });
} catch (error: any) {
console.error('POST /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, name, description, price, active, sort_order, featured_photo, amenity_ids } = body;
if (!id) {
return NextResponse.json({ error: 'Room ID required' }, { status: 400 });
}
// Photos are stored in images table, not rooms.photos
const { rows } = await db.query(
`UPDATE rooms SET name = $1, description = $2, price = $3, active = $4, sort_order = $5, featured_photo = $6
WHERE id = $7 RETURNING *`,
[name, description, price, active ?? true, sort_order ?? 0, featured_photo || null, id]
);
// Update amenity links
await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [id]);
if (amenity_ids && amenity_ids.length > 0) {
const values = amenity_ids.map((aid: number) => `(${id}, ${aid})`).join(',');
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
}
return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } });
} catch (error: any) {
console.error('PUT /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to update room' }, { status: 500 });
}
}
@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit';
import { withRateLimit } from '@/lib/rate-limit';
// GET - Get a single social event reservation
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
await requireAuth();
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-social-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const { rows } = await db.query(
`SELECT ser.*, se.name as event_name
FROM social_event_reservations ser
JOIN social_events se ON se.id = ser.social_event_id
WHERE ser.id = $1`,
[params.id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error fetching reservation:', error);
return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 });
}
}
// PUT - Update reservation status (admin only)
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-social-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const body = await request.json();
const { status } = body;
if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
}
// Get current status for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM social_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
const oldStatus = beforeRows[0].status;
const { rows } = await db.query(
`UPDATE social_event_reservations SET status = $1 WHERE id = $2 RETURNING *`,
[status, params.id]
);
// Audit log
await auditLog({
userId: session.id,
action: 'status_change',
entityType: 'social_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: { status: oldStatus },
newValues: { status },
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error updating reservation:', error);
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
}
}
// DELETE - Delete a reservation (admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-social-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
// Get the reservation before deleting for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM social_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
await db.query('DELETE FROM social_event_reservations WHERE id = $1', [params.id]);
// Audit log
await auditLog({
userId: session.id,
action: 'delete',
entityType: 'social_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: beforeRows[0],
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting reservation:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
+37 -11
View File
@@ -7,20 +7,31 @@ export async function GET(request: Request) {
const session = await requireAuth();
const { searchParams } = new URL(request.url);
const socialEventId = searchParams.get('social_event_id');
const status = searchParams.get('status');
let query = `
SELECT ser.*, u.username, u.first_name, u.last_name, se.name as event_name
SELECT ser.*, se.name as event_name
FROM social_event_reservations ser
JOIN users u ON u.id = ser.user_id
JOIN social_events se ON se.id = ser.social_event_id
`;
const params: (number | string)[] = [];
if (session.role === 'admin') {
const conditions: string[] = [];
let paramCount = 1;
if (socialEventId) {
query += ' WHERE ser.social_event_id = $1';
conditions.push(`ser.social_event_id = $${paramCount++}`);
params.push(parseInt(socialEventId, 10));
}
if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) {
conditions.push(`ser.status = $${paramCount++}`);
params.push(status);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
} else {
query += ' WHERE ser.user_id = $1';
params.push(session.id);
@@ -41,23 +52,38 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const session = await requireAuth();
const body = await request.json();
const { social_event_id, guests, notes } = body;
const { social_event_id, guests, notes, contact_name, contact_email, contact_phone, contact_method } = body;
if (!social_event_id || !guests) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Try to authenticate, but allow guest submissions
let userId: number | null = null;
try {
const session = await requireAuth();
userId = session.id;
} catch {
// Guest submission - require contact info
if (!contact_name || !contact_method) {
return NextResponse.json({ error: 'Contact name and method required for guest reservations' }, { status: 400 });
}
if (!['email', 'phone', 'whatsapp'].includes(contact_method)) {
return NextResponse.json({ error: 'Invalid contact method' }, { status: 400 });
}
}
const { rows } = await db.query(
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, social_event_id)
DO UPDATE SET guests = EXCLUDED.guests, notes = EXCLUDED.notes, status = 'pending'
`INSERT INTO social_event_reservations
(user_id, social_event_id, guests, notes, status, contact_name, contact_email, contact_phone, contact_method)
VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
RETURNING *`,
[session.id, parseInt(social_event_id, 10), parseInt(guests, 10), notes || '']
[userId, parseInt(social_event_id, 10), parseInt(guests, 10), notes || null, contact_name || null, contact_email || null, contact_phone || null, contact_method || null]
);
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
console.error('POST /api/social_event_reservations error:', error);
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: error.message || 'Failed to create reservation' }, { status: 500 });
}
}
+419 -5
View File
@@ -13,6 +13,19 @@ interface MenuItem {
featured_photo?: string | null;
}
interface Room {
id: number;
name: string;
}
interface User {
id: number;
username: string;
role: 'admin' | 'user';
first_name: string | null;
last_name: string | null;
}
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
@@ -20,15 +33,39 @@ const warm = '#a6683c';
export default function CoffeePage() {
const [items, setItems] = useState<MenuItem[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Order state
const [showOrderModal, setShowOrderModal] = useState(false);
const [selectedItems, setSelectedItems] = useState<Map<number, number>>(new Map());
const [orderType, setOrderType] = useState<'pickup' | 'dine_in' | 'room_delivery'>('pickup');
const [selectedRoom, setSelectedRoom] = useState<number | null>(null);
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [orderResult, setOrderResult] = useState<{ success: boolean; message: string } | null>(null);
useEffect(() => {
fetch('/api/menu')
.then(async (res) => {
Promise.all([
fetch('/api/menu').then(async (res) => {
if (!res.ok) throw new Error('Failed to load menu');
const json = await res.json();
setItems(json.data || []);
return res.json();
}),
fetch('/api/auth/session').then(async (res) => {
if (res.ok) return res.json();
return { user: null };
}),
fetch('/api/rooms').then(async (res) => {
if (!res.ok) return { rooms: [] };
return res.json();
}),
])
.then(([menuData, sessionData, roomsData]) => {
setItems(menuData.data || []);
setUser(sessionData.user || null);
setRooms(roomsData.rooms || []);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
@@ -37,12 +74,111 @@ export default function CoffeePage() {
const categories = Array.from(new Set(items.map((i) => i.category)));
const daily = items.find((i) => i.is_daily_special);
// Calculate order totals
const orderItems = items.filter((item) => selectedItems.has(item.id) && (selectedItems.get(item.id) || 0) > 0);
const subtotal = orderItems.reduce((sum, item) => {
const qty = selectedItems.get(item.id) || 0;
return sum + parseFloat(item.price) * qty;
}, 0);
const serviceFee = orderType === 'room_delivery' ? subtotal * 0.1 : 0;
const total = subtotal + serviceFee;
const updateQuantity = (itemId: number, delta: number) => {
setSelectedItems((prev) => {
const newMap = new Map(prev);
const current = newMap.get(itemId) || 0;
const newQty = Math.max(0, current + delta);
if (newQty === 0) {
newMap.delete(itemId);
} else {
newMap.set(itemId, newQty);
}
return newMap;
});
};
const handleSubmitOrder = async () => {
if (selectedItems.size === 0) {
alert('Please select at least one item');
return;
}
if (orderType === 'room_delivery' && !selectedRoom) {
alert('Please select a room for delivery');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: Array.from(selectedItems.entries()).map(([menu_item_id, quantity]) => ({
menu_item_id,
quantity,
})),
order_type: orderType,
room_id: orderType === 'room_delivery' ? selectedRoom : null,
notes: notes || null,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to place order');
}
setOrderResult({ success: true, message: data.message });
setSelectedItems(new Map());
setNotes('');
} catch (err) {
setOrderResult({ success: false, message: err instanceof Error ? err.message : 'Failed to place order' });
} finally {
setSubmitting(false);
}
};
const closeModal = () => {
setShowOrderModal(false);
setOrderResult(null);
};
return (
<main style={{ padding: 'clamp(1rem, 5vw, 2rem)', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h1 style={{ fontSize: 'clamp(28px, 6vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.25rem' }}>Coffee &amp; Kitchen</h1>
<p style={{ color: '#555', marginBottom: '1.5rem', maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
<p style={{ color: '#555', marginBottom: 0, maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
Locally sourced coffee, fresh pastries, and seasonal plates.
</p>
</div>
<button
onClick={() => setShowOrderModal(true)}
style={{
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
color: 'white',
border: 'none',
padding: '0.75rem 1.5rem',
borderRadius: '8px',
fontSize: 'clamp(14px, 3vw, 16px)',
fontWeight: 600,
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(232,168,73,0.3)',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(232,168,73,0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(232,168,73,0.3)';
}}
>
Place Order
</button>
</div>
{loading && <p style={{ color: warm }}>Loading menu...</p>}
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
@@ -130,6 +266,284 @@ export default function CoffeePage() {
</div>
</section>
))}
{/* Order Modal */}
{showOrderModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
zIndex: 1000,
}}
onClick={closeModal}
>
<div
style={{
background: 'white',
borderRadius: '16px',
maxWidth: 'min(95vw, 600px)',
width: '100%',
maxHeight: '90vh',
overflow: 'auto',
padding: 'clamp(1rem, 4vw, 1.5rem)',
}}
onClick={(e) => e.stopPropagation()}
>
{orderResult ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div style={{ fontSize: '48px', marginBottom: '1rem' }}>
{orderResult.success ? '✓' : '✕'}
</div>
<h3 style={{ margin: '0 0 1rem', color: navy }}>{orderResult.success ? 'Order Placed!' : 'Error'}</h3>
<p style={{ color: '#555', marginBottom: '1.5rem' }}>{orderResult.message}</p>
<button
onClick={closeModal}
style={{
background: navy,
color: 'white',
border: 'none',
padding: '0.75rem 2rem',
borderRadius: '8px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Close
</button>
</div>
) : (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Place Your Order</h3>
{/* Order Type Selection */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Order Type</label>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{[
{ value: 'pickup', label: 'Pickup' },
{ value: 'dine_in', label: 'Dine In' },
{ value: 'room_delivery', label: 'Room Delivery (+10%)' },
].map((opt) => (
<button
key={opt.value}
onClick={() => setOrderType(opt.value as typeof orderType)}
disabled={opt.value === 'room_delivery' && !user}
style={{
flex: 1,
minWidth: '120px',
padding: '0.75rem 1rem',
border: `2px solid ${orderType === opt.value ? gold : '#ddd'}`,
borderRadius: '8px',
background: orderType === opt.value ? gold : 'white',
color: orderType === opt.value ? 'white' : opt.value === 'room_delivery' && !user ? '#aaa' : navy,
cursor: opt.value === 'room_delivery' && !user ? 'not-allowed' : 'pointer',
opacity: opt.value === 'room_delivery' && !user ? 0.5 : 1,
fontWeight: 500,
fontSize: '14px',
}}
>
{opt.label}
{opt.value === 'room_delivery' && !user && (
<div style={{ fontSize: '11px', marginTop: '2px' }}>Login required</div>
)}
</button>
))}
</div>
</div>
{/* Room Selection for Delivery */}
{orderType === 'room_delivery' && user && (
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Room</label>
<select
value={selectedRoom || ''}
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
style={{
width: '100%',
padding: '0.75rem',
border: '2px solid #ddd',
borderRadius: '8px',
fontSize: '16px',
}}
>
<option value="">Select a room...</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
</div>
)}
{/* Menu Items Selection */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Items</label>
<div style={{ maxHeight: '300px', overflow: 'auto', border: '2px solid #ddd', borderRadius: '8px' }}>
{categories.map((category) => (
<div key={category}>
<div style={{ background: '#f5f5f5', padding: '0.5rem 1rem', fontWeight: 600, color: navy, borderBottom: '1px solid #ddd' }}>
{category}
</div>
{items
.filter((i) => i.category === category)
.map((item) => {
const qty = selectedItems.get(item.id) || 0;
return (
<div
key={item.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.75rem 1rem',
borderBottom: '1px solid #eee',
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, color: navy }}>{item.name}</div>
<div style={{ color: warm, fontWeight: 600 }}>${item.price}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
onClick={() => updateQuantity(item.id, -1)}
disabled={qty === 0}
style={{
width: '28px',
height: '28px',
border: `2px solid ${qty > 0 ? navy : '#ddd'}`,
borderRadius: '50%',
background: 'white',
color: qty > 0 ? navy : '#aaa',
cursor: qty > 0 ? 'pointer' : 'not-allowed',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
</button>
<span style={{ minWidth: '24px', textAlign: 'center', fontWeight: 600 }}>{qty}</span>
<button
onClick={() => updateQuantity(item.id, 1)}
style={{
width: '28px',
height: '28px',
border: `2px solid ${navy}`,
borderRadius: '50%',
background: navy,
color: 'white',
cursor: 'pointer',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
+
</button>
</div>
</div>
);
})}
</div>
))}
</div>
</div>
{/* Notes */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Special Instructions (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any allergies, preferences, or special requests..."
style={{
width: '100%',
padding: '0.75rem',
border: '2px solid #ddd',
borderRadius: '8px',
fontSize: '14px',
minHeight: '80px',
resize: 'vertical',
}}
/>
</div>
{/* Order Summary */}
{selectedItems.size > 0 && (
<div style={{ background: cream, borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem' }}>
<h4 style={{ margin: '0 0 0.5rem', color: navy }}>Order Summary</h4>
{orderItems.map((item) => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px', marginBottom: '0.25rem' }}>
<span>{item.name} × {selectedItems.get(item.id)}</span>
<span>${(parseFloat(item.price) * (selectedItems.get(item.id) || 0)).toFixed(2)}</span>
</div>
))}
<div style={{ borderTop: '1px solid #ddd', marginTop: '0.5rem', paddingTop: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Subtotal</span>
<span>${subtotal.toFixed(2)}</span>
</div>
{orderType === 'room_delivery' && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: warm }}>
<span>Service Fee (10%)</span>
<span>${serviceFee.toFixed(2)}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, marginTop: '0.25rem', fontSize: '16px' }}>
<span>Total</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
</div>
)}
{/* Submit Button */}
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={closeModal}
style={{
flex: 1,
padding: '0.75rem',
border: '2px solid #ddd',
borderRadius: '8px',
background: 'white',
color: navy,
fontSize: '16px',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleSubmitOrder}
disabled={submitting || selectedItems.size === 0}
style={{
flex: 2,
padding: '0.75rem',
border: 'none',
borderRadius: '8px',
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
color: 'white',
fontSize: '16px',
fontWeight: 600,
cursor: submitting || selectedItems.size === 0 ? 'not-allowed' : 'pointer',
opacity: submitting || selectedItems.size === 0 ? 0.5 : 1,
}}
>
{submitting ? 'Placing Order...' : `Place Order • $${total.toFixed(2)}`}
</button>
</div>
</>
)}
</div>
</div>
)}
</main>
);
}
+524 -38
View File
@@ -2,6 +2,8 @@
import { useEffect, useState, useRef } from 'react';
import { formatDisplayDate } from '@/lib/date';
import RoomCalendar from '@/components/RoomCalendar';
import OptimizedImage from '@/components/OptimizedImage';
interface Room {
id: number;
@@ -11,6 +13,7 @@ interface Room {
photos: string[];
featured_photo: string | null;
active?: boolean;
amenities: Array<{ id: number; name: string; icon_svg: string }>;
}
interface RoomComment {
@@ -30,6 +33,7 @@ interface User {
role: 'admin' | 'user';
first_name: string | null;
last_name: string | null;
email?: string;
comments_disabled: boolean;
}
@@ -58,6 +62,15 @@ export default function RoomsPage() {
const [sending, setSending] = useState(false);
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({});
// Action modal states
const [activeModal, setActiveModal] = useState<'reserve' | 'message' | 'calendar' | 'review' | null>(null);
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
const [reservationDates, setReservationDates] = useState({ checkIn: '', checkOut: '' });
const [messageText, setMessageText] = useState('');
const [reviewText, setReviewText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [guestForm, setGuestForm] = useState({ name: '', contactMethod: 'email', contact: '' });
useEffect(() => {
fetch('/api/rooms')
.then(async (res) => {
@@ -139,13 +152,155 @@ export default function RoomsPage() {
const getPhoto = (room: Room, index: number): string | null => {
if (!room.photos || room.photos.length === 0) return null;
return room.photos[Math.min(index, room.photos.length - 1)] || null;
const photo = room.photos[Math.min(index, room.photos.length - 1)];
if (!photo) return null;
// If it's a URL with id= param, extract id; if it's base64, return as-is
if (photo.includes('id=')) {
return photo;
}
return photo;
};
const getPhotoId = (photo: string): number | null => {
if (!photo) return null;
if (photo.includes('id=')) {
const match = photo.match(/id=(\d+)/);
return match ? parseInt(match[1]) : null;
}
return null;
};
const getActiveIndex = (room: Room): number => {
return activePhotoByRoom[room.id] ?? 0;
};
const openModal = (type: 'reserve' | 'message' | 'calendar' | 'review', room: Room) => {
setSelectedRoom(room);
setActiveModal(type);
setReservationDates({ checkIn: '', checkOut: '' });
setMessageText('');
setReviewText('');
};
const closeModal = () => {
setActiveModal(null);
setSelectedRoom(null);
};
const handleReserve = async () => {
if (!selectedRoom || !reservationDates.checkIn || !reservationDates.checkOut) {
alert('Please select dates');
return;
}
// For guests, require name and contact
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
alert('Please provide your name and contact info');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/reservations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
room_id: selectedRoom.id,
check_in: reservationDates.checkIn,
check_out: reservationDates.checkOut,
...(user ? {} : {
guest_name: guestForm.name.trim(),
guest_contact_method: guestForm.contactMethod,
guest_contact: guestForm.contact.trim(),
}),
}),
});
if (res.ok) {
alert('Reservation request sent! You will receive a confirmation shortly.');
closeModal();
setReservationDates({ checkIn: '', checkOut: '' });
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
} else {
const err = await res.json();
alert(err.error || 'Failed to create reservation');
}
} catch {
alert('Failed to create reservation');
}
setSubmitting(false);
};
const handleSendMessage = async () => {
if (!selectedRoom || !messageText.trim()) {
alert('Please enter a message');
return;
}
// For guests, require name and contact
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
alert('Please provide your name and contact info');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
room_id: selectedRoom.id,
message: messageText.trim(),
...(user ? {} : {
guest_name: guestForm.name.trim(),
guest_contact_method: guestForm.contactMethod,
guest_contact: guestForm.contact.trim(),
}),
}),
});
if (res.ok) {
alert('Message sent to our team!');
closeModal();
setMessageText('');
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
} else {
const err = await res.json();
alert(err.error || 'Failed to send message');
}
} catch {
alert('Failed to send message');
}
setSubmitting(false);
};
const handleReview = async () => {
if (!selectedRoom || !reviewText.trim() || !user) {
alert('Please log in and enter a review');
return;
}
setSubmitting(true);
try {
const res = await fetch(`/api/rooms/${selectedRoom.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ text: reviewText.trim(), status: 'pending' }),
});
if (res.ok) {
alert('Review submitted for approval');
closeModal();
loadComments(selectedRoom.id);
} else {
const err = await res.json();
alert(err.error || 'Failed to submit review');
}
} catch {
alert('Failed to submit review');
}
setSubmitting(false);
};
const displayedRooms = rooms.filter((r) => r.active !== false);
return (
@@ -205,65 +360,56 @@ export default function RoomsPage() {
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
>
{/* ─── Main Image ─── */}
<div style={{ position: 'relative', width: '100%' }}>
<div style={{ position: 'relative', width: '100%', height: '300px' }}>
{activeSrc ? (
activeSrc.startsWith('data:') ? (
<img
src={activeSrc}
alt={room.name}
style={{
width: '100%',
height: '300px',
objectFit: 'cover',
display: 'block',
}}
style={{ width: '100%', height: '300px', objectFit: 'cover' }}
/>
) : (
<OptimizedImage
id={parseInt(activeSrc.split('id=')[1])}
alt={room.name}
size="medium"
lazy={true}
style={{ width: '100%', height: '300px' }}
/>
)
) : (
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
No photo
</div>
)}
{/* Like button overlay */}
{/* Like button with count */}
<button
onClick={() => toggleLike(room.id)}
style={{
position: 'absolute',
top: 12,
left: 12,
width: 40,
height: 40,
borderRadius: '50%',
border: 'none',
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.4)',
color: '#fff',
fontSize: '20px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '999px',
border: 'none',
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.5)',
color: '#fff',
fontSize: '14px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.15)'; }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
aria-label="Like this room"
>
{hasLiked ? '\u2764\uFE0F' : '\u2661'}
<span style={{ fontSize: '16px' }}>{hasLiked ? '❤️' : '♡'}</span>
<span>{totalLikes}</span>
</button>
{/* Like count */}
<div style={{
position: 'absolute',
bottom: 8,
right: 12,
background: hasLiked ? 'rgba(231,76,60,0.85)' : 'rgba(0,0,0,0.5)',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
padding: '4px 10px',
borderRadius: '999px',
}}>
{'\u2764\uFE0F'} {totalLikes}
</div>
</div>
{/* ─── Thumbnail Strip ─── */}
@@ -299,7 +445,21 @@ export default function RoomsPage() {
}}
>
{p ? (
<img src={p} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
p.startsWith('data:') ? (
<img
src={p}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<OptimizedImage
id={parseInt(p.split('id=')[1])}
alt=""
size="thumbnail"
lazy={true}
style={{ width: '100%', height: '100%' }}
/>
)
) : (
<div style={{ width: '100%', height: '100%', background: '#ddd' }} />
)}
@@ -311,13 +471,105 @@ export default function RoomsPage() {
{/* ─── Room Info ─── */}
<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>
<h2 style={{ margin: 0, color: navy, fontSize: '24px' }}>{room.name}</h2>
<p style={{ color: '#555', lineHeight: 1.5, flex: 1, marginTop: '0.5rem' }}>{room.description}</p>
{/* ─── Amenity Icons ─── */}
{room.amenities && room.amenities.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.75rem' }}>
{room.amenities.slice(0, 6).map((amenity) => (
<div
key={amenity.id}
title={amenity.name}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.35rem 0.6rem',
background: 'rgba(1,13,30,0.06)',
borderRadius: '999px',
fontSize: '12px',
color: navy,
whiteSpace: 'nowrap',
}}
>
{amenity.icon_svg ? (
<span
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '16px', height: '16px' }}
dangerouslySetInnerHTML={{ __html: amenity.icon_svg
.replace(/width="[^"]*"/g, '')
.replace(/height="[^"]*"/g, '')
.replace(/stroke="currentColor"/g, `stroke="${navy}"`)
.replace(/<svg/, '<svg style="width:100%;height:100%"')
}}
/>
) : null}
<span>{amenity.name}</span>
</div>
))}
{room.amenities.length > 6 && (
<span style={{ fontSize: '10px', color: '#777', alignSelf: 'center', padding: '0 0.5rem' }}>+{room.amenities.length - 6}</span>
)}
</div>
)}
<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>
{/* ─── Action Buttons ─── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginTop: '1rem' }}>
<button
onClick={() => openModal('message', room)}
style={{
padding: '0.6rem 0.5rem',
borderRadius: '10px',
border: `2px solid ${navy}`,
background: 'transparent',
color: navy,
fontWeight: 600,
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Message
</button>
<button
onClick={() => openModal('calendar', room)}
style={{
padding: '0.6rem 0.5rem',
borderRadius: '10px',
border: `2px solid ${navy}`,
background: 'transparent',
color: navy,
fontWeight: 600,
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Reserve
</button>
<button
onClick={() => openModal('review', room)}
style={{
padding: '0.6rem 0.5rem',
borderRadius: '10px',
border: `2px solid ${navy}`,
background: 'transparent',
color: navy,
fontWeight: 600,
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Review
</button>
</div>
{/* Comments button — cream */}
<button
onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
@@ -422,6 +674,240 @@ export default function RoomsPage() {
);
})}
</div>
{/* ─── Modals ─── */}
{activeModal && selectedRoom && (
<div
onClick={closeModal}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: '#fff',
borderRadius: '20px',
padding: '2rem',
maxWidth: '500px',
width: '100%',
maxHeight: '90vh',
overflow: 'auto',
}}
>
{/* Reserve Modal */}
{activeModal === 'reserve' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
{!user ? (
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to make a reservation.</p>
) : (
<>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date</label>
<input
type="date"
value={reservationDates.checkIn}
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
min={new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date</label>
<input
type="date"
value={reservationDates.checkOut}
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
</div>
</>
)}
</>
)}
{/* Message Modal */}
{activeModal === 'message' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Send a Message</h3>
<p style={{ color: '#555', marginBottom: '1rem' }}>Send a message about {selectedRoom.name} to our team.</p>
{!user && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
<input
type="text"
value={guestForm.name}
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
placeholder="Enter your name"
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
<select
value={guestForm.contactMethod}
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
<option value="phone">Phone</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
</label>
<input
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
value={guestForm.contact}
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
</div>
)}
{user && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
<strong>From:</strong> {user.first_name} {user.last_name} ({user.email})
</div>
)}
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="Your message..."
rows={5}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
/>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleSendMessage} disabled={submitting || !messageText.trim() || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Sending...' : 'Send Message'}</button>
</div>
</>
)}
{/* Reserve/Calendar Modal */}
{activeModal === 'calendar' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
<p style={{ color: '#555', marginBottom: '1rem' }}>Select your dates and we'll contact you to confirm.</p>
{!user && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
<input
type="text"
value={guestForm.name}
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
placeholder="Enter your name"
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
<select
value={guestForm.contactMethod}
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
<option value="phone">Phone</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
</label>
<input
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
value={guestForm.contact}
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
</div>
)}
{user && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
<strong>Reserving as:</strong> {user.first_name} {user.last_name} ({user.email})
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date *</label>
<input
type="date"
value={reservationDates.checkIn}
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
min={new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date *</label>
<input
type="date"
value={reservationDates.checkOut}
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Availability Calendar</label>
<RoomCalendar roomId={selectedRoom.id} />
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
</div>
</>
)}
{/* Review Modal */}
{activeModal === 'review' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Leave a Review</h3>
<p style={{ color: '#555', marginBottom: '1rem' }}>Share your experience with {selectedRoom.name}.</p>
{!user ? (
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to leave a review.</p>
) : (
<>
<textarea
value={reviewText}
onChange={(e) => setReviewText(e.target.value)}
placeholder="Your review or suggestion..."
rows={5}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
/>
<p style={{ fontSize: '12px', color: '#777', marginTop: '0.5rem' }}>Reviews are submitted for admin approval before being published.</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleReview} disabled={submitting || !reviewText.trim()} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Submitting...' : 'Submit Review'}</button>
</div>
</>
)}
</>
)}
</div>
</div>
)}
</main>
);
}
+15 -4
View File
@@ -4,11 +4,22 @@ import { Language, setLanguage, useLanguage } from '@/lib/i18n';
function Flag({ language }: { language: Language }) {
if (language === 'en') {
// Union Jack (UK flag) - simplified but recognizable
return (
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" aria-hidden="true">
<rect width="24" height="16" fill="white" />
<rect x="0" y="6" width="24" height="4" fill="#CE1124" />
<rect x="10" y="0" width="4" height="16" fill="#CE1124" />
<svg width="24" height="16" viewBox="0 0 60 30" fill="none" aria-hidden="true">
{/* Blue background */}
<rect width="60" height="30" fill="#012169"/>
{/* White diagonal saltire (St Andrew) */}
<path d="M0 0L60 30M60 0L0 30" stroke="white" strokeWidth="6"/>
{/* Red diagonal saltire (St Patrick) - offset */}
<path d="M0 0L30 15M60 0L30 15" stroke="#C8102E" strokeWidth="2"/>
<path d="M60 30L30 15M0 30L30 15" stroke="#C8102E" strokeWidth="2"/>
{/* White cross (St George + St Andrew) */}
<rect x="25" y="0" width="10" height="30" fill="white"/>
<rect x="0" y="10" width="60" height="10" fill="white"/>
{/* Red cross (St George) */}
<rect x="27" y="0" width="6" height="30" fill="#C8102E"/>
<rect x="0" y="12" width="60" height="6" fill="#C8102E"/>
</svg>
);
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { useEffect, useState } from 'react';
interface LikeButtonProps {
roomId: number;
}
export default function LikeButton({ roomId }: LikeButtonProps) {
const [liked, setLiked] = useState(false);
const [totalLikes, setTotalLikes] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/rooms/${roomId}/likes`)
.then(res => res.json())
.then(data => {
setLiked(data.likedByUser);
setTotalLikes(data.totalLikes);
setLoading(false);
})
.catch(() => setLoading(false));
}, [roomId]);
const toggleLike = async () => {
try {
const res = await fetch(`/api/rooms/${roomId}/likes`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
setLiked(data.liked);
setTotalLikes(data.totalLikes);
}
} catch (err) {
console.error('Failed to toggle like:', err);
}
};
if (loading) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.4rem 0.75rem',
background: 'rgba(0,0,0,0.04)',
borderRadius: '999px',
fontSize: '13px',
color: '#666'
}}>
...
</div>
);
}
return (
<button
onClick={toggleLike}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.4rem 0.75rem',
background: liked ? '#fee2e2' : 'rgba(0,0,0,0.04)',
border: 'none',
borderRadius: '999px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
color: liked ? '#dc2626' : '#666',
transition: 'all 0.2s ease',
}}
title={liked ? 'Unlike this room' : 'Like this room'}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill={liked ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{totalLikes > 0 && <span>{totalLikes}</span>}
</button>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ export default function Navbar() {
link.href = data.data;
})
.catch(() => {});
}, []);
}, [pathname]);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useState, useEffect, useRef } from 'react';
interface OptimizedImageProps {
id: number;
alt: string;
style?: React.CSSProperties;
className?: string;
size?: 'thumbnail' | 'medium' | 'full';
lazy?: boolean;
placeholder?: boolean;
}
export default function OptimizedImage({
id,
alt,
style,
className,
size = 'medium',
lazy = true,
placeholder = true,
}: OptimizedImageProps) {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(!lazy);
const [error, setError] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
// Intersection Observer for lazy loading
useEffect(() => {
if (!lazy || !imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin: '50px' }
);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, [lazy]);
const getUrl = () => {
const base = `/api/image?id=${id}`;
if (size === 'thumbnail') return `${base}&thumb=1`;
if (size === 'medium') return `${base}&medium=1`;
return base;
};
return (
<div
ref={imgRef}
className={className}
style={{
position: 'relative',
overflow: 'hidden',
background: placeholder ? '#f0f0f0' : undefined,
...style,
}}
>
{/* Placeholder shimmer */}
{placeholder && !loaded && !error && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(90deg, #f5f0e8 25%, #e8e4dc 50%, #f5f0e8 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s ease-in-out infinite',
zIndex: 1,
}}
/>
)}
{/* Error state */}
{error && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f5',
color: '#999',
fontSize: '14px',
}}
>
Failed to load
</div>
)}
{/* Actual image - only loads when in view */}
{inView && !error && (
<img
src={getUrl()}
alt={alt}
loading={lazy ? 'lazy' : 'eager'}
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: loaded ? 1 : 0,
transition: 'opacity 0.4s ease-out',
position: 'relative',
zIndex: 2,
}}
/>
)}
<style jsx global>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { useEffect, useState } from 'react';
interface RoomCalendarProps {
roomId: number;
}
interface AvailabilityDay {
date: string;
status: 'available' | 'booked' | 'maintenance';
}
const gold = '#E8A849';
const navy = '#010D1E';
export default function RoomCalendar({ roomId }: RoomCalendarProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [availability, setAvailability] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = new Date(year, month + 1, 0).toISOString().split('T')[0];
fetch(`/api/rooms/${roomId}/availability?start=${startDate}&end=${endDate}`)
.then((r) => r.json())
.then((data) => {
const map: Record<string, string> = {};
(data.days || []).forEach((d: AvailabilityDay) => {
map[d.date] = d.status;
});
setAvailability(map);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [roomId, currentMonth]);
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = new Date(year, month, 1).getDay();
return { daysInMonth, firstDayOfMonth };
};
const { daysInMonth, firstDayOfMonth } = getDaysInMonth(currentMonth);
const days = [];
// Empty cells for days before the first day of the month
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={`empty-${i}`} />);
}
// Days of the month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const status = availability[dateStr] || 'available';
const isToday = dateStr === new Date().toISOString().split('T')[0];
let bgColor = '#e8f5e9'; // green for available
let textColor = navy;
if (status === 'booked') {
bgColor = '#ffebee'; // red for booked
textColor = '#c23b22';
} else if (status === 'maintenance') {
bgColor = '#fff3e0'; // orange for maintenance
textColor = '#e65100';
}
days.push(
<div
key={day}
title={`${dateStr}: ${status}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '36px',
borderRadius: '8px',
background: bgColor,
color: textColor,
fontWeight: isToday ? 700 : 400,
border: isToday ? `2px solid ${gold}` : 'none',
fontSize: '13px',
}}
>
{day}
</div>
);
}
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
};
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
};
const monthName = currentMonth.toLocaleString('en-US', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<button onClick={prevMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>&larr;</button>
<span style={{ fontWeight: 600, color: navy }}>{monthName}</span>
<button onClick={nextMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>&rarr;</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '4px', textAlign: 'center' }}>
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
<div key={d} style={{ fontSize: '11px', fontWeight: 600, color: '#777', padding: '4px' }}>{d}</div>
))}
{days}
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', fontSize: '12px', justifyContent: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#e8f5e9', borderRadius: '2px' }} /> Available
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#ffebee', borderRadius: '2px' }} /> Booked
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#fff3e0', borderRadius: '2px' }} /> Maintenance
</div>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
// Audit logging for admin actions
// Records who did what to which entity, with before/after values
import { db } from './db';
export type AuditAction =
| 'create'
| 'update'
| 'delete'
| 'status_change'
| 'login'
| 'logout'
| 'password_change';
export type EntityType =
| 'reservation'
| 'private_event_reservation'
| 'social_event_reservation'
| 'room_reservation'
| 'user'
| 'room'
| 'menu_item'
| 'social_event'
| 'offer'
| 'config';
interface AuditLogEntry {
userId?: number;
action: AuditAction;
entityType: EntityType;
entityId?: number;
oldValues?: Record<string, unknown>;
newValues?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
/**
* Log an admin action to the audit log
*/
export async function auditLog(entry: AuditLogEntry): Promise<void> {
try {
await db.query(
`INSERT INTO audit_log
(user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
entry.userId || null,
entry.action,
entry.entityType,
entry.entityId || null,
entry.oldValues ? JSON.stringify(entry.oldValues) : null,
entry.newValues ? JSON.stringify(entry.newValues) : null,
entry.ipAddress || null,
entry.userAgent || null,
]
);
} catch (error) {
// Log but don't throw - audit failures shouldn't break the main operation
console.error('Failed to write audit log:', error);
}
}
/**
* Get client IP from request headers
*/
export function getAuditClientIp(request: Request): string | undefined {
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
return request.headers.get('x-real-ip') || undefined;
}
/**
* Get user agent from request
*/
export function getAuditUserAgent(request: Request): string | undefined {
return request.headers.get('user-agent') || undefined;
}
/**
* Helper to log a status change on a reservation
*/
export async function logStatusChange(
request: Request,
userId: number,
entityType: EntityType,
entityId: number,
oldStatus: string,
newStatus: string
): Promise<void> {
await auditLog({
userId,
action: 'status_change',
entityType,
entityId,
oldValues: { status: oldStatus },
newValues: { status: newStatus },
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
}
/**
* Helper to log a deletion
*/
export async function logDeletion(
request: Request,
userId: number,
entityType: EntityType,
entityId: number,
deletedValues: Record<string, unknown>
): Promise<void> {
await auditLog({
userId,
action: 'delete',
entityType,
entityId,
oldValues: deletedValues,
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
}
+150
View File
@@ -0,0 +1,150 @@
// Rate limiting for public API endpoints
// Uses in-memory store with sliding window - suitable for single-instance deployments
// For multi-instance deployments, consider using Redis or database-backed rate limiting
interface RateLimitEntry {
count: number;
resetAt: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
}
// Default configs for different endpoints
export const RATE_LIMITS: Record<string, RateLimitConfig> = {
'private-event-reservation': {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 5, // 5 submissions per hour per IP
},
'contact-form': {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 10,
},
'login': {
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 10,
},
// Admin endpoints - higher limits for legitimate admin use
'admin-private-events': {
windowMs: 60 * 1000, // 1 minute
maxRequests: 100, // 100 requests per minute
},
'admin-social-events': {
windowMs: 60 * 1000, // 1 minute
maxRequests: 100, // 100 requests per minute
},
};
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now();
rateLimitStore.forEach((entry, key) => {
if (entry.resetAt < now) {
rateLimitStore.delete(key);
}
});
}, 60 * 1000); // Clean every minute
/**
* Check if a request is rate limited
* @param endpointKey - The endpoint identifier (e.g., 'private-event-reservation')
* @param identifier - The client identifier (e.g., IP address)
* @returns { limited: boolean, remaining: number, resetAt: number }
*/
export function checkRateLimit(
endpointKey: string,
identifier: string
): { limited: boolean; remaining: number; resetAt: number; retryAfter?: number } {
const config = RATE_LIMITS[endpointKey];
if (!config) {
// No rate limit configured for this endpoint
return { limited: false, remaining: Infinity, resetAt: Date.now() + 60000 };
}
const key = `${endpointKey}:${identifier}`;
const now = Date.now();
const entry = rateLimitStore.get(key);
// If no entry or window expired, create new entry
if (!entry || entry.resetAt < now) {
const resetAt = now + config.windowMs;
rateLimitStore.set(key, { count: 1, resetAt });
return { limited: false, remaining: config.maxRequests - 1, resetAt };
}
// Check if limit exceeded
if (entry.count >= config.maxRequests) {
return {
limited: true,
remaining: 0,
resetAt: entry.resetAt,
retryAfter: Math.ceil((entry.resetAt - now) / 1000),
};
}
// Increment count
entry.count++;
return { limited: false, remaining: config.maxRequests - entry.count, resetAt: entry.resetAt };
}
/**
* Get client IP from request headers
* Handles X-Forwarded-For and X-Real-IP headers for reverse proxy setups
*/
export function getClientIp(request: Request): string {
// Check X-Forwarded-For header (common for reverse proxies)
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
// Take the first IP (original client) from the chain
return forwardedFor.split(',')[0].trim();
}
// Check X-Real-IP header (used by some proxies)
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp.trim();
}
// Fallback - this may be undefined in some environments
return 'unknown';
}
/**
* Middleware-style rate limit checker for API routes
* Returns null if allowed, or a Response object if rate limited
*/
export function withRateLimit(
request: Request,
endpointKey: string
): { allowed: true } | { allowed: false; response: Response } {
const clientIp = getClientIp(request);
const result = checkRateLimit(endpointKey, clientIp);
if (result.limited) {
return {
allowed: false,
response: new Response(
JSON.stringify({
error: 'Too many requests. Please try again later.',
retryAfter: result.retryAfter,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(result.retryAfter || 60),
'X-RateLimit-Limit': String(RATE_LIMITS[endpointKey]?.maxRequests || 0),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),
},
}
),
};
}
return { allowed: true };
}
+145
View File
@@ -20,6 +20,8 @@ export async function initSchema() {
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
data TEXT NOT NULL,
thumbnail TEXT,
medium TEXT,
category VARCHAR(100) DEFAULT 'other',
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
location_target VARCHAR(100) DEFAULT 'gallery',
@@ -27,6 +29,10 @@ export async function initSchema() {
);
`);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS medium TEXT`);
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS thumbnail TEXT`);
await db.query(`
CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY,
@@ -308,6 +314,145 @@ export async function initSchema() {
PRIMARY KEY (room_id, amenity_id)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_reservations (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
user_id INTEGER REFERENCES users(id),
check_in DATE NOT NULL,
check_out DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total_price DECIMAL(10,2),
guest_name VARCHAR(100),
guest_contact_method VARCHAR(20),
guest_contact VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_availability (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, date)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id),
user_id INTEGER REFERENCES users(id),
message TEXT NOT NULL,
guest_name VARCHAR(100),
guest_contact_method VARCHAR(20),
guest_contact VARCHAR(255),
read_by_admins BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`ALTER TABLE room_comments ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'approved'`);
// Add guest columns to room_reservations
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
await db.query(`ALTER TABLE room_reservations ALTER COLUMN user_id DROP NOT NULL`);
// Add guest columns to messages
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
await db.query(`ALTER TABLE messages ALTER COLUMN user_id DROP NOT NULL`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_reservations_room_dates ON reservations(room_id, check_in, check_out)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_availability_room_date ON room_availability(room_id, date)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC)`);
// Orders tables for coffee/kitchen
await db.query(`
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
order_type VARCHAR(20) NOT NULL,
room_id INTEGER REFERENCES rooms(id),
subtotal DECIMAL(10,2) NOT NULL,
service_fee DECIMAL(10,2) DEFAULT 0,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
menu_item_id INTEGER NOT NULL REFERENCES menu_items(id),
quantity INTEGER NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
total_price DECIMAL(10,2) NOT NULL
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS private_event_reservations (
id SERIAL PRIMARY KEY,
event_name VARCHAR(255) NOT NULL,
contact_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255),
contact_phone VARCHAR(50),
contact_method VARCHAR(20) NOT NULL CHECK (contact_method IN ('email', 'phone', 'whatsapp')),
event_date DATE,
event_time TIME,
guests INTEGER NOT NULL,
notes TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Add status column to social_event_reservations if it doesn't exist
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled'))`);
// Add contact columns to social_event_reservations
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255)`);
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255)`);
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50)`);
await db.query(`ALTER TABLE social_event_reservations ALTER COLUMN user_id DROP NOT NULL`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
// Audit log for admin actions
await db.query(`
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER,
old_values JSONB,
new_values JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`);
}
export async function migrateFromLegacyPlaces() {
+5 -1
View File
@@ -31,13 +31,17 @@ describe('auth helpers', () => {
it('authenticates a user with the stored password hash', async () => {
const passwordHash = await hashPassword('secret-password');
query.mockResolvedValueOnce({
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin' }],
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin', first_name: null, last_name: null, preferred_language: 'es', terms_accepted_at: null }],
});
await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({
id: 2,
username: 'bob',
role: 'admin',
first_name: null,
last_name: null,
preferred_language: 'es',
terms_accepted_at: null,
});
});
+7 -2
View File
@@ -20,13 +20,18 @@ describe('/api/rooms', () => {
});
it('returns active rooms', async () => {
// Mock rooms query
query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] });
// Mock amenities query
query.mockResolvedValueOnce({ rows: [] });
// Mock images query
query.mockResolvedValueOnce({ rows: [] });
const response = await GET();
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true }] });
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true, photos: [], amenities: [] }] });
});
it('creates a room for admins', async () => {
@@ -52,6 +57,6 @@ describe('/api/rooms', () => {
expect(requireRole).toHaveBeenCalledWith('admin');
expect(response.status).toBe(200);
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00' } });
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00', photos: [], amenity_ids: [] } });
});
});