Compare commits

...

76 Commits

Author SHA1 Message Date
muken 288767f6c0 Security: remove hardcoded credentials, require DB_PASSWORD, delete .bak file, warn on secrets template 2026-07-17 14:24:48 -05:00
muken 9286f3e323 feat: room status table view with all rooms 2026-07-03 00:34:43 -05:00
muken 4e514c2fbb feat: admins can create private events with max_guests
- Added is_private and max_guests columns to social_events table
- Updated SocialEvent type with new fields
- Modified /api/social_events to include private events for admins
- Merged Public/Private Events into single 'Events' section with tabs
- SocialEditor now supports private event creation with max_guests field
- Public frontend still only sees public events
2026-07-03 00:27:44 -05:00
muken bfc627f451 fix: use correct /api/auth/me endpoint for coffee page user detection 2026-07-03 00:23:04 -05:00
muken 60afc2349e feat: room assignments, kitchen dashboard, and order user tracking
- Add room_assignments table for assigning users to rooms with dates
- Auto-detect logged-in user's assigned room when ordering food
- Kitchen tab in admin: dedicated view for active orders with auto-refresh
- Room Assignments tab: assign registered users to rooms by date range
- Orders now show user info (username, name) for tracking
- Kitchen view groups orders by status (pending/preparing/ready)
- Messages section shows order notifications
2026-07-03 00:22:00 -05:00
muken 9557fa7d07 fix: logged-in users can now comment without entering personal info
- Fix user data extraction in rooms page (was looking for j.user instead of direct fields)
- Add email to /api/auth/me response for logged-in user display
- User object now properly populated with id, username, role, first_name, last_name, email, comments_disabled
- Comments section now correctly shows 'Posting as {name}' for logged-in users
- Reservations and messages already use user info when logged in, only require guest info for guests
2026-07-03 00:14:32 -05:00
muken 200784fa49 feat: add housekeeping fields to room status
- Add checkout time/date for current guest
- Add last_cleaned timestamp
- Add assigned_staff_id with staff dropdown
- Add priority (urgent/normal/low) for cleaning order
- Add issues_count for tracking problems
- Add is_vip flag for VIP guests
- Update RoomStatusSection UI to display and edit all new fields
- Auto-set last_cleaned when status changes to 'clean'
2026-07-03 00:13:19 -05:00
muken 0785facd9e fix: correct user authentication check in messages page
The /api/auth/me endpoint returns { authenticated: true, id, username, ... }
not { user: {...} }. Fixed the messages page to correctly parse the response.
2026-07-03 00:11:09 -05:00
muken 6c8d70d41c feat: add Room Status tab in admin panel for housekeeping management
- Add clean_status and notes columns to rooms table
- Create /api/admin/room-status endpoint for status tracking
- Add RoomStatusSection component with:
  - Visual status icons ( clean, 🧹 dirty, 🔧 maintenance)
  - Occupancy status (occupied/available)
  - Current guest display with payment status
  - Upcoming reservations preview
  - Inline status and notes editing
- Add 'Room Status' tab to admin navigation
2026-07-03 00:09:38 -05:00
muken 1e66e918ac feat: add messaging system with admin bulk messages and user conversations
- Add conversations and direct_messages tables to schema
- User messaging page at /messages (users can start conversations, see admin responses)
- Admin messaging page at /admin/messages (view all conversations, reply to users)
- Admin bulk messaging: send to all customers, specific users, or admins
- Mail icon in navbar for logged-in users (links to appropriate messaging page)
- Show first name of admin who responded in conversation view
- Clean build cache after middleware changes
2026-07-03 00:06:07 -05:00
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
muken c829e27bf6 feat: mobile-responsive coffee menu with fluid typography 2026-06-30 20:57:25 -05:00
muken c42b1c3cc8 perf: add sharp for image optimization, lazy loading on menu
- Added sharp for server-side image resizing
- Lazy loading and async decoding on menu images
- Added thumbnail column to images table
- Images API now generates 400x300 thumbnails on upload
2026-06-30 20:54:43 -05:00
muken 2288c8563d feat: show food photos in coffee menu 2026-06-30 00:34:01 -05:00
muken 04d5f8c727 fix: 'Ver Menú' button now links to /coffee page 2026-06-30 00:23:36 -05:00
muken e299c07912 feat: show menu item photo thumbnail in admin list 2026-06-30 00:22:07 -05:00
muken 3bdaee333b fix: contact page now fetches brand info from config
- Created contact API endpoint to store messages
- Added contact_messages table
- Contact page now displays phone, email, WhatsApp, and address from site config
2026-06-30 00:20:00 -05:00
muken ef2bfa5f05 fix: properly track featured_photo state in menu editor 2026-06-30 00:13:30 -05:00
muken 271457dfa5 fix: replace England flag with UK flag for English language 2026-06-30 00:09:25 -05:00
muken dcc996c64a fix: update location to Ecuadorian highlands 2026-06-30 00:06:53 -05:00
muken 9424e260c2 fix: remove vineyard references - property has no vineyard 2026-06-30 00:05:01 -05:00
muken b46d490be2 feat: add photo support to food menu items
- Add photos and featured_photo columns to menu_items table
- Update MenuEditor with photo upload functionality
- Add click-to-set-featured-photo interaction
- Update menu API endpoints to handle photos
2026-06-30 00:04:04 -05:00
muken f9c8d48631 fix: use Ecuador timezone (GMT-5) for all date displays across the app 2026-06-29 23:53:58 -05:00
muken 7b72994d4a fix: use Ecuador timezone (GMT-5) for all dates 2026-06-29 23:52:08 -05:00
muken d6ca208fc6 feat: compact homepage with CTA buttons, new contact page, remove large text section 2026-06-29 23:48:56 -05:00
muken 3c5f911f8f feat: show both Celsius and Fahrenheit in weather widget 2026-06-29 23:37:07 -05:00
muken d67759485a fix: menu reorder - use items state after drag, add logging 2026-06-29 21:46:48 -05:00
muken d753a3713f fix: menu drag-and-drop reorder using sorted array and correct sort_order 2026-06-29 21:41:12 -05:00
muken 2dc949553e fix: slide image upload - clear image_url when image_id set, fix slides.image_url column type to TEXT, add bed amenities (Queen, 2 Queen) 2026-06-29 21:38:27 -05:00
muken 91fce014fd feat: room amenities selection in room editor 2026-06-29 20:49:51 -05:00
muken 262cd4745c feat: room amenities with editable SVG icons 2026-06-29 20:45:56 -05:00
muken 0c00bfe23a feat: calendar strip smooth pause and fade edges 2026-06-29 20:39:59 -05:00
muken 8cc1d4bb96 fix: calendar strip NaN days and 3-day weather forecast 2026-06-29 20:38:14 -05:00
muken e0a82b945f moved icons 2026-06-29 20:28:33 -05:00
muken a2a0335b7d moved icos 2026-06-29 20:21:28 -05:00
muken 657af63e73 move widget 2026-06-29 20:07:08 -05:00
muken 6b7720d2d8 feat: users can change their own password via user portal 2026-06-29 18:09:54 -05:00
muken 765465348e feat: allow admins to change user passwords 2026-06-29 18:07:24 -05:00
muken 11d8f4a456 feat: add shimmer loading animation to rooms page 2026-06-28 23:45:40 -05:00
muken 25428e1246 style: reduce weather widget height and move lower 2026-06-28 23:43:11 -05:00
muken 59698686d5 feat: add weather widget to user portal below navbar 2026-06-28 23:36:29 -05:00
muken 291df16f1f feat: user portal with trips, wifi, welcome message, chat; admin trips & messages mgmt 2026-06-28 23:33:39 -05:00
muken 658a3f00a7 fix: remove wifi_password from public site-assets API - only show in logged-in user portal 2026-06-28 23:28:02 -05:00
muken 9f418d6b19 fix: slide editor upload button now works with useRef instead of nested label 2026-06-28 23:26:21 -05:00
muken a892e9e132 feat: enable weather widget for Otavalo using Open-Meteo API (free, no key required) 2026-06-28 23:23:50 -05:00
muken a1d88af78b feat: add footer configuration (address, phone, email, social URLs, tagline) to admin panel 2026-06-28 23:21:41 -05:00
muken abf46b9075 fix: image upload returns correct data structure for all editors 2026-06-28 23:18:27 -05:00
muken 6b0db0e3cf fix: change admin portal title to Hosteria La Huasca 2026-06-28 23:14:06 -05:00
muken 4d2c26d3af fix: admin data loading - extract data from API response 2026-06-28 23:12:07 -05:00
muken cac82e6d9d fix: admin panel save issues - POST/PUT URLs, users list, image upload 2026-06-28 23:01:13 -05:00
muken 7f81833936 fix: remove middleware redirect, let login page handle auth check 2026-06-28 22:46:57 -05:00
muken f99da73118 fix: login redirect, admin auth guard, JWT_SECRET env 2026-06-28 22:41:08 -05:00
muken c2c2217028 fix: calendar date parsing, login credentials hint, flag z-index overlap 2026-06-28 22:33:55 -05:00
muken 6121c76d97 fix: add calendar_events.active, expand seed coverage, and refresh local init script 2026-06-28 22:21:08 -05:00
muken f771ad751b Auto‑merge remote changes, keep our new globals.css 2026-06-28 20:35:04 -05:00
muken d8dcd7eadb Rewrite globals.css with spacing, dark‑mode, and focus styles 2026-06-28 20:35:04 -05:00
91 changed files with 11505 additions and 2073 deletions
+66
View File
@@ -0,0 +1,66 @@
/* 1. Add spacing vars
Added for consistent responsive spacing
*/
:root {
--sp-base: 0.5rem;
--sp-sm: 1rem;
--sp-md: 1.5rem;
--sp-lg: 3rem;
}
/* Existing color vars remain */
:root {
--gold: #E8A849;
--cream: #f5f0e8;
--navy: #001321;
--ink: #1a1a1a;
--warm: #a6683c;
--bg: #FAF7F0;
--surface: #e8eff3;
}
/* 2. Responsive adjustments */
@media (min-width: 640px) {
.navbar {
padding: var(--sp-sm) var(--sp-lg);
}
}
/* 3. Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--ink: #faf7f0;
}
}
/* 4. Focus visible */
button:focus-visible,
a:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
/* 5. Navbar link reuse */
.navbar-link {
display: inline-block;
color: rgba(245, 240, 232, 0.85);
text-decoration: none;
font-size: 14px;
padding: 6px 0;
border-bottom: 1.5px solid transparent;
transition: color 0.15s ease, border-color 0.15s ease;
}
.navbar-link.active {
color: var(--gold);
border-color: var(--gold);
}
.navbar-link:hover {
color: var(--cream);
}
/* 6. Typography helpers */
h1 { font-size: 2.5rem; font-weight: 700; }
h2 { font-size: 2rem; font-weight: 600; }
+43 -20
View File
@@ -1,14 +1,8 @@
#!/bin/bash
# Database initialization script for La Huasca (local version)
# This script creates the necessary tables and seeds initial data
set -e # Exit on any error
set -e
echo "Initializing La Huasca database (local version)..."
# Execute the schema directly
echo "Executing database schema..."
sudo -u postgres psql -d lahuasca << 'EOF'
-- Create all tables
CREATE TABLE IF NOT EXISTS users (
@@ -19,6 +13,11 @@ CREATE TABLE IF NOT EXISTS users (
comments_disabled BOOLEAN DEFAULT FALSE,
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
country VARCHAR(100),
preferred_language VARCHAR(10) DEFAULT 'es',
terms_accepted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
@@ -123,7 +122,7 @@ CREATE TABLE IF NOT EXISTS slides (
title VARCHAR(255),
subtitle TEXT,
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
image_url VARCHAR(500),
image_url TEXT,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
@@ -171,7 +170,19 @@ CREATE TABLE IF NOT EXISTS calendar_events (
title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(20) NOT NULL
color VARCHAR(20) NOT NULL,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS terms_of_service (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version VARCHAR(20) DEFAULT '1.0',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_sessions (
@@ -181,31 +192,45 @@ CREATE TABLE IF NOT EXISTS user_sessions (
created_at TIMESTAMP DEFAULT NOW()
);
-- Add missing columns if they don't exist
CREATE TABLE IF NOT EXISTS availability (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available' CHECK (status IN ('available', 'booked', 'maintenance')),
price NUMERIC(10,2),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, date)
);
-- Add missing columns
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
ALTER TABLE users ADD COLUMN IF NOT EXISTS country VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT 'es';
ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMP;
-- Insert initial data
-- Default admin user (password: admin)
-- Fix slides.image_url to TEXT (was VARCHAR(500), too short for base64)
ALTER TABLE slides ALTER COLUMN image_url TYPE TEXT;
-- Seed initial data
INSERT INTO users (username, password_hash, role) VALUES
('admin', '$2a$10$8K1p/a0dhrxiowP.dnkgNORTWgdEDHn5L2/xjpEWuC.QQv4rKO9jO', 'admin')
ON CONFLICT (username) DO NOTHING;
-- Default config values
INSERT INTO config (key, value) VALUES
('wifi_password', 'LaHuascaGuest2026'),
('tagline', 'Hotel · Restaurant · Andean Highlands')
ON CONFLICT (key) DO NOTHING;
-- Sample offers
INSERT INTO offers (title, description, active, sort_order) VALUES
('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.', true, 1),
('Sunset Dinner', 'Three-course menu on the terrace every Friday.', true, 2)
INSERT INTO offers (title, description) VALUES
('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.'),
('Sunset Dinner', 'Three-course menu on the terrace every Friday.')
ON CONFLICT DO NOTHING;
-- Sample benefits
INSERT INTO benefits (text) VALUES
('Priority booking for peak weekends'),
('Late checkout on availability'),
@@ -214,8 +239,6 @@ INSERT INTO benefits (text) VALUES
ON CONFLICT DO NOTHING;
EOF
# Verify that the tables were created
echo "Verifying database tables..."
sudo -u postgres psql -d lahuasca -c "\dt"
echo "Database initialization completed successfully!"
+3 -1
View File
@@ -180,7 +180,9 @@ CREATE TABLE IF NOT EXISTS calendar_events (
title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(20) NOT NULL
color VARCHAR(20) NOT NULL,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_sessions (
+1
View File
@@ -1,3 +1,4 @@
# WARNING: Replace CHANGE_ME_* values before applying
apiVersion: v1
kind: Secret
metadata:
+1154 -48
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -13,10 +13,11 @@
"@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3",
"jose": "^5.6.3",
"next": "14.2.28",
"next": "^14.2.35",
"pg": "^8.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"sharp": "^0.35.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -28,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);
+115
View File
@@ -0,0 +1,115 @@
/**
* 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: (() => {
const p = process.env.DB_PASSWORD;
if (!p) throw new Error('DB_PASSWORD env var is required');
return p;
})(),
});
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;
+267
View File
@@ -0,0 +1,267 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
type TermsData = {
id?: number;
language: string;
title: string;
content: string;
version: string;
};
export default function AcceptTermsPage() {
const [preferredLanguage, setPreferredLanguage] = useState('es');
const [terms, setTerms] = useState<TermsData | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const router = useRouter();
// Check if logged in and needs terms acceptance
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then(data => {
if (!data.authenticated) {
router.push('/login');
} else if (data.terms_accepted_at) {
router.push(data.role === 'admin' ? '/admin' : '/user');
} else {
setChecking(false);
}
})
.catch(() => {
router.push('/login');
});
}, [router]);
// Fetch terms when language changes
useEffect(() => {
fetch(`/api/terms?language=${preferredLanguage}`)
.then(r => r.json())
.then(data => {
if (data.data) setTerms(data.data);
})
.catch(console.error);
}, [preferredLanguage]);
const handleAccept = async () => {
if (!acceptTerms) {
setError(preferredLanguage === 'es'
? 'Debes aceptar los términos de servicio'
: 'You must accept the terms of service');
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
preferred_language: preferredLanguage,
accept_terms: true,
}),
credentials: 'same-origin',
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Failed to accept terms');
setLoading(false);
return;
}
// Fetch user role to redirect appropriately
const meRes = await fetch('/api/auth/me', { credentials: 'include' });
const meData = await meRes.json();
router.push(meData.role === 'admin' ? '/admin' : '/user');
} catch (err) {
setError('An error occurred. Please try again.');
setLoading(false);
}
};
if (checking) {
return (
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#FAF7F0' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '32px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '16px', color: '#555' }}>Loading...</div>
</div>
</main>
);
}
const accent = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
return (
<main
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
background: '#FAF7F0',
}}
>
<div
style={{
width: '100%',
maxWidth: '40rem',
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
padding: '2.5rem',
background: cream,
borderRadius: '16px',
boxShadow: '0 6px 18px rgba(14,47,71,0.12)',
}}
>
{/* Language toggle */}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setPreferredLanguage('es')}
style={{
padding: '0.5rem 1rem',
background: preferredLanguage === 'es' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'es' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: preferredLanguage === 'es' ? 600 : 400,
}}
>
🇪🇨 Español
</button>
<button
type="button"
onClick={() => setPreferredLanguage('en')}
style={{
padding: '0.5rem 1rem',
background: preferredLanguage === 'en' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'en' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: preferredLanguage === 'en' ? 600 : 400,
}}
>
🇬🇧 English
</button>
</div>
<h1
style={{
margin: '0',
fontSize: 'clamp(24px, 4vw, 36px)',
fontWeight: 400,
fontStyle: 'italic',
color: navy,
textAlign: 'center',
}}
>
{preferredLanguage === 'es' ? 'Términos de Servicio' : 'Terms of Service'}
</h1>
<p style={{ margin: '0 0 1rem', color: '#555', textAlign: 'center' }}>
{preferredLanguage === 'es'
? 'Por favor, revisa y acepta nuestros términos de servicio para continuar.'
: 'Please review and accept our terms of service to continue.'}
</p>
{/* Terms content */}
<div
style={{
background: '#fff',
borderRadius: '12px',
padding: '1.5rem',
border: '1px solid rgba(1,13,30,0.18)',
maxHeight: '300px',
overflow: 'auto',
}}
>
{terms ? (
<>
<h3 style={{ margin: '0 0 1rem', fontSize: '18px', color: navy }}>{terms.title}</h3>
<p style={{ whiteSpace: 'pre-wrap', margin: 0, fontSize: '14px', lineHeight: 1.6, color: '#555' }}>
{terms.content}
</p>
</>
) : (
<p style={{ margin: 0, color: '#777', textAlign: 'center' }}>
{preferredLanguage === 'es' ? 'Cargando términos...' : 'Loading terms...'}
</p>
)}
</div>
{/* Accept checkbox */}
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
cursor: 'pointer',
fontSize: '15px',
color: '#333',
}}
>
<input
type="checkbox"
checked={acceptTerms}
onChange={(e) => setAcceptTerms(e.target.checked)}
style={{ marginTop: '0.25rem' }}
/>
<span>
{preferredLanguage === 'es'
? 'He leído y acepto los términos de servicio y la política de privacidad de Hostería La Huasca.'
: 'I have read and accept the terms of service and privacy policy of Hostería La Huasca.'}
</span>
</label>
{error && <p style={{ color: '#c23b22', margin: 0, fontSize: '14px' }}>{error}</p>}
<button
onClick={handleAccept}
disabled={!acceptTerms || loading}
style={{
padding: '1rem',
background: acceptTerms && !loading ? accent : '#ccc',
color: navy,
border: 'none',
borderRadius: '999px',
cursor: acceptTerms && !loading ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: 600,
transition: 'transform .12s, box-shadow .15s',
opacity: loading ? 0.7 : 1,
}}
onMouseEnter={(e) => {
if (acceptTerms && !loading) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 6px 18px rgba(232,168,73,0.35)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{loading
? (preferredLanguage === 'es' ? 'Guardando...' : 'Saving...')
: (preferredLanguage === 'es' ? 'Continuar' : 'Continue')}
</button>
</div>
</main>
);
}
+383
View File
@@ -0,0 +1,383 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { formatDisplayDateTime } from '@/lib/date';
type User = { id: number; username: string; first_name: string; last_name: string; role: string; email: string };
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
type Conversation = {
id: number;
subject: string | null;
last_message: string | null;
last_message_at: string | null;
message_count: number;
unread_count: number;
participants: Participant[];
};
export default function AdminMessagesPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [selectedConv, setSelectedConv] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [newMessage, setNewMessage] = useState('');
const [showBulk, setShowBulk] = useState(false);
const [bulkSubject, setBulkSubject] = useState('');
const [bulkContent, setBulkContent] = useState('');
const [bulkType, setBulkType] = useState<'all_customers' | 'specific_users' | 'admins'>('all_customers');
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
if (selectedConv) {
fetchMessages(selectedConv);
}
}, [selectedConv]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const fetchData = async () => {
try {
const [convRes, usersRes] = await Promise.all([
fetch('/api/admin/conversations'),
fetch('/api/admin/users'),
]);
if (convRes.ok) {
const data = await convRes.json();
setConversations(data.conversations || []);
}
if (usersRes.ok) {
const data = await usersRes.json();
setUsers(data.users || []);
}
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setLoading(false);
}
};
const fetchMessages = async (convId: number) => {
try {
const res = await fetch(`/api/conversations/${convId}`);
if (res.ok) {
const data = await res.json();
setMessages(data.messages || []);
setParticipants(data.participants || []);
}
} catch (err) {
console.error('Failed to fetch messages:', err);
}
};
const sendMessage = async () => {
if (!newMessage.trim() || !selectedConv) return;
try {
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newMessage.trim() }),
});
if (res.ok) {
const data = await res.json();
setMessages(prev => [...prev, data.message]);
setNewMessage('');
fetchData();
}
} catch (err) {
console.error('Failed to send message:', err);
}
};
const sendBulkMessage = async () => {
if (!bulkContent.trim()) return;
if (bulkType === 'specific_users' && selectedUsers.length === 0) {
alert('Please select at least one user');
return;
}
setSending(true);
try {
const res = await fetch('/api/admin/bulk-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: bulkSubject || null,
content: bulkContent.trim(),
recipient_type: bulkType,
recipient_ids: bulkType === 'specific_users' ? selectedUsers : undefined,
}),
});
if (res.ok) {
const data = await res.json();
alert(`Message sent to ${data.sent_count} users`);
setShowBulk(false);
setBulkSubject('');
setBulkContent('');
setSelectedUsers([]);
fetchData();
} else {
const err = await res.json();
alert(err.error || 'Failed to send');
}
} catch (err) {
console.error('Failed to send bulk message:', err);
alert('Failed to send message');
} finally {
setSending(false);
}
};
const getDisplayName = (p: Participant | User) => {
return p.first_name || p.username;
};
const customerConvs = conversations.filter(c => c.participants.some(p => p.role === 'customer'));
const adminConvs = conversations.filter(c => c.participants.every(p => p.role === 'admin'));
if (loading) {
return (
<div className="p-8 flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">Messages</h1>
<button
onClick={() => setShowBulk(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send Bulk Message
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Conversations Panel */}
<div className="lg:col-span-1 bg-white rounded-lg shadow">
<div className="p-4 border-b bg-gray-50 font-medium">Customer Conversations</div>
<div className="divide-y max-h-[300px] overflow-y-auto">
{customerConvs.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No customer conversations</div>
) : (
customerConvs.map(conv => (
<div
key={conv.id}
onClick={() => setSelectedConv(conv.id)}
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
>
<div className="flex justify-between items-start">
<div className="font-medium text-sm">
{conv.participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ')}
</div>
{conv.unread_count > 0 && (
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
{conv.unread_count}
</span>
)}
</div>
{conv.last_message && (
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
)}
<div className="text-xs text-gray-400 mt-1">
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
</div>
</div>
))
)}
</div>
<div className="p-4 border-b bg-gray-50 font-medium mt-4">Admin Conversations</div>
<div className="divide-y max-h-[300px] overflow-y-auto">
{adminConvs.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No admin conversations</div>
) : (
adminConvs.map(conv => (
<div
key={conv.id}
onClick={() => setSelectedConv(conv.id)}
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
>
<div className="font-medium text-sm">
{conv.subject || conv.participants.map(getDisplayName).join(', ')}
</div>
{conv.last_message && (
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
)}
</div>
))
)}
</div>
</div>
{/* Messages Panel */}
<div className="lg:col-span-2 bg-white rounded-lg shadow flex flex-col h-[700px]">
{selectedConv ? (
<>
<div className="p-4 border-b bg-gray-50">
<div className="font-medium">
Conversation with: {participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ') || 'Admins'}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'admin' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] ${msg.role === 'admin' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium">
{msg.first_name || msg.username}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${msg.role === 'admin' ? 'bg-blue-500' : 'bg-gray-200 text-gray-600'}`}>
{msg.role}
</span>
</div>
<div>{msg.content}</div>
<div className={`text-xs mt-1 ${msg.role === 'admin' ? 'text-blue-200' : 'text-gray-400'}`}>
{formatDisplayDateTime(msg.created_at)}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="p-3 border-t bg-gray-50">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="Type a reply..."
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={sendMessage}
disabled={!newMessage.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
Select a conversation to view messages
</div>
)}
</div>
</div>
{/* Bulk Message Modal */}
{showBulk && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Send Bulk Message</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Recipients</label>
<select
value={bulkType}
onChange={e => setBulkType(e.target.value as typeof bulkType)}
className="w-full border rounded-lg px-3 py-2"
>
<option value="all_customers">All Customers</option>
<option value="specific_users">Specific Users</option>
<option value="admins">Admins Only</option>
</select>
</div>
{bulkType === 'specific_users' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Select Users</label>
<div className="max-h-48 overflow-y-auto border rounded-lg p-2 space-y-1">
{users.filter(u => u.role === 'customer').map(user => (
<label key={user.id} className="flex items-center gap-2 p-1 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedUsers.includes(user.id)}
onChange={e => {
if (e.target.checked) {
setSelectedUsers(prev => [...prev, user.id]);
} else {
setSelectedUsers(prev => prev.filter(id => id !== user.id));
}
}}
className="rounded"
/>
<span className="text-sm">{user.first_name || user.username} ({user.email})</span>
</label>
))}
</div>
<div className="text-xs text-gray-500 mt-1">{selectedUsers.length} selected</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
<input
type="text"
value={bulkSubject}
onChange={e => setBulkSubject(e.target.value)}
placeholder="Message subject"
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
<textarea
value={bulkContent}
onChange={e => setBulkContent(e.target.value)}
placeholder="Type your message..."
rows={5}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowBulk(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={sendBulkMessage}
disabled={!bulkContent.trim() || sending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{sending ? 'Sending...' : 'Send Message'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+2181 -118
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Admin: Send bulk message to customers
export async function POST(request: NextRequest) {
try {
const admin = await requireRole('admin');
const { subject, content, recipient_type, recipient_ids } = await request.json();
if (!content || content.trim().length === 0) {
return NextResponse.json({ error: 'Message content is required' }, { status: 400 });
}
if (!['all_customers', 'specific_users', 'admins'].includes(recipient_type)) {
return NextResponse.json({ error: 'Invalid recipient type' }, { status: 400 });
}
// Get recipients based on type
let recipientList: number[] = [];
if (recipient_type === 'all_customers') {
const { rows } = await db.query(
"SELECT id FROM users WHERE role = 'customer'"
);
recipientList = rows.map((r: any) => r.id);
} else if (recipient_type === 'admins') {
const { rows } = await db.query(
"SELECT id FROM users WHERE role = 'admin'"
);
recipientList = rows.map((r: any) => r.id);
} else if (recipient_type === 'specific_users' && recipient_ids) {
recipientList = recipient_ids;
}
if (recipientList.length === 0) {
return NextResponse.json({ error: 'No recipients found' }, { status: 400 });
}
// Create bulk message record
const { rows: bulkRows } = await db.query(
'INSERT INTO bulk_messages (sender_id, subject, content, recipient_type) VALUES ($1, $2, $3, $4) RETURNING *',
[admin.id, subject || null, content, recipient_type]
);
const bulkMessage = bulkRows[0];
// Create recipient records
for (const userId of recipientList) {
await db.query(
'INSERT INTO bulk_message_recipients (bulk_message_id, user_id) VALUES ($1, $2)',
[bulkMessage.id, userId]
);
}
// Create individual conversations for each recipient if they don't exist
for (const userId of recipientList) {
// Check if there's an existing conversation between this admin and user
const { rows: existingConv } = await db.query(`
SELECT c.id FROM conversations c
JOIN conversation_participants cp1 ON c.id = cp1.conversation_id
JOIN conversation_participants cp2 ON c.id = cp2.conversation_id
WHERE cp1.user_id = $1 AND cp2.user_id = $2
GROUP BY c.id
HAVING COUNT(DISTINCT cp1.user_id) = 1 AND COUNT(DISTINCT cp2.user_id) = 1
`, [admin.id, userId]);
let conversationId: number;
if (existingConv.length > 0) {
conversationId = existingConv[0].id;
} else {
// Create new conversation
const { rows: convRows } = await db.query(
'INSERT INTO conversations (subject, created_by) VALUES ($1, $2) RETURNING id',
[subject || 'Admin Message', admin.id]
);
conversationId = convRows[0].id;
// Add participants
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2), ($1, $3)',
[conversationId, admin.id, userId]
);
}
// Add the message to the conversation
await db.query(
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3)',
[conversationId, admin.id, content]
);
// Update conversation timestamp
await db.query(
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
[conversationId]
);
}
return NextResponse.json({
success: true,
sent_count: recipientList.length,
bulk_message: bulkMessage
});
} catch (error) {
console.error('Send bulk message error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to send bulk message') },
{ status: 500 }
);
}
}
+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 });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Admin: Get all conversations
export async function GET() {
try {
await requireRole('admin');
// Get all conversations with participant info and last message
const { rows } = await db.query(`
SELECT
c.id,
c.subject,
c.created_at,
c.updated_at,
(SELECT content FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message,
(SELECT created_at FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_at,
(SELECT COUNT(*) FROM direct_messages WHERE conversation_id = c.id) as message_count
FROM conversations c
ORDER BY c.updated_at DESC
`);
// Get participants for each conversation
const conversations = await Promise.all(rows.map(async (conv: any) => {
const { rows: participants } = await db.query(`
SELECT u.id, u.username, u.first_name, u.last_name, u.role
FROM conversation_participants cp
JOIN users u ON cp.user_id = u.id
WHERE cp.conversation_id = $1
ORDER BY u.role DESC, u.first_name
`, [conv.id]);
// Count unread for admins (messages from customers)
const { rows: unreadRows } = await db.query(`
SELECT COUNT(*) as unread
FROM direct_messages dm
JOIN users u ON dm.sender_id = u.id
WHERE dm.conversation_id = $1
AND u.role != 'admin'
AND dm.created_at > COALESCE(
(SELECT joined_at FROM conversation_participants WHERE conversation_id = $1 AND user_id = (SELECT id FROM users WHERE role = 'admin' LIMIT 1)),
'1970-01-01'::timestamp
)
`, [conv.id]);
return {
...conv,
participants,
unread_count: parseInt(unreadRows[0]?.unread || '0')
};
}));
return NextResponse.json({ conversations });
} catch (error) {
console.error('Admin get conversations error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get conversations') },
{ status: 500 }
);
}
}
+51 -5
View File
@@ -1,6 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
import sharp from 'sharp';
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 { thumbnail: base64Data, medium: base64Data, data: base64Data };
}
const ext = matches[1];
const buffer = Buffer.from(matches[2], '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) {
try {
@@ -9,7 +52,7 @@ export async function GET(request: NextRequest) {
const category = searchParams.get('category');
const roomId = searchParams.get('room_id');
let query = 'SELECT id, filename, data, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC';
let query = 'SELECT id, filename, data, thumbnail, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC';
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
@@ -28,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 });
@@ -48,12 +91,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
}
// Generate multiple sizes for responsive images (thumbnail, medium, full)
const sizes = await generateImageSizes(data);
const { rows } = await db.query(
'INSERT INTO images (filename, data, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5) RETURNING id, filename, data, category, room_id, location_target, uploaded_at',
[filename, data, 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, data, thumbnail, medium, 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({ image: rows[0] });
return NextResponse.json({ data: rows[0] });
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
await requireRole('admin');
const userId = request.nextUrl.searchParams.get('user_id');
if (userId) {
const { rows } = await db.query(
`SELECT m.id, m.user_id, m.sender_role, m.message, m.created_at, u.username
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.user_id = $1
ORDER BY m.created_at ASC`,
[userId]
);
return NextResponse.json({ messages: rows });
}
const { rows } = await db.query(
`SELECT DISTINCT ON (m.user_id) m.user_id, m.created_at, u.username, m.message
FROM messages m
JOIN users u ON m.user_id = u.id
ORDER BY m.user_id, m.created_at DESC`
);
return NextResponse.json({ threads: rows });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('GET /api/admin/messages error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const { user_id, message } = await request.json();
if (!user_id || !message || typeof message !== 'string') {
return NextResponse.json({ error: 'user_id and message are required' }, { status: 400 });
}
const { rows } = await db.query(
`INSERT INTO messages (user_id, sender_role, message) VALUES ($1, 'admin', $2) RETURNING *`,
[user_id, message.trim()]
);
return NextResponse.json({ message: rows[0] });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('POST /api/admin/messages error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+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 });
}
}
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { rows } = await db.query(`
SELECT r.*, u.username, u.first_name, u.last_name
FROM reservations r
LEFT JOIN users u ON r.user_id = u.id
ORDER BY r.date DESC, r.time DESC
`);
return NextResponse.json({ reservations: rows });
} catch (error) {
console.error('Get restaurant reservations error:', error);
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
}
}
export async function DELETE(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 id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Reservation ID required' }, { status: 400 });
}
await db.query('DELETE FROM reservations WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete restaurant reservation error:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
@@ -0,0 +1,118 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
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 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 room reservations error:', error);
return NextResponse.json({ error: 'Failed to get reservations' }, { 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 body = await request.json();
const { id, status } = body;
if (!id || !status) {
return NextResponse.json({ error: 'ID and status required' }, { status: 400 });
}
await db.query(
'UPDATE room_reservations SET status = $1, updated_at = NOW() WHERE id = $2',
[status, id]
);
// If cancelling, free up the blocked dates
if (status === 'cancelled') {
const { rows: reservation } = await db.query(
'SELECT room_id, check_in, check_out FROM room_reservations WHERE id = $1',
[id]
);
if (reservation.length > 0) {
const { room_id, check_in, check_out } = reservation[0];
await db.query(`
DELETE FROM room_availability
WHERE room_id = $1 AND date >= $2 AND date < $3 AND reason = $4
`, [room_id, check_in, check_out, `Reservation #${id}`]);
}
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Update room reservation error:', error);
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
}
}
export async function DELETE(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 id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Reservation ID required' }, { status: 400 });
}
// Get reservation details before deleting
const { rows: reservation } = await db.query(
'SELECT room_id, check_in, check_out FROM room_reservations WHERE id = $1',
[id]
);
// Delete the reservation
await db.query('DELETE FROM room_reservations WHERE id = $1', [id]);
// Free up blocked dates
if (reservation.length > 0) {
const { room_id, check_in, check_out } = reservation[0];
await db.query(`
DELETE FROM room_availability
WHERE room_id = $1 AND date >= $2 AND date < $3 AND reason = $4
`, [room_id, check_in, check_out, `Reservation #${id}`]);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete room reservation error:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
+217
View File
@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Get all rooms with their current status and reservation info
export async function GET() {
try {
await requireRole('admin');
// Get all rooms with current reservation info and staff assignment
const { rows } = await db.query(`
SELECT
r.id,
r.name,
r.price,
r.clean_status,
r.notes,
r.active,
r.last_cleaned,
r.assigned_staff_id,
r.priority,
r.issues_count,
r.is_vip,
r.checkout_time,
r.checkout_date,
rr.id as reservation_id,
rr.guest_name,
rr.check_in,
rr.check_out,
rr.status as reservation_status,
rr.payment_status,
u.first_name,
u.last_name,
u.username,
s.first_name as staff_first_name,
s.last_name as staff_last_name,
CASE
WHEN rr.id IS NOT NULL AND rr.status = 'confirmed'
AND CURRENT_DATE >= rr.check_in
AND CURRENT_DATE <= rr.check_out
THEN 'occupied'
ELSE 'available'
END as occupancy_status
FROM rooms r
LEFT JOIN room_reservations rr ON r.id = rr.room_id
AND rr.status = 'confirmed'
AND CURRENT_DATE >= rr.check_in
AND CURRENT_DATE <= rr.check_out
LEFT JOIN users u ON rr.user_id = u.id
LEFT JOIN users s ON r.assigned_staff_id = s.id
ORDER BY
CASE r.priority
WHEN 'urgent' THEN 1
WHEN 'normal' THEN 2
WHEN 'low' THEN 3
END,
r.name
`);
// Get upcoming reservations for each room
const rooms = await Promise.all(rows.map(async (room: any) => {
const { rows: upcoming } = await db.query(`
SELECT
rr.id,
rr.guest_name,
rr.check_in,
rr.check_out,
rr.status,
rr.payment_status,
u.first_name,
u.last_name
FROM room_reservations rr
LEFT JOIN users u ON rr.user_id = u.id
WHERE rr.room_id = $1
AND rr.status = 'confirmed'
AND rr.check_in > CURRENT_DATE
ORDER BY rr.check_in
LIMIT 3
`, [room.id]);
return {
...room,
upcoming_reservations: upcoming,
current_guest: room.guest_name || (room.first_name ? `${room.first_name} ${room.last_name || ''}`.trim() : room.username) || null,
assigned_staff: room.assigned_staff_id ? {
id: room.assigned_staff_id,
first_name: room.staff_first_name,
last_name: room.staff_last_name,
} : null,
};
}));
// Get all staff members (users who can be assigned to rooms)
const { rows: staff } = await db.query(`
SELECT id, first_name, last_name, username
FROM users
WHERE role IN ('admin', 'staff') OR role = 'user'
ORDER BY first_name, last_name
`);
return NextResponse.json({ rooms, staff });
} catch (error) {
console.error('Get room status error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get room status') },
{ status: 500 }
);
}
}
// Update room status
export async function PATCH(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const {
id,
clean_status,
notes,
last_cleaned,
assigned_staff_id,
priority,
issues_count,
is_vip,
checkout_time,
checkout_date,
} = body;
if (!id) {
return NextResponse.json({ error: 'Room ID is required' }, { status: 400 });
}
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (clean_status !== undefined) {
updates.push(`clean_status = $${paramIndex}`);
values.push(clean_status);
paramIndex++;
}
if (notes !== undefined) {
updates.push(`notes = $${paramIndex}`);
values.push(notes);
paramIndex++;
}
if (last_cleaned !== undefined) {
updates.push(`last_cleaned = $${paramIndex}`);
values.push(last_cleaned);
paramIndex++;
}
if (assigned_staff_id !== undefined) {
updates.push(`assigned_staff_id = $${paramIndex}`);
values.push(assigned_staff_id || null);
paramIndex++;
}
if (priority !== undefined) {
updates.push(`priority = $${paramIndex}`);
values.push(priority);
paramIndex++;
}
if (issues_count !== undefined) {
updates.push(`issues_count = $${paramIndex}`);
values.push(issues_count);
paramIndex++;
}
if (is_vip !== undefined) {
updates.push(`is_vip = $${paramIndex}`);
values.push(is_vip);
paramIndex++;
}
if (checkout_time !== undefined) {
updates.push(`checkout_time = $${paramIndex}`);
values.push(checkout_time || null);
paramIndex++;
}
if (checkout_date !== undefined) {
updates.push(`checkout_date = $${paramIndex}`);
values.push(checkout_date || null);
paramIndex++;
}
// Auto-set last_cleaned when status changes to 'clean'
if (clean_status === 'clean') {
updates.push(`last_cleaned = NOW()`);
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
}
values.push(id);
const { rows } = await db.query(
`UPDATE rooms SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex} RETURNING *`,
values
);
return NextResponse.json({ room: rows[0] });
} catch (error) {
console.error('Update room status error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to update room status') },
{ status: 500 }
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
try {
const { rows } = await db.query(
'SELECT id, language, title, version, updated_at FROM terms_content ORDER BY language, updated_at DESC'
);
return NextResponse.json({ data: rows });
} catch (error) {
console.error('Error fetching all terms:', error);
return NextResponse.json({ error: 'Failed to fetch terms' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { language, title, content, version = '1.0' } = body;
if (!language || !title || !content) {
return NextResponse.json({ error: 'Language, title, and content are required' }, { status: 400 });
}
const { rows } = await db.query(
'INSERT INTO terms_content (language, title, content, version) VALUES ($1, $2, $3, $4) RETURNING id, language, title, version, updated_at',
[language, title, content, version]
);
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
console.error('Error creating terms:', error);
if (error.code === '23505') {
return NextResponse.json({ error: 'Terms with this language and version already exists' }, { status: 409 });
}
return NextResponse.json({ error: error.message || 'Failed to create terms' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, title, content } = body;
if (!id) {
return NextResponse.json({ error: 'Terms ID is required' }, { status: 400 });
}
const { rows } = await db.query(
'UPDATE terms_content SET title = $1, content = $2, updated_at = NOW() WHERE id = $3 RETURNING id, language, title, version, updated_at',
[title, content, id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Terms not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
console.error('Error updating terms:', error);
return NextResponse.json({ error: error.message || 'Failed to update terms' }, { status: 500 });
}
}
+106
View File
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
await requireRole('admin');
const { rows } = await db.query(
`SELECT t.id, t.user_id, t.room_id, t.check_in, t.check_out, t.notes, t.status, t.created_at,
u.username, r.name as room_name
FROM trips t
JOIN users u ON t.user_id = u.id
LEFT JOIN rooms r ON t.room_id = r.id
ORDER BY t.check_in DESC`
);
return NextResponse.json({ trips: rows });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('GET /api/admin/trips error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const { user_id, room_id, check_in, check_out, notes, status } = await request.json();
if (!user_id || !check_in || !check_out) {
return NextResponse.json({ error: 'user_id, check_in, and check_out are required' }, { status: 400 });
}
const { rows } = await db.query(
`INSERT INTO trips (user_id, room_id, check_in, check_out, notes, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[user_id, room_id || null, check_in, check_out, notes || null, status || 'confirmed']
);
return NextResponse.json({ trip: rows[0] });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('POST /api/admin/trips error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const { id, user_id, room_id, check_in, check_out, notes, status } = await request.json();
if (!id) {
return NextResponse.json({ error: 'Trip id is required' }, { status: 400 });
}
const { rows } = await db.query(
`UPDATE trips
SET user_id = COALESCE($1, user_id),
room_id = COALESCE($2, room_id),
check_in = COALESCE($3, check_in),
check_out = COALESCE($4, check_out),
notes = COALESCE($5, notes),
status = COALESCE($6, status)
WHERE id = $7
RETURNING *`,
[user_id, room_id, check_in, check_out, notes, status, id]
);
return NextResponse.json({ trip: rows[0] });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('PUT /api/admin/trips error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
await requireRole('admin');
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'Trip id is required' }, { status: 400 });
}
await db.query('DELETE FROM trips WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('DELETE /api/admin/trips error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+15 -60
View File
@@ -1,70 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole, createUser } from '@/lib/auth';
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Admin: Get all users for recipient selection
export async function GET() {
try {
await requireRole('admin');
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, created_at FROM users ORDER BY created_at DESC');
return NextResponse.json({ data: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { username, password, role = 'user' } = body;
const { rows } = await db.query(`
SELECT id, username, first_name, last_name, role, email, created_at
FROM users
ORDER BY role, first_name, last_name
`);
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
}
if (role !== 'admin' && role !== 'user') {
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 });
}
const user = await createUser(username, password, role as 'admin' | 'user');
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, first_name, last_name, comments_disabled } = body;
const { rows } = await db.query(
'UPDATE users SET first_name = $1, last_name = $2, comments_disabled = $3 WHERE id = $4 RETURNING id, username, role, first_name, last_name, comments_disabled, created_at',
[first_name || null, last_name || null, comments_disabled === true, id]
return NextResponse.json({ users: rows });
} catch (error) {
console.error('Get users error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get users') },
{ status: 500 }
);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to update user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
await requireRole('admin');
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
}
await db.query('DELETE FROM users WHERE id = $1', [id]);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to delete user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const { rows } = await db.query(
'SELECT id, name, icon_svg, sort_order FROM room_amenities ORDER BY sort_order, name'
);
return NextResponse.json({ data: rows });
} catch (error) {
console.error('Failed to fetch amenities:', error);
return NextResponse.json({ error: 'Failed to fetch amenities' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { name, icon_svg, sort_order } = await request.json();
if (!name || !icon_svg) {
return NextResponse.json({ error: 'Name and icon_svg are required' }, { status: 400 });
}
const { rows } = await db.query(
'INSERT INTO room_amenities (name, icon_svg, sort_order) VALUES ($1, $2, $3) RETURNING id, name, icon_svg, sort_order',
[name, icon_svg, sort_order || 0]
);
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
console.error('Failed to create amenity:', error);
if (error.code === '23505') {
return NextResponse.json({ error: 'Amenity with this name already exists' }, { status: 409 });
}
return NextResponse.json({ error: 'Failed to create amenity' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const { id, name, icon_svg, sort_order } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
}
const { rows } = await db.query(
'UPDATE room_amenities SET name = $1, icon_svg = $2, sort_order = $3 WHERE id = $4 RETURNING id, name, icon_svg, sort_order',
[name, icon_svg, sort_order || 0, id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Amenity not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
console.error('Failed to update amenity:', error);
if (error.code === '23505') {
return NextResponse.json({ error: 'Amenity with this name already exists' }, { status: 409 });
}
return NextResponse.json({ error: 'Failed to update amenity' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
}
await db.query('DELETE FROM room_amenities WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete amenity:', error);
return NextResponse.json({ error: 'Failed to delete amenity' }, { status: 500 });
}
}
+8 -1
View File
@@ -17,7 +17,14 @@ export async function POST(request: NextRequest) {
await createSession(user);
return NextResponse.json({ role: user.role, username: user.username });
return NextResponse.json({
role: user.role,
username: user.username,
preferred_language: user.preferred_language,
terms_accepted_at: user.terms_accepted_at,
first_name: user.first_name,
last_name: user.last_name
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+2 -1
View File
@@ -8,7 +8,7 @@ export async function GET() {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
const { rows } = await db.query(
'SELECT id, username, role, first_name, last_name, comments_disabled FROM users WHERE id = $1',
'SELECT id, username, role, first_name, last_name, email, comments_disabled FROM users WHERE id = $1',
[session.id]
);
const user = rows[0] || {};
@@ -19,6 +19,7 @@ export async function GET() {
id: session.id,
first_name: user.first_name || null,
last_name: user.last_name || null,
email: user.email || null,
comments_disabled: user.comments_disabled === true,
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { createUser } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password, preferred_language, first_name, last_name, email, phone, country, accept_terms } = body;
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
}
if (!accept_terms) {
return NextResponse.json({ error: 'You must accept the terms of service' }, { status: 400 });
}
// Check if username already exists
const { rows: existing } = await db.query('SELECT id FROM users WHERE username = $1', [username]);
if (existing.length > 0) {
return NextResponse.json({ error: 'Username already exists' }, { status: 409 });
}
// Create user with role 'user' and terms_accepted_at
const user = await createUser(username, password, 'user');
// Update with additional profile fields
await db.query(
'UPDATE users SET preferred_language = $1, first_name = $2, last_name = $3, email = $4, phone = $5, country = $6, terms_accepted_at = NOW() WHERE id = $7',
[preferred_language || 'es', first_name || null, last_name || null, email || null, phone || null, country || null, user.id]
);
const { rows } = await db.query(
'SELECT id, username, role, preferred_language, first_name, last_name, email, phone, country, terms_accepted_at FROM users WHERE id = $1',
[user.id]
);
return NextResponse.json({ ok: true, user: rows[0] });
} catch (error: any) {
console.error('Registration error:', error);
return NextResponse.json({ error: error.message || 'Failed to register user' }, { status: 500 });
}
}
+6 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { createUser } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
export async function POST() {
try {
@@ -12,7 +13,10 @@ export async function POST() {
const user = await createUser(username, password, 'admin');
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
} catch (err: any) {
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 });
} catch (err: unknown) {
return NextResponse.json(
{ error: getErrorMessage(err, 'Failed to create bootstrap user') },
{ status: 500 }
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, email, message } = body;
if (!name || !email || !message) {
return NextResponse.json({ error: 'Name, email, and message are required' }, { status: 400 });
}
// Store the contact message
await db.query(
`INSERT INTO contact_messages (name, email, message, created_at) VALUES ($1, $2, $3, NOW())`,
[name, email, message]
);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to send message' }, { status: 500 });
}
}
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Send a message to a conversation
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const { content } = await request.json();
if (!content || content.trim().length === 0) {
return NextResponse.json({ error: 'Message content is required' }, { status: 400 });
}
// Verify user is participant
const { rows: participants } = await db.query(
'SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
[id, user.id]
);
if (participants.length === 0) {
return NextResponse.json({ error: 'Not authorized for this conversation' }, { status: 403 });
}
// Create message
const { rows: msgRows } = await db.query(
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3) RETURNING *',
[id, user.id, content]
);
// Update conversation updated_at
await db.query(
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
[id]
);
// Get sender info
const { rows: userRows } = await db.query(
'SELECT id, username, first_name, last_name, role FROM users WHERE id = $1',
[user.id]
);
return NextResponse.json({
message: {
...msgRows[0],
sender_id: user.id,
username: userRows[0].username,
first_name: userRows[0].first_name,
last_name: userRows[0].last_name,
role: userRows[0].role
}
});
} catch (error) {
console.error('Send message error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to send message') },
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Get messages for a conversation
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
// Verify user is participant
const { rows: participants } = await db.query(
'SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
[id, user.id]
);
if (participants.length === 0) {
return NextResponse.json({ error: 'Not authorized for this conversation' }, { status: 403 });
}
// Get all messages with sender info
const { rows: messages } = await db.query(`
SELECT
dm.id,
dm.content,
dm.created_at,
u.id as sender_id,
u.username,
u.first_name,
u.last_name,
u.role
FROM direct_messages dm
JOIN users u ON dm.sender_id = u.id
WHERE dm.conversation_id = $1
ORDER BY dm.created_at ASC
`, [id]);
// Get conversation details with participants
const { rows: convRows } = await db.query(`
SELECT
c.id,
c.subject,
c.created_at,
c.updated_at
FROM conversations c
WHERE c.id = $1
`, [id]);
const { rows: participantRows } = await db.query(`
SELECT u.id, u.username, u.first_name, u.last_name, u.role
FROM conversation_participants cp
JOIN users u ON cp.user_id = u.id
WHERE cp.conversation_id = $1
`, [id]);
return NextResponse.json({
conversation: convRows[0],
participants: participantRows,
messages
});
} catch (error) {
console.error('Get messages error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get messages') },
{ status: 500 }
);
}
}
+127
View File
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Get all conversations for the current user
export async function GET() {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get all conversations where user is a participant
const { rows } = await db.query(`
SELECT
c.id,
c.subject,
c.created_at,
c.updated_at,
(SELECT content FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message,
(SELECT created_at FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_at,
(SELECT COUNT(*) FROM direct_messages WHERE conversation_id = c.id AND created_at > COALESCE(
(SELECT joined_at FROM conversation_participants WHERE conversation_id = c.id AND user_id = $1), c.created_at
)) as unread_count
FROM conversations c
JOIN conversation_participants cp ON c.id = cp.conversation_id
WHERE cp.user_id = $1
ORDER BY c.updated_at DESC
`, [user.id]);
// Get other participants for each conversation
const conversations = await Promise.all(rows.map(async (conv: any) => {
const { rows: participants } = await db.query(`
SELECT u.id, u.username, u.first_name, u.last_name, u.role
FROM conversation_participants cp
JOIN users u ON cp.user_id = u.id
WHERE cp.conversation_id = $1
`, [conv.id]);
return {
...conv,
participants
};
}));
return NextResponse.json({ conversations });
} catch (error) {
console.error('Get conversations error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get conversations') },
{ status: 500 }
);
}
}
// Create a new conversation
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { subject, initial_message, recipient_ids } = await request.json();
if (!initial_message || initial_message.trim().length === 0) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
}
// Create conversation
const { rows: convRows } = await db.query(
'INSERT INTO conversations (subject, created_by) VALUES ($1, $2) RETURNING *',
[subject || null, user.id]
);
const conversation = convRows[0];
// Add creator as participant
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)',
[conversation.id, user.id]
);
// Add other participants (admins for customer messages, specific user for direct)
if (recipient_ids && Array.isArray(recipient_ids)) {
for (const recipientId of recipient_ids) {
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[conversation.id, recipientId]
);
}
}
// If user is not admin, add all admins as participants
if (user.role !== 'admin') {
const { rows: admins } = await db.query(
"SELECT id FROM users WHERE role = 'admin'"
);
for (const admin of admins) {
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[conversation.id, admin.id]
);
}
}
// Add initial message
const { rows: msgRows } = await db.query(
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3) RETURNING *',
[conversation.id, user.id, initial_message]
);
// Update conversation updated_at
await db.query(
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
[conversation.id]
);
return NextResponse.json({ conversation, message: msgRows[0] });
} catch (error) {
console.error('Create conversation error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to create conversation') },
{ status: 500 }
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
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 });
}
// 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 });
}
const imageData = rows[0].data;
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
const matches = imageData.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
return NextResponse.json({ error: 'Invalid image format' }, { status: 400 });
}
const ext = matches[1];
const base64 = matches[2];
const buffer = Buffer.from(base64, 'base64');
return new NextResponse(buffer, {
headers: {
'Content-Type': `image/${ext}`,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error) {
console.error('Image serve error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+4 -4
View File
@@ -8,12 +8,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const { id: idStr } = await params;
const id = parseInt(idStr, 10);
const body = await request.json();
const { category, name, description, price, is_daily_special, active, sort_order } = body;
const { category, name, description, price, is_daily_special, active, sort_order, photos, featured_photo } = body;
const { rows } = await db.query(
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7
WHERE id = $8 RETURNING *`,
[category, name, description || '', price, is_daily_special, active, sort_order || 0, id]
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7, photos = $8, featured_photo = $9
WHERE id = $10 RETURNING *`,
[category, name, description || '', price, is_daily_special, active, sort_order || 0, photos || [], featured_photo || null, id]
);
if (rows.length === 0) {
+2 -2
View File
@@ -13,10 +13,10 @@ export async function POST(request: NextRequest) {
}
// Each entry: { id: number, sort_order: number }
const updates = order.map((item: { id: number; sort_order: number }, index: number) =>
const updates = order.map((item: { id: number; sort_order: number }) =>
db.query(
'UPDATE menu_items SET sort_order = $1 WHERE id = $2',
[index, item.id]
[item.sort_order, item.id]
)
);
+4 -4
View File
@@ -18,17 +18,17 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { category, name, description, price, is_daily_special, sort_order } = body;
const { category, name, description, price, is_daily_special, sort_order, photos, featured_photo } = body;
if (!category || !name || price === undefined || price === '') {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const { rows } = await db.query(
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order, photos, featured_photo)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[category, name, description || '', price, is_daily_special || false, sort_order || 0]
[category, name, description || '', price, is_daily_special || false, sort_order || 0, photos || [], featured_photo || null]
);
return NextResponse.json({ data: rows[0] });
+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 });
}
}
+195
View File
@@ -0,0 +1,195 @@
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');
const kitchen = searchParams.get('kitchen') === 'true';
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,148 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { withRateLimit, getClientIp } from '@/lib/rate-limit';
import { getErrorMessage } from '@/lib/errors';
// 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: getErrorMessage(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: getErrorMessage(error, 'Failed to create reservation') },
{ status: 500 }
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
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: getErrorMessage(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: getErrorMessage(error, 'Failed to get reservations') },
{ status: 500 }
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET - list all room assignments (admin) or current user's assignment
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 onlyActive = searchParams.get('active') === 'true';
if (user.role !== 'admin') {
// Regular user - return their current active assignment
const today = new Date().toISOString().split('T')[0];
const { rows } = await db.query(`
SELECT ra.*, r.name as room_name, r.price
FROM room_assignments ra
JOIN rooms r ON ra.room_id = r.id
WHERE ra.user_id = $1 AND ra.check_in <= $2 AND ra.check_out >= $2
`, [user.id, today]);
return NextResponse.json({ assignments: rows });
}
// Admin - return all assignments
let query = `
SELECT ra.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email
FROM room_assignments ra
JOIN rooms r ON ra.room_id = r.id
JOIN users u ON ra.user_id = u.id
`;
const params: any[] = [];
if (onlyActive) {
const today = new Date().toISOString().split('T')[0];
query += ' WHERE ra.check_in <= $1 AND ra.check_out >= $1';
params.push(today);
}
query += ' ORDER BY ra.check_in DESC';
const { rows } = await db.query(query, params);
return NextResponse.json({ assignments: rows });
} catch (error) {
console.error('Get room assignments error:', error);
return NextResponse.json({ error: 'Failed to get room assignments' }, { status: 500 });
}
}
// POST - create new room assignment (admin only)
export async function POST(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { room_id, user_id, check_in, check_out, notes } = await request.json();
if (!room_id || !user_id || !check_in || !check_out) {
return NextResponse.json({ error: 'Room, user, check-in, and check-out are required' }, { status: 400 });
}
// Check for conflicting assignments
const { rows: conflicts } = await db.query(`
SELECT id FROM room_assignments
WHERE room_id = $1 AND check_in < $3 AND check_out > $2
`, [room_id, check_in, check_out]);
if (conflicts.length > 0) {
return NextResponse.json({ error: 'Room has conflicting assignment for these dates' }, { status: 400 });
}
const { rows } = await db.query(`
INSERT INTO room_assignments (room_id, user_id, check_in, check_out, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [room_id, user_id, check_in, check_out, notes || null, user.id]);
return NextResponse.json({ success: true, assignment: rows[0] });
} catch (error) {
console.error('Create room assignment error:', error);
return NextResponse.json({ error: 'Failed to create room assignment' }, { status: 500 });
}
}
// DELETE - remove room assignment (admin only)
export async function DELETE(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 id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Assignment ID required' }, { status: 400 });
}
await db.query('DELETE FROM room_assignments WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete room assignment error:', error);
return NextResponse.json({ error: 'Failed to delete room assignment' }, { status: 500 });
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
// GET room's amenities
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const roomId = searchParams.get('room_id');
try {
if (roomId) {
const { rows } = await db.query(
`SELECT a.id, a.name, a.icon_svg, a.sort_order
FROM room_amenities a
JOIN room_amenity_links r ON a.id = r.amenity_id
WHERE r.room_id = $1
ORDER BY a.sort_order, a.name`,
[roomId]
);
return NextResponse.json({ data: rows });
}
const { rows } = await db.query(
'SELECT id, name, icon_svg, sort_order FROM room_amenities ORDER BY sort_order, name'
);
return NextResponse.json({ data: rows });
} catch (error) {
console.error('Failed to fetch amenities:', error);
return NextResponse.json({ error: 'Failed to fetch amenities' }, { status: 500 });
}
}
// Link amenities to a room (replaces all links)
export async function POST(request: NextRequest) {
try {
const { room_id, amenity_ids } = await request.json();
if (!room_id || !Array.isArray(amenity_ids)) {
return NextResponse.json({ error: 'room_id and amenity_ids array are required' }, { status: 400 });
}
// Delete existing links
await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [room_id]);
// Insert new links
if (amenity_ids.length > 0) {
const values = amenity_ids.map((_, i) => `($1, $${i + 2})`).join(', ');
await db.query(
`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`,
[room_id, ...amenity_ids]
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to link amenities:', error);
return NextResponse.json({ error: 'Failed to link amenities' }, { 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 });
}
+29 -5
View File
@@ -8,7 +8,15 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]);
if (!rows.length) return NextResponse.json({ error: 'Room not found' }, { status: 404 });
return NextResponse.json({ data: rows[0] });
// Get amenities for this room
const { rows: amenityRows } = await db.query(
'SELECT amenity_id FROM room_amenity_links WHERE room_id = $1',
[id]
);
const amenity_ids = amenityRows.map(r => r.amenity_id);
return NextResponse.json({ data: { ...rows[0], amenity_ids } });
} catch (error: any) {
console.error('GET /api/rooms/[id]:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -21,17 +29,33 @@ 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 } = 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]
);
return NextResponse.json({ data: rows[0] });
// Update amenities
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}`);
}
// 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);
+80 -4
View File
@@ -5,7 +5,43 @@ import { db } from '@/lib/db';
export async function GET() {
try {
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id');
return NextResponse.json({ data: rows });
// 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,
photos: imageMap.get(r.id) || [],
amenities: amenityMap.get(r.id) || []
}));
return NextResponse.json({ data: roomsWithAmenities });
} catch (error: any) {
console.error('GET /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to fetch rooms' }, { status: 500 });
@@ -16,22 +52,62 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { name, description, price, photos, sort_order, featured_photo } = 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]
);
return NextResponse.json({ data: rows[0] });
const roomId = rows[0].id;
// Insert amenity links
if (amenity_ids && amenity_ids.length > 0) {
const values = amenity_ids.map((aid: number) => `(${roomId}, ${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('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 });
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
const PUBLIC_KEYS = ['logo','favicon','facebook_url','instagram_url','whatsapp_url','address','phone','email','wifi_password'];
const PUBLIC_KEYS = ['logo','favicon','tagline','facebook_url','instagram_url','whatsapp_url','address','phone','email'];
export async function GET(request: NextRequest) {
try {
@@ -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 });
}
}
+4 -4
View File
@@ -7,13 +7,13 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
await requireRole('admin');
const id = parseInt(params.id, 10);
const body = await request.json();
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
const { rows } = await db.query(
`UPDATE social_events
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8
WHERE id=$9
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, is_private=$8, max_guests=$9, sort_order=$10
WHERE id=$11
RETURNING *`,
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0, id]
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0, id]
);
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ data: rows[0] });
+20 -9
View File
@@ -2,15 +2,26 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
export async function GET(request: NextRequest) {
try {
const { rows } = await db.query(`
const { searchParams } = new URL(request.url);
const includePrivate = searchParams.get('includePrivate') === 'true';
// Public requests only see active, non-private events
// Admin requests with includePrivate=true see all events
let query = `
SELECT se.*,
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
FROM social_events se
WHERE se.active = TRUE
ORDER BY se.sort_order, se.date, se.id
`);
`;
if (includePrivate) {
query += ' ORDER BY se.is_private, se.sort_order, se.date, se.id';
} else {
query += ' WHERE se.active = TRUE AND (se.is_private = FALSE OR se.is_private IS NULL) ORDER BY se.sort_order, se.date, se.id';
}
const { rows } = await db.query(query);
return NextResponse.json({ data: rows });
} catch (error: any) {
console.error('GET /api/social_events error:', error);
@@ -22,12 +33,12 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
const { rows } = await db.query(
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0]
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0]
);
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const language = searchParams.get('language') || 'es';
// Get the latest terms for the requested language
const { rows } = await db.query(
'SELECT id, language, title, content, version, updated_at FROM terms_content WHERE language = $1 ORDER BY updated_at DESC LIMIT 1',
[language]
);
if (rows.length === 0) {
// Return default English terms if no terms found for requested language
const { rows: enRows } = await db.query(
'SELECT id, language, title, content, version, updated_at FROM terms_content WHERE language = $1 ORDER BY updated_at DESC LIMIT 1',
['en']
);
if (enRows.length === 0) {
// Return empty terms if nothing exists yet
return NextResponse.json({ data: { title: '', content: '', version: '1.0', language } });
}
return NextResponse.json({ data: enRows[0] });
}
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error fetching terms:', error);
return NextResponse.json({ error: 'Failed to fetch terms' }, { status: 500 });
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const session = await requireRole('user');
const userId = session.id;
const { rows } = await db.query(
`SELECT id, sender_role, message, created_at
FROM messages
WHERE user_id = $1
ORDER BY created_at ASC`,
[userId]
);
return NextResponse.json({ messages: rows });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('GET /api/user/messages error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireRole('user');
const userId = session.id;
const { message } = await request.json();
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
}
const { rows } = await db.query(
`INSERT INTO messages (user_id, sender_role, message) VALUES ($1, 'user', $2) RETURNING *`,
[userId, message.trim()]
);
return NextResponse.json({ message: rows[0] });
} catch (error: any) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('POST /api/user/messages error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, hashPassword } from '@/lib/auth';
import { db } from '@/lib/db';
import { getErrorMessage } from '@/lib/errors';
export async function POST(request: NextRequest) {
try {
const session = await requireAuth();
const body = await request.json();
const { current_password, new_password } = body;
if (!current_password || !new_password) {
return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 });
}
if (new_password.length < 12) {
return NextResponse.json({ error: 'Password must be at least 12 characters' }, { status: 400 });
}
// Password complexity requirements
if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) {
return NextResponse.json({ error: 'Password must contain uppercase, lowercase, and numbers' }, { status: 400 });
}
// Verify current password
const { rows } = await db.query('SELECT password_hash FROM users WHERE id = $1', [session.id]);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const { verifyPassword } = await import('@/lib/auth');
const valid = await verifyPassword(current_password, rows[0].password_hash);
if (!valid) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
}
// Update to new password
const hash = await hashPassword(new_password);
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, session.id]);
return NextResponse.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error && err.message === 'Unauthorized' ? 'Unauthorized' : getErrorMessage(err, 'Failed to change password');
return NextResponse.json(
{ error: message },
{ status: err instanceof Error && err.message === 'Unauthorized' ? 401 : 500 }
);
}
}
+23 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic'; // Prevents static generation
export const dynamic = 'force-dynamic';
export async function GET() {
try {
@@ -32,12 +32,34 @@ export async function GET() {
);
const wifiPassword = configRows[0]?.value || '';
// Get user's trips (future and current)
const { rows: trips } = await db.query(
`SELECT t.id, t.room_id, t.check_in, t.check_out, t.notes, t.status, r.name as room_name
FROM trips t
LEFT JOIN rooms r ON t.room_id = r.id
WHERE t.user_id = $1 AND t.check_out >= CURRENT_DATE
ORDER BY t.check_in ASC`,
[userId]
);
// Get weather
let weather = undefined;
try {
const weatherRes = await fetch('http://localhost:3000/api/weather');
if (weatherRes.ok) {
const weatherData = await weatherRes.json();
weather = weatherData.data;
}
} catch { /* ignore */ }
return NextResponse.json({
reservations,
coupons,
offers,
benefits: benefits.map((b: { text: string }) => b.text),
wifiPassword,
trips,
weather,
});
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, hashPassword } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
try {
const session = await requireAuth();
const { rows } = await db.query(
'SELECT id, username, role, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at FROM users WHERE id = $1',
[session.id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to fetch profile' }, { status: error.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const session = await requireAuth();
const body = await request.json();
const { first_name, last_name, preferred_language, email, phone, country, password, accept_terms } = body;
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (first_name !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(first_name || null);
}
if (last_name !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(last_name || null);
}
if (preferred_language !== undefined) {
updates.push(`preferred_language = $${paramIndex++}`);
values.push(preferred_language || 'es');
}
if (email !== undefined) {
updates.push(`email = $${paramIndex++}`);
values.push(email || null);
}
if (phone !== undefined) {
updates.push(`phone = $${paramIndex++}`);
values.push(phone || null);
}
if (country !== undefined) {
updates.push(`country = $${paramIndex++}`);
values.push(country || null);
}
if (password !== undefined && password) {
updates.push(`password_hash = $${paramIndex++}`);
values.push(await hashPassword(password));
}
if (accept_terms === true) {
updates.push(`terms_accepted_at = NOW()`);
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(session.id);
const { rows } = await db.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, username, role, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at`,
values
);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to update profile' }, { status: error.message === 'Unauthorized' ? 401 : 500 });
}
}
+16
View File
@@ -45,6 +45,22 @@ export async function GET() {
weathercode: daily.weathercode?.[0],
summary: codeMap[daily.weathercode?.[0]] || 'Unknown',
},
tomorrow: {
maxC: celsius(daily.temperature_2m_max?.[1]),
minC: celsius(daily.temperature_2m_min?.[1]),
maxF: fahrenheit(daily.temperature_2m_max?.[1]),
minF: fahrenheit(daily.temperature_2m_min?.[1]),
weathercode: daily.weathercode?.[1],
summary: codeMap[daily.weathercode?.[1]] || 'Unknown',
},
dayAfter: {
maxC: celsius(daily.temperature_2m_max?.[2]),
minC: celsius(daily.temperature_2m_min?.[2]),
maxF: fahrenheit(daily.temperature_2m_max?.[2]),
minF: fahrenheit(daily.temperature_2m_min?.[2]),
weathercode: daily.weathercode?.[2],
summary: codeMap[daily.weathercode?.[2]] || 'Unknown',
},
},
});
} catch (error: any) {
+494 -31
View File
@@ -9,6 +9,21 @@ interface MenuItem {
description: string;
price: string;
is_daily_special: boolean;
photos?: string[];
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';
@@ -18,15 +33,60 @@ 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 [assignedRoom, setAssignedRoom] = useState<Room | 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/me').then(async (res) => {
if (res.ok) {
const data = await res.json();
if (data.authenticated && data.id) {
return { user: { id: data.id, username: data.username, role: data.role, first_name: data.first_name, last_name: data.last_name } };
}
}
return { user: null };
}),
fetch('/api/rooms').then(async (res) => {
if (!res.ok) return { rooms: [] };
return res.json();
}),
])
.then(async ([menuData, sessionData, roomsData]) => {
setItems(menuData.data || []);
setUser(sessionData.user || null);
setRooms(roomsData.rooms || []);
// Fetch user's active room assignment if logged in
if (sessionData.user) {
try {
const assignRes = await fetch('/api/room-assignments');
if (assignRes.ok) {
const assignData = await assignRes.json();
if (assignData.assignments?.length > 0) {
const assignment = assignData.assignments[0];
setAssignedRoom({ id: assignment.room_id, name: assignment.room_name });
setSelectedRoom(assignment.room_id);
}
}
} catch { /* ignore */ }
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
@@ -35,12 +95,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: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Coffee &amp; Kitchen</h1>
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
Locally sourced coffee, fresh pastries, and seasonal plates.
</p>
<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: 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>}
@@ -50,10 +209,10 @@ export default function CoffeePage() {
style={{
background: `linear-gradient(135deg, ${navy} 0%, #0e2f47 100%)`,
color: cream,
borderRadius: '20px',
padding: '1.5rem 2rem',
marginBottom: '2rem',
boxShadow: '0 6px 20px rgba(1,13,30,0.2)',
borderRadius: '16px',
padding: 'clamp(1rem, 4vw, 1.5rem) clamp(1.25rem, 5vw, 2rem)',
marginBottom: '1.5rem',
boxShadow: '0 4px 16px rgba(1,13,30,0.2)',
}}
>
<span
@@ -61,62 +220,366 @@ export default function CoffeePage() {
display: 'inline-block',
background: gold,
color: navy,
fontSize: '12px',
fontSize: '11px',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.1em',
padding: '0.35rem 0.8rem',
letterSpacing: '0.08em',
padding: '0.3rem 0.7rem',
borderRadius: '999px',
marginBottom: '0.75rem',
marginBottom: '0.5rem',
}}
>
Menu of the Day
</span>
<h2 style={{ margin: '0 0 0.5rem', fontSize: '26px' }}>{daily.name}</h2>
<p style={{ margin: '0 0 1rem', opacity: 0.85 }}>{daily.description}</p>
<strong style={{ color: gold, fontSize: '20px' }}>${daily.price}</strong>
<h2 style={{ margin: '0 0 0.35rem', fontSize: 'clamp(20px, 4vw, 26px)' }}>{daily.name}</h2>
<p style={{ margin: '0 0 0.75rem', opacity: 0.85, fontSize: 'clamp(14px, 3vw, 16px)' }}>{daily.description}</p>
<strong style={{ color: gold, fontSize: 'clamp(18px, 4vw, 20px)' }}>${daily.price}</strong>
</section>
)}
{!loading && items.length === 0 && <p style={{ color: '#777' }}>No menu items yet.</p>}
{categories.map((category) => (
<section key={category} style={{ marginBottom: '2rem' }}>
<section key={category} style={{ marginBottom: '1.5rem' }}>
<h2
style={{
fontSize: '20px',
fontSize: 'clamp(16px, 4vw, 20px)',
color: navy,
borderBottom: '2px solid ' + warm,
display: 'inline-block',
paddingBottom: '0.3rem',
margin: '0 0 1rem',
paddingBottom: '0.25rem',
margin: '0 0 0.75rem',
}}
>
{category}
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 280px), 1fr))', gap: 'clamp(0.75rem, 3vw, 1rem)' }}>
{items
.filter((i) => i.category === category)
.map((item) => (
<div
key={item.id}
style={{
padding: '1.25rem',
padding: 'clamp(1rem, 4vw, 1.25rem)',
background: cream,
borderRadius: '16px',
borderRadius: '12px',
boxShadow: '0 1px 3px rgba(1,13,30,0.08)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.5rem' }}>
<strong style={{ color: navy }}>{item.name}</strong>
<span style={{ color: warm, fontWeight: 700 }}>${item.price}</span>
{(item.photos && item.photos.length > 0) && (
<div style={{ marginBottom: '0.6rem', background: '#e8e4dc', borderRadius: '10px', overflow: 'hidden' }}>
<img
src={item.featured_photo || item.photos[0]}
alt={item.name}
loading="lazy"
decoding="async"
style={{ width: '100%', height: 'clamp(100px, 25vw, 140px)', objectFit: 'cover', display: 'block' }}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.4rem', gap: '0.5rem' }}>
<strong style={{ color: navy, fontSize: 'clamp(15px, 3.5vw, 17px)' }}>{item.name}</strong>
<span style={{ color: warm, fontWeight: 700, fontSize: 'clamp(15px, 3.5vw, 17px)', whiteSpace: 'nowrap' }}>${item.price}</span>
</div>
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{item.description}</p>
<p style={{ margin: 0, color: '#555', fontSize: 'clamp(13px, 3vw, 14px)', lineHeight: 1.4 }}>{item.description}</p>
</div>
))}
</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 }}>
{assignedRoom ? 'Your Room' : 'Select Room'}
</label>
{assignedRoom ? (
<div style={{
padding: '0.75rem',
border: '2px solid gold',
borderRadius: '8px',
background: '#fff9e6',
color: navy,
fontWeight: 500,
}}>
🏨 {assignedRoom.name}
</div>
) : (
<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>
);
}
+200
View File
@@ -0,0 +1,200 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/lib/i18n';
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
const copy = {
en: {
title: 'Contact Us',
subtitle: 'Get in touch with us',
phone: 'Phone',
email: 'Email',
whatsapp: 'WhatsApp',
location: 'Location',
send_message: 'Send us a message',
form_name: 'Your Name',
form_email: 'Your Email',
form_message: 'Message',
form_submit: 'Send Message',
form_sending: 'Sending...',
form_success: 'Message sent successfully!',
form_error: 'Failed to send message. Please try again.',
},
es: {
title: 'Contáctenos',
subtitle: 'Póngase en contacto con nosotros',
phone: 'Teléfono',
email: 'Correo',
whatsapp: 'WhatsApp',
location: 'Ubicación',
send_message: 'Envíenos un mensaje',
form_name: 'Su Nombre',
form_email: 'Su Correo',
form_message: 'Mensaje',
form_submit: 'Enviar Mensaje',
form_sending: 'Enviando...',
form_success: '¡Mensaje enviado exitosamente!',
form_error: 'Error al enviar el mensaje. Intente de nuevo.',
},
};
export default function ContactPage() {
const language = useLanguage();
const t = copy[language];
const [form, setForm] = useState({ name: '', email: '', message: '' });
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
const [config, setConfig] = useState<{ phone?: string; email?: string; whatsapp_url?: string; address?: string }>({});
useEffect(() => {
fetch('/api/site-assets')
.then(res => res.json())
.then(data => setConfig(data.data || {}))
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('sending');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (res.ok) {
setStatus('success');
setForm({ name: '', email: '', message: '' });
} else {
setStatus('error');
}
} catch {
setStatus('error');
}
};
return (
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream, padding: '3rem 2rem' }}>
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', marginBottom: '0.5rem', color: cream }}>
{t.title}
</h1>
<p style={{ fontSize: '16px', color: 'rgba(245,240,232,0.7)', marginBottom: '3rem' }}>
{t.subtitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '2rem', marginBottom: '3rem' }}>
{/* Phone */}
<div style={{ background: 'rgba(245,240,232,0.06)', borderRadius: '12px', padding: '1.5rem', border: '1px solid rgba(245,240,232,0.12)' }}>
<div style={{ fontSize: '24px', marginBottom: '0.5rem' }}>📞</div>
<div style={{ fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.1em', color: gold, marginBottom: '0.25rem' }}>{t.phone}</div>
<div style={{ fontSize: '16px' }}>{config.phone || '+593 99 XXX XXXX'}</div>
</div>
{/* Email */}
<div style={{ background: 'rgba(245,240,232,0.06)', borderRadius: '12px', padding: '1.5rem', border: '1px solid rgba(245,240,232,0.12)' }}>
<div style={{ fontSize: '24px', marginBottom: '0.5rem' }}></div>
<div style={{ fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.1em', color: gold, marginBottom: '0.25rem' }}>{t.email}</div>
<div style={{ fontSize: '16px' }}>{config.email || 'info@lahuasca.com'}</div>
</div>
{/* WhatsApp */}
<div style={{ background: 'rgba(245,240,232,0.06)', borderRadius: '12px', padding: '1.5rem', border: '1px solid rgba(245,240,232,0.12)' }}>
<div style={{ fontSize: '24px', marginBottom: '0.5rem' }}>💬</div>
<div style={{ fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.1em', color: gold, marginBottom: '0.25rem' }}>{t.whatsapp}</div>
<div style={{ fontSize: '16px' }}>{config.whatsapp_url ? <a href={config.whatsapp_url} target="_blank" rel="noopener noreferrer" style={{ color: cream, textDecoration: 'underline' }}>WhatsApp</a> : (config.phone || '+593 99 XXX XXXX')}</div>
</div>
{/* Location */}
<div style={{ background: 'rgba(245,240,232,0.06)', borderRadius: '12px', padding: '1.5rem', border: '1px solid rgba(245,240,232,0.12)' }}>
<div style={{ fontSize: '24px', marginBottom: '0.5rem' }}>📍</div>
<div style={{ fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.1em', color: gold, marginBottom: '0.25rem' }}>{t.location}</div>
<div style={{ fontSize: '16px' }}>{config.address || 'Otavalo, Imbabura, Ecuador'}</div>
</div>
</div>
{/* Contact Form */}
<div style={{ background: 'rgba(245,240,232,0.04)', borderRadius: '16px', padding: '2rem', border: '1px solid rgba(245,240,232,0.1)' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '1.5rem', color: cream }}>
{t.send_message}
</h2>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<input
type="text"
placeholder={t.form_name}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
style={{
padding: '0.875rem 1rem',
background: 'rgba(245,240,232,0.08)',
border: '1px solid rgba(245,240,232,0.2)',
borderRadius: '8px',
color: cream,
fontSize: '15px',
}}
/>
<input
type="email"
placeholder={t.form_email}
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
style={{
padding: '0.875rem 1rem',
background: 'rgba(245,240,232,0.08)',
border: '1px solid rgba(245,240,232,0.2)',
borderRadius: '8px',
color: cream,
fontSize: '15px',
}}
/>
<textarea
placeholder={t.form_message}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
required
rows={5}
style={{
padding: '0.875rem 1rem',
background: 'rgba(245,240,232,0.08)',
border: '1px solid rgba(245,240,232,0.2)',
borderRadius: '8px',
color: cream,
fontSize: '15px',
resize: 'vertical',
}}
/>
<button
type="submit"
disabled={status === 'sending'}
style={{
padding: '0.875rem 1.5rem',
background: gold,
color: navy,
border: 'none',
borderRadius: '8px',
fontSize: '15px',
fontWeight: 600,
cursor: status === 'sending' ? 'wait' : 'pointer',
opacity: status === 'sending' ? 0.7 : 1,
transition: 'opacity 200ms',
}}
>
{status === 'sending' ? t.form_sending : t.form_submit}
</button>
{status === 'success' && (
<p style={{ color: '#4CAF50', margin: 0 }}>{t.form_success}</p>
)}
{status === 'error' && (
<p style={{ color: '#f44336', margin: 0 }}>{t.form_error}</p>
)}
</form>
</div>
</div>
</main>
);
}
+18
View File
@@ -88,6 +88,24 @@ a {
.navbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-icon-link {
color: var(--gold);
padding: 0.5rem;
border-radius: 0.375rem;
transition: background 0.2s, color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.navbar-icon-link:hover {
background: rgba(212, 175, 55, 0.1);
color: #fff;
}
.navbar-btn-ghost {
+1 -1
View File
@@ -4,7 +4,7 @@ import './globals.css';
export const metadata = {
title: 'La Huasca',
description: 'Hotel, restaurant, and vineyard in the Honduran highlands',
description: 'Hotel and restaurant in the Ecuadorian highlands',
};
export default function RootLayout({
+354 -12
View File
@@ -1,15 +1,59 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
type TermsData = {
id?: number;
language: string;
title: string;
content: string;
version: string;
};
export default function LoginPage() {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [country, setCountry] = useState('');
const [preferredLanguage, setPreferredLanguage] = useState('es');
const [terms, setTerms] = useState<TermsData | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);
const [showTerms, setShowTerms] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
// Check if already logged in
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then(data => {
if (data.authenticated) {
router.push(data.role === 'admin' ? '/admin' : '/user');
} else {
setChecking(false);
}
})
.catch(() => setChecking(false));
}, [router]);
// Fetch terms when language changes
useEffect(() => {
fetch(`/api/terms?language=${preferredLanguage}`)
.then(r => r.json())
.then(data => {
if (data.data) setTerms(data.data);
})
.catch(console.error);
}, [preferredLanguage]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -27,9 +71,82 @@ export default function LoginPage() {
}
const data = await res.json();
// Check if user needs to accept terms
if (!data.terms_accepted_at) {
// Store user data and redirect to terms acceptance
localStorage.setItem('pendingUser', JSON.stringify(data));
router.push('/accept-terms');
return;
}
router.push(data.role === 'admin' ? '/admin' : '/user');
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (!acceptTerms) {
setError('You must accept the terms of service');
return;
}
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
preferred_language: preferredLanguage,
first_name: firstName,
last_name: lastName,
email,
phone,
country,
accept_terms: true,
}),
credentials: 'same-origin',
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Registration failed');
return;
}
// Auto-login after registration
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'same-origin',
});
if (loginRes.ok) {
router.push('/user');
} else {
setMode('login');
setError('Registration successful! Please log in.');
}
};
if (checking) {
return (
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#FAF7F0' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '32px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '16px', color: '#555' }}>Checking authentication...</div>
</div>
</main>
);
}
const accent = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
@@ -46,34 +163,74 @@ export default function LoginPage() {
}}
>
<form
onSubmit={handleSubmit}
onSubmit={mode === 'login' ? handleLogin : handleRegister}
style={{
width: '100%',
maxWidth: '26rem',
maxWidth: '28rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
gap: '1rem',
padding: '2.5rem',
background: cream,
borderRadius: '16px',
boxShadow: '0 6px 18px rgba(14,47,71,0.12)',
}}
>
{/* Language toggle */}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
<button
type="button"
onClick={() => setPreferredLanguage('es')}
style={{
padding: '0.25rem 0.75rem',
background: preferredLanguage === 'es' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'es' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: preferredLanguage === 'es' ? 600 : 400,
}}
>
🇪🇨 Español
</button>
<button
type="button"
onClick={() => setPreferredLanguage('en')}
style={{
padding: '0.25rem 0.75rem',
background: preferredLanguage === 'en' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'en' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: preferredLanguage === 'en' ? 600 : 400,
}}
>
🇬🇧 English
</button>
</div>
<h1
style={{
margin: '0 0 0.25rem',
margin: '0',
fontSize: 'clamp(28px, 4vw, 40px)',
fontWeight: 400,
fontStyle: 'italic',
color: navy,
}}
>
Welcome back
{mode === 'login'
? (preferredLanguage === 'es' ? 'Bienvenido de nuevo' : 'Welcome back')
: (preferredLanguage === 'es' ? 'Crear cuenta' : 'Create account')}
</h1>
<p style={{ margin: '0 0 1rem', color: '#555' }}>Log in to your portal.</p>
<p style={{ margin: '0 0 0.5rem', color: '#555' }}>
{mode === 'login'
? (preferredLanguage === 'es' ? 'Inicia sesión en tu portal.' : 'Log in to your portal.')
: (preferredLanguage === 'es' ? 'Regístrate como huésped.' : 'Register as a guest.')}
</p>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
Username
{preferredLanguage === 'es' ? 'Usuario' : 'Username'}
<input
type="text"
value={username}
@@ -91,7 +248,7 @@ export default function LoginPage() {
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
Password
{preferredLanguage === 'es' ? 'Contraseña' : 'Password'}
<input
type="password"
value={password}
@@ -108,7 +265,161 @@ export default function LoginPage() {
required
/>
</label>
{mode === 'register' && (
<>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
required
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Nombre' : 'First name'}
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Apellido' : 'Last name'}
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Correo electrónico' : 'Email'}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Teléfono' : 'Phone'}
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'País' : 'Country'}
<input
type="text"
value={country}
onChange={(e) => setCountry(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
</div>
{/* Terms of Service */}
<div style={{ background: '#fff', borderRadius: '12px', padding: '1rem', border: '1px solid rgba(1,13,30,0.18)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}>
<input
type="checkbox"
id="accept-terms"
checked={acceptTerms}
onChange={(e) => setAcceptTerms(e.target.checked)}
style={{ marginTop: '0.25rem' }}
/>
<label htmlFor="accept-terms" style={{ fontSize: '14px', color: '#555', cursor: 'pointer' }}>
{preferredLanguage === 'es'
? 'Acepto los términos de servicio y la política de privacidad'
: 'I accept the terms of service and privacy policy'}
</label>
</div>
<button
type="button"
onClick={() => setShowTerms(!showTerms)}
style={{
marginTop: '0.75rem',
background: 'transparent',
border: 'none',
color: accent,
cursor: 'pointer',
fontSize: '13px',
textDecoration: 'underline',
padding: 0,
}}
>
{preferredLanguage === 'es' ? 'Leer términos completos' : 'Read full terms'}
</button>
{showTerms && terms && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f5f5f5', borderRadius: '8px', maxHeight: '200px', overflow: 'auto', fontSize: '13px' }}>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '16px' }}>{terms.title}</h3>
<p style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{terms.content}</p>
</div>
)}
</div>
</>
)}
{error && <p style={{ color: '#c23b22', margin: 0, fontSize: '14px' }}>{error}</p>}
<button
type="submit"
style={{
@@ -131,11 +442,42 @@ export default function LoginPage() {
e.currentTarget.style.boxShadow = 'none';
}}
>
Log in
{mode === 'login'
? (preferredLanguage === 'es' ? 'Iniciar sesión' : 'Log in')
: (preferredLanguage === 'es' ? 'Registrarse' : 'Register')}
</button>
<p style={{ margin: '0.5rem 0 0', fontSize: '13px', color: '#777', textAlign: 'center' }}>
Demo accounts: <strong style={{ color: navy }}>admin</strong> / admin and <strong style={{ color: navy }}>user</strong> / user.
{mode === 'login' ? (
<>
{preferredLanguage === 'es' ? '¿No tienes cuenta? ' : "Don't have an account? "}
<button
type="button"
onClick={() => setMode('register')}
style={{ background: 'none', border: 'none', color: accent, cursor: 'pointer', textDecoration: 'underline', fontSize: '13px' }}
>
{preferredLanguage === 'es' ? 'Regístrate' : 'Register'}
</button>
</>
) : (
<>
{preferredLanguage === 'es' ? '¿Ya tienes cuenta? ' : 'Already have an account? '}
<button
type="button"
onClick={() => setMode('login')}
style={{ background: 'none', border: 'none', color: accent, cursor: 'pointer', textDecoration: 'underline', fontSize: '13px' }}
>
{preferredLanguage === 'es' ? 'Iniciar sesión' : 'Log in'}
</button>
</>
)}
</p>
{mode === 'login' && (
<p style={{ margin: '0.25rem 0 0', fontSize: '12px', color: '#999', textAlign: 'center' }}>
{preferredLanguage === 'es' ? 'Contacta a recepción para obtener credenciales.' : 'Contact reception for login credentials.'}
</p>
)}
</form>
</main>
);
+343
View File
@@ -0,0 +1,343 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { formatDisplayDateTime } from '@/lib/date';
type User = { id: number; username: string; first_name: string; last_name: string; role: string };
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
type Conversation = {
id: number;
subject: string | null;
last_message: string | null;
last_message_at: string | null;
unread_count: number;
participants: Participant[];
};
export default function MessagesPage() {
const [user, setUser] = useState<User | null>(null);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConv, setSelectedConv] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [newMessage, setNewMessage] = useState('');
const [showNewConv, setShowNewConv] = useState(false);
const [convSubject, setConvSubject] = useState('');
const [convMessage, setConvMessage] = useState('');
const [loading, setLoading] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchUser();
}, []);
useEffect(() => {
if (user) {
fetchConversations();
}
}, [user]);
useEffect(() => {
if (selectedConv) {
fetchMessages(selectedConv);
}
}, [selectedConv]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
setUser({
id: data.id,
username: data.username,
first_name: data.first_name,
last_name: data.last_name,
role: data.role,
});
} else {
window.location.href = '/login';
}
} else {
window.location.href = '/login';
}
} catch {
window.location.href = '/login';
}
};
const fetchConversations = async () => {
try {
const res = await fetch('/api/conversations');
if (res.ok) {
const data = await res.json();
setConversations(data.conversations || []);
}
} catch (err) {
console.error('Failed to fetch conversations:', err);
} finally {
setLoading(false);
}
};
const fetchMessages = async (convId: number) => {
try {
const res = await fetch(`/api/conversations/${convId}`);
if (res.ok) {
const data = await res.json();
setMessages(data.messages || []);
setParticipants(data.participants || []);
}
} catch (err) {
console.error('Failed to fetch messages:', err);
}
};
const sendMessage = async () => {
if (!newMessage.trim() || !selectedConv) return;
try {
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newMessage.trim() }),
});
if (res.ok) {
const data = await res.json();
setMessages(prev => [...prev, data.message]);
setNewMessage('');
fetchConversations(); // Update last message in list
}
} catch (err) {
console.error('Failed to send message:', err);
}
};
const createConversation = async () => {
if (!convMessage.trim()) return;
try {
const res = await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: convSubject || null,
initial_message: convMessage.trim(),
}),
});
if (res.ok) {
const data = await res.json();
setConversations(prev => [data.conversation, ...prev]);
setSelectedConv(data.conversation.id);
setShowNewConv(false);
setConvSubject('');
setConvMessage('');
}
} catch (err) {
console.error('Failed to create conversation:', err);
}
};
const getOtherParticipants = (conv: Conversation) => {
return conv.participants.filter(p => p.id !== user?.id);
};
const getDisplayName = (p: Participant) => {
if (p.role === 'admin') {
return p.first_name || p.username;
}
return p.first_name || p.username;
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-4">
<h1 className="text-2xl font-bold text-gray-800 mb-6">Messages</h1>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="flex h-[600px]">
{/* Conversations List */}
<div className={`w-full md:w-1/3 border-r ${selectedConv ? 'hidden md:block' : ''}`}>
<div className="p-3 border-b bg-gray-50 flex justify-between items-center">
<span className="font-medium text-gray-700">Conversations</span>
<button
onClick={() => setShowNewConv(true)}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700"
>
New
</button>
</div>
<div className="overflow-y-auto h-[calc(100%-50px)]">
{conversations.length === 0 ? (
<div className="p-4 text-center text-gray-500">
No conversations yet. Start a new one!
</div>
) : (
conversations.map(conv => (
<div
key={conv.id}
onClick={() => setSelectedConv(conv.id)}
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
selectedConv === conv.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex justify-between items-start">
<div className="font-medium text-gray-800">
{conv.subject || getOtherParticipants(conv).map(getDisplayName).join(', ') || 'Conversation'}
</div>
{conv.unread_count > 0 && (
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
{conv.unread_count}
</span>
)}
</div>
{conv.last_message && (
<div className="text-sm text-gray-500 truncate mt-1">
{conv.last_message}
</div>
)}
<div className="text-xs text-gray-400 mt-1">
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
</div>
</div>
))
)}
</div>
</div>
{/* Messages View */}
<div className={`flex-1 flex flex-col ${selectedConv ? '' : 'hidden md:flex'}`}>
{selectedConv ? (
<>
{/* Header */}
<div className="p-3 border-b bg-gray-50">
<button
onClick={() => setSelectedConv(null)}
className="md:hidden text-blue-600 mr-3"
>
Back
</button>
<span className="font-medium text-gray-700">
{participants.filter(p => p.id !== user?.id).map(getDisplayName).join(', ')}
</span>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${msg.sender_id === user?.id ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[70%] ${msg.sender_id === user?.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
{msg.sender_id !== user?.id && (
<div className="text-xs font-medium mb-1 text-gray-600">
{msg.first_name || msg.username}
</div>
)}
<div>{msg.content}</div>
<div className={`text-xs mt-1 ${msg.sender_id === user?.id ? 'text-blue-200' : 'text-gray-400'}`}>
{formatDisplayDateTime(msg.created_at)}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t bg-gray-50">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={sendMessage}
disabled={!newMessage.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
Select a conversation or start a new one
</div>
)}
</div>
</div>
</div>
</div>
{/* New Conversation Modal */}
{showNewConv && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h2 className="text-xl font-bold mb-4">New Conversation</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
<input
type="text"
value={convSubject}
onChange={e => setConvSubject(e.target.value)}
placeholder="What is this about?"
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
<textarea
value={convMessage}
onChange={e => setConvMessage(e.target.value)}
placeholder="Type your message..."
rows={4}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowNewConv(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={createConversation}
disabled={!convMessage.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
)}
</div>
);
}
+101 -54
View File
@@ -5,6 +5,7 @@ import Slideshow from '@/components/Slideshow';
import CalendarStrip from '@/components/CalendarStrip';
import WeatherWidget from '@/components/WeatherWidget';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import Link from 'next/link';
import { t, useLanguage } from '@/lib/i18n';
const gold = '#E8A849';
@@ -13,94 +14,140 @@ const cream = '#f5f0e8';
const homeCopy = {
en: {
title: 'La Huasca',
subtitle: 'A mountain retreat with warm hospitality and local flavor.',
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
tagline: 'Welcome to the Heart of Imbabura',
cta_rooms: 'Book a Room',
cta_menu: 'View Our Menu',
cta_contact: 'Contact Us',
},
es: {
title: 'La Huasca',
subtitle: 'Un refugio de montaña con hospitalidad cálida y sabor local.',
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
tagline: 'Bienvenidos al Corazón de Imbabura',
cta_rooms: 'Reservar Habitación',
cta_menu: 'Ver Menú',
cta_contact: 'Contáctenos',
},
} as const;
const fallbackTaglineKey = 'common.home_tagline';
export default function HomePage() {
const language = useLanguage();
const [tagline, setTagline] = useState(t(fallbackTaglineKey, language));
useEffect(() => {
let cancelled = false;
const fallbackTagline = t(fallbackTaglineKey, language);
setTagline(fallbackTagline);
fetch('/api/site-assets?key=tagline')
.then((r) => r.json())
.then((j) => {
if (!cancelled && j.data) setTagline(j.data);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [language]);
const copy = homeCopy[language];
return (
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
<LanguageSwitcher />
<div style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}>
<div style={{ position: 'absolute', top: '4rem', right: '1rem', zIndex: 10 }}>
<WeatherWidget />
</div>
<Slideshow />
{/* Compact CTA section */}
<section
style={{
padding: 'clamp(3rem, 9vw, 7rem) 2rem',
padding: 'clamp(1.5rem, 4vw, 2.5rem) 2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: '1.5rem',
}}
>
<p
style={{
fontFamily: 'monospace',
fontSize: '12px',
fontSize: '11px',
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: gold,
margin: '0 0 1rem',
margin: 0,
}}
>
{tagline}
</p>
<h1
style={{
fontSize: 'clamp(44px, 7vw, 84px)',
fontWeight: 400,
fontStyle: 'italic',
lineHeight: 1.08,
margin: '0 0 1.25rem',
color: cream,
}}
>
{copy.title}
</h1>
<p style={{ fontSize: '18px', lineHeight: 1.55, color: 'rgba(245,240,232,0.78)', maxWidth: '52ch', margin: '0 0 2rem' }}>
{copy.highlights.map((key, index) => (
<span key={key}>
{t(key, language)}
{index < copy.highlights.length - 1 ? ' · ' : ''}
</span>
))}
</p>
<p style={{ fontSize: '16px', lineHeight: 1.65, color: 'rgba(245,240,232,0.7)', maxWidth: '58ch', margin: 0 }}>
{copy.subtitle}
{copy.tagline}
</p>
{/* CTA Buttons */}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', justifyContent: 'center' }}>
<Link
href="/rooms"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.875rem 1.75rem',
background: gold,
color: navy,
fontWeight: 600,
fontSize: '15px',
borderRadius: '8px',
textDecoration: 'none',
transition: 'transform 200ms, box-shadow 200ms',
boxShadow: '0 4px 12px rgba(232,168,73,0.25)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(232,168,73,0.35)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(232,168,73,0.25)';
}}
>
🛏 {copy.cta_rooms}
</Link>
<Link
href="/coffee"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.875rem 1.75rem',
background: 'transparent',
color: cream,
fontWeight: 600,
fontSize: '15px',
borderRadius: '8px',
textDecoration: 'none',
border: `2px solid ${gold}`,
transition: 'background 200ms, color 200ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = gold;
e.currentTarget.style.color = navy;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = cream;
}}
>
🍽 {copy.cta_menu}
</Link>
<Link
href="/contact"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.875rem 1.75rem',
background: 'transparent',
color: 'rgba(245,240,232,0.8)',
fontWeight: 600,
fontSize: '15px',
borderRadius: '8px',
textDecoration: 'none',
border: '2px solid rgba(245,240,232,0.3)',
transition: 'border-color 200ms, color 200ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(245,240,232,0.6)';
e.currentTarget.style.color = cream;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(245,240,232,0.3)';
e.currentTarget.style.color = 'rgba(245,240,232,0.8)';
}}
>
{copy.cta_contact}
</Link>
</div>
</section>
<CalendarStrip />
+567 -46
View File
@@ -1,6 +1,9 @@
'use client';
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;
@@ -10,6 +13,7 @@ interface Room {
photos: string[];
featured_photo: string | null;
active?: boolean;
amenities: Array<{ id: number; name: string; icon_svg: string }>;
}
interface RoomComment {
@@ -29,6 +33,7 @@ interface User {
role: 'admin' | 'user';
first_name: string | null;
last_name: string | null;
email?: string;
comments_disabled: boolean;
}
@@ -57,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) => {
@@ -73,7 +87,21 @@ export default function RoomsPage() {
fetch('/api/auth/me', { credentials: 'same-origin' })
.then((r) => r.json())
.then((j) => setUser(j.user || null))
.then((j) => {
if (j.authenticated) {
setUser({
id: j.id,
username: j.username,
role: j.role,
first_name: j.first_name,
last_name: j.last_name,
email: j.email,
comments_disabled: j.comments_disabled,
});
} else {
setUser(null);
}
})
.catch(() => {});
}, []);
@@ -138,23 +166,185 @@ 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 (
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}>
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms &amp; Suites</h1>
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
Comfortable stays with views of the vineyard and cloud forest.
Comfortable stays with views of the cloud forest.
</p>
{loading && <p style={{ color: warm }}>Loading rooms...</p>}
{loading && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '2rem' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{ background: cream, borderRadius: '20px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
<div style={{ width: '100%', height: '300px', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite' }} />
<div style={{ padding: '1.5rem' }}>
<div style={{ height: '24px', width: '60%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.75rem' }} />
<div style={{ height: '16px', width: '90%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.5rem' }} />
<div style={{ height: '16px', width: '75%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.5rem' }} />
<div style={{ height: '16px', width: '50%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px' }} />
</div>
</div>
))}
</div>
)}
<style jsx>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
@@ -184,65 +374,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 ? (
<img
src={activeSrc}
alt={room.name}
style={{
width: '100%',
height: '300px',
objectFit: 'cover',
display: 'block',
}}
/>
activeSrc.startsWith('data:') ? (
<img
src={activeSrc}
alt={room.name}
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 ─── */}
@@ -278,7 +459,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' }} />
)}
@@ -290,13 +485,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)}
@@ -378,7 +665,7 @@ export default function RoomsPage() {
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
</strong>
<span style={{ fontSize: '12px', color: '#777' }}>
{new Date(c.created_at).toLocaleDateString()}
{formatDisplayDate(c.created_at)}
</span>
</div>
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
@@ -401,6 +688,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>
);
}
+8 -7
View File
@@ -1,6 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { useState, useEffect } from 'react';
import { formatDisplayDateFull } from '@/lib/date';
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
const warm = '#a6683c';
interface SocialEvent {
id: number;
@@ -23,11 +29,6 @@ interface User {
comments_disabled?: boolean;
}
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
const warm = '#a6683c';
export default function SocialEventsPage() {
const [events, setEvents] = useState<SocialEvent[]>([]);
const [loading, setLoading] = useState(true);
@@ -167,7 +168,7 @@ export default function SocialEventsPage() {
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{event.name}</h2>
{event.date && (
<p style={{ margin: '0 0 0.5rem', color: warm, fontSize: '15px', fontWeight: 600 }}>
{new Date(event.date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
{formatDisplayDateFull(event.date)}
</p>
)}
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{event.description}</p>
+310 -138
View File
@@ -1,34 +1,36 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { formatDisplayDate, formatDisplayDateTime } from '@/lib/date';
interface Reservation {
interface Trip {
id: number;
date: string;
time: string;
guests: number;
table_name: string;
room_id: number | null;
room_name: string | null;
check_in: string;
check_out: string;
notes: string | null;
status: string;
}
interface Coupon {
interface Message {
id: number;
code: string;
discount: string;
sender_role: 'user' | 'admin';
message: string;
created_at: string;
}
interface Offer {
id: number;
title: string;
description: string;
interface Weather {
current: { celsius: number; fahrenheit: number; summary: string; windspeed: number; weathercode: number };
today: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
}
interface PortalData {
reservations: Reservation[];
coupons: Coupon[];
offers: Offer[];
benefits: string[];
trips: Trip[];
wifiPassword: string;
messages: Message[];
weather?: Weather;
}
const gold = '#E8A849';
@@ -40,24 +42,76 @@ export default function UserPage() {
const router = useRouter();
const [data, setData] = useState<PortalData | null>(null);
const [loading, setLoading] = useState(true);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fetchData = async () => {
try {
const res = await fetch('/api/user/portal', { credentials: 'same-origin' });
if (!res.ok) {
if (res.status === 401) {
router.push('/login');
return;
}
throw new Error('Failed to load portal data');
}
const json = await res.json();
setData(json);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const fetchMessages = async () => {
try {
const res = await fetch('/api/user/messages', { credentials: 'same-origin' });
if (res.ok) {
const json = await res.json();
setData(prev => prev ? { ...prev, messages: json.messages } : null);
}
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetch('/api/user/portal', { credentials: 'same-origin' })
.then(async (res) => {
if (!res.ok) {
if (res.status === 401) {
router.push('/login');
return;
}
throw new Error('Failed to load portal data');
}
return res.json();
})
.then((json) => setData(json))
.catch((err) => console.error(err))
.finally(() => setLoading(false));
fetchData();
}, [router]);
useEffect(() => {
const interval = setInterval(fetchMessages, 10000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [data?.messages]);
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || sending) return;
setSending(true);
try {
const res = await fetch('/api/user/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ message: newMessage.trim() }),
});
if (res.ok) {
setNewMessage('');
fetchMessages();
}
} catch (err) {
console.error(err);
} finally {
setSending(false);
}
};
if (loading) {
return (
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
@@ -67,135 +121,253 @@ export default function UserPage() {
}
if (!data) {
return <main style={{ padding: '2rem' }}><p>Please log in as user to access this page.</p></main>;
return <main style={{ padding: '2rem' }}><p>Please log in to access this page.</p></main>;
}
const today = new Date().toISOString().split('T')[0];
const activeTrip = data.trips.find(t => t.check_in <= today && t.check_out >= today);
const upcomingTrip = data.trips.find(t => t.check_in > today);
return (
<main style={{ padding: '2rem', maxWidth: '56rem', margin: '0 auto' }}>
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 2rem' }}>
Member Portal
<main style={{ padding: '2rem', maxWidth: '64rem', margin: '0 auto' }}>
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 1.5rem' }}>
Your Portal
</h1>
<Section title="Future Reservations">
{data.reservations.length === 0 ? (
<p style={{ color: '#777' }}>No upcoming reservations.</p>
) : (
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{data.reservations.map((r) => (
<li
key={r.id}
style={{
padding: '1rem 1.25rem',
background: cream,
borderRadius: '16px',
boxShadow: '0 1px 2px rgba(1,13,30,0.08)',
color: navy,
}}
>
<strong style={{ color: warm }}>{r.date}</strong> at {r.time} · {r.guests} guests · {r.table_name}
</li>
))}
</ul>
)}
</Section>
<Section title="Discount Coupons">
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
{data.coupons.map((c) => (
<div
key={c.id}
style={{
padding: '1.25rem',
background: cream,
border: '2px dashed ' + warm,
borderRadius: '16px',
minWidth: '13rem',
color: navy,
}}
>
<strong style={{ fontSize: '18px', color: warm }}>{c.code}</strong>
<p style={{ margin: '0.5rem 0 0', color: '#555', fontSize: '14px' }}>{c.discount}</p>
{/* Weather Widget - Always visible below navbar */}
{data.weather && (
<div style={{ background: 'linear-gradient(135deg, #010D1E 0%, #1a2a3a 100%)', borderRadius: '12px', padding: '0.75rem 1.25rem', marginTop: '5rem', marginBottom: '2rem', display: 'flex', alignItems: 'center', gap: '1.5rem', flexWrap: 'wrap', boxShadow: '0 2px 8px rgba(1,13,30,0.15)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ fontSize: '32px' }}>
{data.weather.current.weathercode <= 1 ? '☀️' : data.weather.current.weathercode === 2 ? '⛅' : data.weather.current.weathercode === 3 ? '☁️' : data.weather.current.weathercode >= 51 && data.weather.current.weathercode <= 55 ? '🌧️' : data.weather.current.weathercode >= 61 ? '🌧️' : '☀️'}
</div>
))}
<div>
<div style={{ fontSize: '22px', fontWeight: 600, color: '#fff' }}>{data.weather.current.celsius}°C</div>
<div style={{ fontSize: '11px', color: 'rgba(255,255,255,0.7)' }}>{data.weather.current.summary}</div>
</div>
</div>
<div style={{ display: 'flex', gap: '1.5rem', flex: 1, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>High</div>
<div style={{ fontSize: '16px', fontWeight: 500, color: '#E8A849' }}>{data.weather.today.maxC}°</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Low</div>
<div style={{ fontSize: '16px', fontWeight: 500, color: '#88b4e8' }}>{data.weather.today.minC}°</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Wind</div>
<div style={{ fontSize: '14px', color: '#fff' }}>{data.weather.current.windspeed} km/h</div>
</div>
</div>
</div>
</Section>
)}
<Section title="Offers">
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{data.offers.map((o) => (
<li
key={o.id}
style={{
padding: '1rem 1.25rem',
background: cream,
borderRadius: '16px',
color: navy,
}}
>
<strong style={{ color: warm }}>{o.title}</strong> {o.description}
</li>
))}
</ul>
</Section>
{/* Welcome Message - Show on reservation day */}
{activeTrip && (
<Section title="Welcome!" highlight>
<div style={{ background: 'linear-gradient(135deg, #E8A849 0%, #d4963f 100%)', padding: '1.5rem', borderRadius: '16px', color: '#fff' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '20px', fontWeight: 600 }}>
Welcome to Hosteria La Huasca!
</p>
<p style={{ margin: '0', opacity: 0.9 }}>
Your room{activeTrip.room_name ? ` (${activeTrip.room_name})` : ''} is ready. Check-out: {formatDisplayDate(activeTrip.check_out, { weekday: 'long', month: 'short', day: 'numeric' })}
</p>
</div>
</Section>
)}
{/* Future Trip Info */}
{upcomingTrip && (
<Section title="Your Upcoming Trip">
<div style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<div>
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-in</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{formatDisplayDate(upcomingTrip.check_in, { weekday: 'short', month: 'short', day: 'numeric' })}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-out</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{formatDisplayDate(upcomingTrip.check_out, { weekday: 'short', month: 'short', day: 'numeric' })}</div>
</div>
{upcomingTrip.room_name && (
<div>
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Room</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{upcomingTrip.room_name}</div>
</div>
)}
</div>
{upcomingTrip.notes && (
<div style={{ fontSize: '14px', color: '#555', marginTop: '0.5rem' }}>
<strong>Notes:</strong> {upcomingTrip.notes}
</div>
)}
<div style={{ marginTop: '1rem', fontSize: '13px', color: '#777' }}>
Status: <span style={{ color: warm, fontWeight: 500, textTransform: 'capitalize' }}>{upcomingTrip.status.replace('_', ' ')}</span>
</div>
</div>
</Section>
)}
{/* WiFi Password */}
<Section title="WiFi Password">
<div
style={{
display: 'inline-block',
padding: '0.9rem 2rem',
background: navy,
color: cream,
borderRadius: '12px',
fontFamily: 'monospace',
fontSize: '1.4rem',
letterSpacing: '0.04em',
boxShadow: '0 4px 12px rgba(1,13,30,0.2)',
}}
>
{data.wifiPassword}
<div style={{ display: 'inline-block', padding: '0.9rem 2rem', background: navy, color: cream, borderRadius: '12px', fontFamily: 'monospace', fontSize: '1.4rem', letterSpacing: '0.04em', boxShadow: '0 4px 12px rgba(1,13,30,0.2)' }}>
{data.wifiPassword || 'Not available'}
</div>
</Section>
<Section title="Member Benefits">
<ul style={{ padding: 0, listStyle: 'none', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
{data.benefits.map((b) => (
<li
key={b}
style={{
padding: '0.9rem 1.1rem',
background: '#e8eff3',
borderLeft: '4px solid ' + gold,
borderRadius: '0 12px 12px 0',
color: navy,
}}
>
{b}
</li>
))}
</ul>
{/* Change Password */}
<Section title="Change Password">
<ChangePassword />
</Section>
{/* Chat with Admins */}
<Section title="Message Staff">
<div style={{ background: cream, borderRadius: '16px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
<div style={{ maxHeight: '300px', overflowY: 'auto', padding: '1rem' }}>
{data.messages.length === 0 ? (
<div style={{ textAlign: 'center', color: '#777', padding: '2rem' }}>
No messages yet. Send a message to contact our staff.
</div>
) : (
data.messages.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: m.sender_role === 'user' ? 'flex-end' : 'flex-start', marginBottom: '0.75rem' }}>
<div style={{ maxWidth: '70%', padding: '0.75rem 1rem', borderRadius: '12px', background: m.sender_role === 'user' ? gold : '#fff', color: m.sender_role === 'user' ? '#fff' : navy, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '0.25rem' }}>
{m.sender_role === 'admin' ? 'Staff' : 'You'}
</div>
<div style={{ fontSize: '14px' }}>{m.message}</div>
<div style={{ fontSize: '11px', opacity: 0.7, marginTop: '0.25rem' }}>
{formatDisplayDateTime(m.created_at)}
</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} style={{ display: 'flex', gap: '0.5rem', padding: '1rem', borderTop: '1px solid rgba(0,0,0,0.1)' }}>
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type a message..."
disabled={sending}
style={{ flex: 1, padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
/>
<button type="submit" disabled={sending || !newMessage.trim()} style={{ padding: '0.75rem 1.5rem', background: gold, color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 600, cursor: sending ? 'wait' : 'pointer', opacity: sending ? 0.7 : 1 }}>
Send
</button>
</form>
</div>
</Section>
</main>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({ title, children, highlight }: { title: string; children: React.ReactNode; highlight?: boolean }) {
return (
<section style={{ marginBottom: '2.5rem' }}>
<h2
style={{
fontSize: '20px',
fontWeight: 600,
letterSpacing: '-0.01em',
color: navy,
margin: '0 0 1rem',
paddingBottom: '0.4rem',
borderBottom: '1.5px solid ' + warm,
display: 'inline-block',
}}
>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, letterSpacing: '-0.01em', color: highlight ? '#fff' : navy, margin: '0 0 1rem', paddingBottom: '0.4rem', borderBottom: highlight ? 'none' : '1.5px solid ' + warm, display: 'inline-block' }}>
{title}
</h2>
{children}
</section>
);
}
function ChangePassword() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
if (newPassword.length < 4) {
setMessage({ text: 'Password must be at least 4 characters', type: 'error' });
return;
}
if (newPassword !== confirmPassword) {
setMessage({ text: 'Passwords do not match', type: 'error' });
return;
}
setSaving(true);
try {
const res = await fetch('/api/user/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
if (res.ok) {
setMessage({ text: 'Password changed successfully!', type: 'success' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
const err = await res.json();
setMessage({ text: err.error || 'Failed to change password', type: 'error' });
}
} catch {
setMessage({ text: 'Error changing password', type: 'error' });
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)', maxWidth: '400px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
/>
</div>
{message && (
<div style={{ padding: '0.75rem', borderRadius: '8px', background: message.type === 'success' ? '#d4edda' : '#f8d7da', color: message.type === 'success' ? '#155724' : '#721c24', fontSize: '14px' }}>
{message.text}
</div>
)}
<button
type="submit"
disabled={saving || !currentPassword || !newPassword || !confirmPassword}
style={{ padding: '0.75rem 1.5rem', background: gold, color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 600, cursor: saving ? 'wait' : 'pointer', opacity: saving ? 0.7 : 1 }}
>
{saving ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
);
}
+92 -36
View File
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { getEcuadorToday, getEcuadorDateFromToday, daysUntilEcuador, getMonthDayEcuador } from '@/lib/date';
interface CalendarEvent {
id: number;
@@ -12,8 +13,32 @@ interface CalendarEvent {
}
interface WeatherData {
current?: { celsius: number; fahrenheit: number; summary: string };
today?: { maxC: number; minC: number; maxF: number; minF: number; summary: string };
current?: { celsius: number; fahrenheit: number; summary: string; weathercode: number };
today?: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
tomorrow?: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
dayAfter?: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
}
const weatherIcons: Record<number, string> = {
0: '☀️', 1: '🌤️', 2: '⛅', 3: '☁️',
45: '🌫️', 48: '🌫️', 51: '🌦️', 53: '🌦️', 55: '🌧️',
61: '🌧️', 63: '🌧️', 65: '🌧️', 71: '🌨️', 73: '🌨️',
75: '🌨️', 80: '🌦️', 81: '🌧️', 82: '🌧️',
95: '⛈️', 96: '⛈️', 99: '⛈️'
};
function getWeatherIcon(code: number | undefined): string {
return code !== undefined ? (weatherIcons[code] || '🌤️') : '🌤️';
}
function getDateLabel(daysFromToday: number): { month: string; day: number; label: string } {
const d = new Date();
d.setDate(d.getDate() + daysFromToday);
return {
month: d.toLocaleString('en-US', { month: 'short' }),
day: d.getDate(),
label: daysFromToday === 0 ? 'Today' : daysFromToday === 1 ? 'Tomorrow' : 'Day After'
};
}
const gold = '#E8A849';
@@ -26,22 +51,6 @@ const typeEmoji: Record<CalendarEvent['type'], string> = {
event: '📌',
};
function formatMonthDay(dateStr: string) {
const d = new Date(dateStr + 'T00:00:00');
return { month: d.toLocaleString('en-US', { month: 'short' }), day: d.getDate() };
}
function daysUntil(dateStr: string) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(dateStr + 'T00:00:00');
const diff = Math.ceil((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diff === 0) return 'Today';
if (diff === 1) return 'Tomorrow';
if (diff < 0) return 'Past';
return `in ${diff} days`;
}
export default function CalendarStrip() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [weather, setWeather] = useState<WeatherData | null>(null);
@@ -60,21 +69,40 @@ export default function CalendarStrip() {
.catch(() => {});
}, []);
const cards = events.map((e) => ({ ...e, isWeather: false })).concat(
weather
? [
{
id: 9999,
date: new Date().toISOString().split('T')[0],
title: `${weather.current?.celsius ?? '--'}°C / ${weather.current?.fahrenheit ?? '--'}°F ${weather.current?.summary ?? ''}`,
type: 'weather' as const,
description: `Today ${weather.today?.maxC ?? '--'}°C / ${weather.today?.maxF ?? '--'}°F · ${weather.today?.minC ?? '--'}°C / ${weather.today?.minF ?? '--'}°F`,
color: '#4FC3F7',
isWeather: true,
},
]
: []
);
// Build weather cards: today, tomorrow, day after
const weatherCards = weather
? [
{
id: 9997,
date: getEcuadorToday(),
title: `${getWeatherIcon(weather.today?.weathercode)} ${weather.current?.celsius ?? '--'}°C`,
type: 'weather' as const,
description: `Today: ${weather.today?.maxC ?? '--'}° / ${weather.today?.minC ?? '--'}°C · ${weather.today?.summary ?? ''}`,
color: '#4FC3F7',
isWeather: true,
},
{
id: 9998,
date: getEcuadorDateFromToday(1),
title: `${getWeatherIcon(weather.tomorrow?.weathercode)} ${weather.tomorrow?.maxC ?? '--'}° / ${weather.tomorrow?.minC ?? '--'}°C`,
type: 'weather' as const,
description: `Tomorrow: ${weather.tomorrow?.summary ?? ''}`,
color: '#81D4FA',
isWeather: true,
},
{
id: 9999,
date: getEcuadorDateFromToday(2),
title: `${getWeatherIcon(weather.dayAfter?.weathercode)} ${weather.dayAfter?.maxC ?? '--'}° / ${weather.dayAfter?.minC ?? '--'}°C`,
type: 'weather' as const,
description: `Day After: ${weather.dayAfter?.summary ?? ''}`,
color: '#4DD0E1',
isWeather: true,
},
]
: [];
const cards = [...events.map((e) => ({ ...e, isWeather: false })), ...weatherCards];
if (cards.length === 0) return null;
@@ -86,22 +114,50 @@ export default function CalendarStrip() {
overflow: 'hidden',
borderTop: `1px solid rgba(232,168,73,0.25)`,
borderBottom: `1px solid rgba(232,168,73,0.25)`,
position: 'relative',
}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{/* Left fade mask */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '80px',
background: `linear-gradient(to right, ${navy} 0%, transparent 100%)`,
pointerEvents: 'none',
zIndex: 10,
}}
/>
{/* Right fade mask */}
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '80px',
background: `linear-gradient(to left, ${navy} 0%, transparent 100%)`,
pointerEvents: 'none',
zIndex: 10,
}}
/>
<div
style={{
display: 'flex',
gap: '1rem',
padding: '0 1rem',
width: 'max-content',
animation: paused ? 'none' : 'marquee 38s linear infinite',
animation: 'marquee 60s linear infinite',
animationPlayState: paused ? 'paused' : 'running',
}}
ref={trackRef}
>
{[...cards, ...cards].map((e, i) => {
const { month, day } = formatMonthDay(e.date);
const { month, day } = getMonthDayEcuador(e.date);
const accent = e.color || gold;
return (
<div
@@ -156,7 +212,7 @@ export default function CalendarStrip() {
{e.description || ''}
</p>
<div style={{ marginTop: '0.8rem', fontSize: '12px', color: 'rgba(245,240,232,0.5)' }}>
{e.isWeather ? 'Otavalo now' : daysUntil(e.date)}
{e.isWeather ? 'Otavalo now' : daysUntilEcuador(e.date)}
</div>
</div>
);
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { FacebookIcon, InstagramIcon, WhatsAppIcon } from './icons';
interface Config {
tagline?: string;
facebook_url?: string;
instagram_url?: string;
whatsapp_url?: string;
@@ -47,7 +48,7 @@ export default function Footer() {
<div>
<h3 style={{ color: gold, fontSize: '18px', margin: '0 0 1rem', fontStyle: 'italic' }}>La Huasca</h3>
<p style={{ color: 'rgba(245,240,232,0.75)', lineHeight: 1.55, margin: 0 }}>
A quiet corner of the Ecuadorian Andes.
{config.tagline || 'A quiet corner of the Ecuadorian Andes.'}
</p>
</div>
+23 -12
View File
@@ -4,22 +4,33 @@ 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">
<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>
);
}
return (
<svg width="24" height="16" viewBox="0 0 24 16" fill="none">
<rect width="24" height="16" fill="white" />
<rect y="0" width="24" height="5.33" fill="#EF3340" />
<rect y="10.67" width="24" height="5.33" fill="#EF3340" />
<rect y="5.33" width="24" height="5.33" fill="#073685" />
<path d="M12 8L10.39 9.18L11.02 7.24L9.39 6.82L11.2 6.82L12 5L12.81 6.82L14.62 6.82L12.98 7.24L13.61 9.18L12 8Z" fill="#FFD100" />
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" aria-hidden="true">
<rect width="24" height="8" fill="#FFD100" />
<rect y="8" width="24" height="4" fill="#073685" />
<rect y="12" width="24" height="4" fill="#EF3340" />
<circle cx="12" cy="8.2" r="2.2" fill="#FFFFFF" opacity="0.95" />
<circle cx="12" cy="8.2" r="1.1" fill="#EF3340" opacity="0.8" />
</svg>
);
}
@@ -37,8 +48,8 @@ export default function LanguageSwitcher() {
onClick={toggleLanguage}
style={{
position: 'fixed',
top: '1rem',
left: '1rem',
top: '0.5rem',
right: '7rem',
zIndex: 1000,
background: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ddd',
+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>
);
}
+12 -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' });
@@ -85,6 +85,17 @@ export default function Navbar() {
})}
</ul>
<div className="navbar-actions">
{auth?.authenticated && (
<Link
href={auth?.role === 'admin' ? '/admin/messages' : '/messages'}
className="navbar-icon-link"
title="Messages"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</Link>
)}
{auth?.authenticated ? (
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button>
) : (
+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>
);
}
+46 -64
View File
@@ -2,72 +2,46 @@
import { useState, useEffect } from 'react';
function celsiusToFahrenheit(c: number): number {
return Math.round((c * 9/5) + 32);
}
export default function WeatherWidget() {
const [weather, setWeather] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<any>(null);
// Load weather configuration
useEffect(() => {
const loadConfig = async () => {
try {
const res = await fetch('/api/site-assets?key=weather_config');
if (res.ok) {
const data = await res.json();
if (data.data) {
const parsedConfig = JSON.parse(data.data);
setConfig(parsedConfig);
}
}
} catch (err) {
console.error('Failed to load weather config:', err);
}
};
loadConfig();
}, []);
// Fetch weather data when config is loaded and enabled
useEffect(() => {
if (!config || !config.enabled || config.cities.length === 0) {
setLoading(false);
return;
}
const fetchWeather = async () => {
try {
setLoading(true);
setError(null);
// Use the first city in the list for now
const city = config.cities[0];
const res = await fetch(`/api/weather?lat=${city.lat}&lon=${city.lon}`);
const res = await fetch('/api/weather');
if (res.ok) {
const data = await res.json();
setWeather(data);
setWeather(data.data);
} else {
throw new Error('Failed to fetch weather data');
}
} catch (err) {
console.error('Weather API error:', err);
setError('Failed to load weather data');
setError('Failed to load weather');
} finally {
setLoading(false);
}
};
fetchWeather();
}, [config]);
if (!config?.enabled) return null;
}, []);
if (loading) {
return (
<div style={{
background: 'rgba(255, 255, 255, 0.9)',
borderRadius: '12px',
padding: '12px 16px',
padding: '40px 16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)'
@@ -78,49 +52,57 @@ export default function WeatherWidget() {
}
if (error || !weather) {
return (
<div style={{
background: 'rgba(255, 255, 255, 0.9)',
borderRadius: '12px',
padding: '12px 16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)'
}}>
<div style={{ fontSize: '14px', color: '#666' }}>Weather unavailable</div>
</div>
);
return null;
}
const current = weather.current;
const temp = current.celsius;
const icon = '🌤️';
const today = weather.today;
const weatherIcons: Record<number, string> = {
0: '☀️', 1: '🌤️', 2: '⛅', 3: '☁️',
45: '🌫️', 48: '🌫️', 51: '🌦️', 53: '🌦️', 55: '🌦️',
61: '🌧️', 63: '🌧️', 65: '🌧️', 71: '🌨️', 73: '🌨️',
75: '🌨️', 80: '🌦️', 81: '🌦️', 82: '🌧️',
95: '⛈️', 96: '⛈️', 99: '⛈️'
};
const icon = weatherIcons[current.weathercode] || '🌤️';
const tempF = celsiusToFahrenheit(current.celsius);
const maxF = celsiusToFahrenheit(today.maxC);
const minF = celsiusToFahrenheit(today.minC);
return (
<div style={{
background: 'rgba(255, 255, 255, 0.9)',
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px',
padding: '12px 16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)',
minWidth: '180px'
border: '1px solid rgba(255,255,255,0.3)',
minWidth: '160px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{ fontSize: '20px' }}>{icon}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '28px' }}>{icon}</span>
<div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{temp}°C
<div style={{ fontSize: '18px', fontWeight: '600', color: '#1a1a2e' }}>
{current.celsius}°C / {tempF}°F
</div>
<div style={{ fontSize: '11px', color: '#666', marginTop: '2px' }}>
{current.summary}
</div>
{config.cities[0] && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
{config.cities[0].name}
</div>
)}
</div>
</div>
<div style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}>
Updated: {new Date(current.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid rgba(0,0,0,0.1)',
fontSize: '11px',
color: '#555'
}}>
<span>📍 Otavalo</span>
<span>{today.maxC}°C ({maxF}°F) {today.minC}°C ({minF}°F)</span>
</div>
</div>
);
+1 -10
View File
@@ -18,17 +18,8 @@ export function useAuth() {
}, []);
const login = useCallback((username: string, password: string): boolean => {
// TODO: Implement server-side authentication via API call
if (typeof window === 'undefined') return false;
if (username === 'admin' && password === 'admin') {
localStorage.setItem(ROLE_KEY, 'admin');
setRole('admin');
return true;
}
if (username === 'user' && password === 'user') {
localStorage.setItem(ROLE_KEY, 'user');
setRole('user');
return true;
}
return false;
}, []);
+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),
});
}
+23 -7
View File
@@ -17,30 +17,46 @@ export async function verifyPassword(password: string, hash: string) {
return bcrypt.compareSync(password, hash);
}
export async function createUser(username: string, password: string, role: 'admin' | 'user') {
export async function createUser(
username: string,
password: string,
role: 'admin' | 'user',
profile?: { first_name?: string; last_name?: string; email?: string; phone?: string; country?: string; preferred_language?: string }
) {
const hash = await hashPassword(password);
const { rows } = await db.query(
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = EXCLUDED.role RETURNING id, username, role',
[username, hash, role]
`INSERT INTO users (username, password_hash, role, first_name, last_name, email, phone, country, preferred_language)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = EXCLUDED.role
RETURNING id, username, role, first_name, last_name, email, phone, country, preferred_language`,
[username, hash, role, profile?.first_name || null, profile?.last_name || null, profile?.email || null, profile?.phone || null, profile?.country || null, profile?.preferred_language || 'es']
);
return rows[0];
}
export async function authenticateUser(username: string, password: string) {
const { rows } = await db.query('SELECT id, username, password_hash, role FROM users WHERE username = $1', [username]);
const { rows } = await db.query('SELECT id, username, password_hash, role, preferred_language, terms_accepted_at, first_name, last_name FROM users WHERE username = $1', [username]);
if (rows.length === 0) return null;
const user = rows[0];
const valid = await verifyPassword(password, user.password_hash);
if (!valid) return null;
return { id: user.id, username: user.username, role: user.role };
return {
id: user.id,
username: user.username,
role: user.role,
preferred_language: user.preferred_language || 'es',
terms_accepted_at: user.terms_accepted_at,
first_name: user.first_name,
last_name: user.last_name
};
}
export async function createSession(user: { id: number; username: string; role: string }) {
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.setExpirationTime('4h') // 4 hours for security (reduced from 7 days)
.sign(getSecret());
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 7 });
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 4 }); // 4 hours
return token;
}
+125
View File
@@ -0,0 +1,125 @@
// Date utilities for La Huasca - Ecuador is GMT-5 (no DST)
/**
* Get current date/time in Ecuador timezone (GMT-5)
*/
export function getEcuadorDate(): Date {
const now = new Date();
// Ecuador is UTC-5, so subtract 5 hours from UTC
const ecuadorOffset = -5 * 60; // minutes
const utcOffset = now.getTimezoneOffset(); // local offset from UTC
const ecuadorTime = new Date(now.getTime() + (utcOffset + ecuadorOffset) * 60000);
return ecuadorTime;
}
/**
* Get today's date in Ecuador (YYYY-MM-DD format)
*/
export function getEcuadorToday(): string {
return formatDateEcuador(getEcuadorDate());
}
/**
* Format a date as YYYY-MM-DD in Ecuador timezone
*/
export function formatDateEcuador(date: Date): string {
const ecuadorOffset = -5 * 60; // minutes
const utc = date.getTime() + date.getTimezoneOffset() * 60000;
const ecuadorTime = new Date(utc + ecuadorOffset * 60000);
const year = ecuadorTime.getFullYear();
const month = String(ecuadorTime.getMonth() + 1).padStart(2, '0');
const day = String(ecuadorTime.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Get a date N days from today in Ecuador timezone
*/
export function getEcuadorDateFromToday(days: number): string {
const today = getEcuadorDate();
today.setDate(today.getDate() + days);
return formatDateEcuador(today);
}
/**
* Format a date string (YYYY-MM-DD or ISO) for display in Ecuador timezone
*/
export function formatDisplayDate(dateStr: string, options?: Intl.DateTimeFormatOptions): string {
const date = parseDateAsEcuador(dateStr);
const defaultOptions: Intl.DateTimeFormatOptions = {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'America/Guayaquil',
};
return date.toLocaleDateString('en-US', options || defaultOptions);
}
/**
* Format a date with full weekday name
*/
export function formatDisplayDateFull(dateStr: string): string {
return formatDisplayDate(dateStr, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Guayaquil',
});
}
/**
* Format a datetime string for display
*/
export function formatDisplayDateTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Guayaquil',
});
}
/**
* Parse a date string (YYYY-MM-DD) as Ecuador date, avoiding timezone shifts
*/
export function parseDateAsEcuador(dateStr: string): Date {
// Parse as local components to avoid timezone shifts
const parts = dateStr.split('T')[0].split('-');
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1; // JS months are 0-indexed
const day = parseInt(parts[2], 10);
return new Date(year, month, day);
}
/**
* Calculate days until a date (in Ecuador timezone)
*/
export function daysUntilEcuador(dateStr: string): string {
const today = getEcuadorDate();
today.setHours(0, 0, 0, 0);
const target = parseDateAsEcuador(dateStr);
target.setHours(0, 0, 0, 0);
const diff = Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (isNaN(diff)) return 'Unknown';
if (diff === 0) return 'Today';
if (diff === 1) return 'Tomorrow';
if (diff < 0) return 'Past';
return `in ${diff} days`;
}
/**
* Get month and day for display (e.g., "Jun 29")
*/
export function getMonthDayEcuador(dateStr: string): { month: string; day: number } {
const date = parseDateAsEcuador(dateStr);
return {
month: date.toLocaleString('en-US', { month: 'short' }),
day: date.getDate(),
};
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Production-safe error handling utility
* Returns detailed errors in development, generic errors in production
*/
export function getErrorMessage(error: unknown, fallback = 'An error occurred'): string {
if (process.env.NODE_ENV === 'production') {
return fallback;
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return fallback;
}
/**
* Create a standardized error response
*/
export function errorResponse(message: string, status = 500, details?: Record<string, unknown>) {
const body: Record<string, unknown> = { error: message };
// Only include details in development
if (process.env.NODE_ENV !== 'production' && details) {
body.details = details;
}
return Response.json(body, { status });
}
+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 };
}
+386 -10
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,
@@ -100,10 +106,16 @@ export async function initSchema() {
is_daily_special BOOLEAN DEFAULT FALSE,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
await db.query(`
CREATE TABLE IF NOT EXISTS places (
id SERIAL PRIMARY KEY,
@@ -120,6 +132,10 @@ export async function initSchema() {
);
`);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
await db.query(`
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
@@ -168,11 +184,17 @@ export async function initSchema() {
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
is_private BOOLEAN DEFAULT FALSE,
max_guests INTEGER,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Add is_private and max_guests columns if they don't exist
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE`);
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS max_guests INTEGER`);
await db.query(`
CREATE TABLE IF NOT EXISTS social_event_reservations (
id SERIAL PRIMARY KEY,
@@ -186,16 +208,16 @@ export async function initSchema() {
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(20) NOT NULL
);
`);
await db.query(`CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(20) NOT NULL,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);`);
await db.query(`
CREATE TABLE IF NOT EXISTS user_sessions (
@@ -222,6 +244,38 @@ export async function initSchema() {
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT 'es';
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMP;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS country VARCHAR(100);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS terms_content (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version VARCHAR(20) NOT NULL DEFAULT '1.0',
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(language, version)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS availability (
id SERIAL PRIMARY KEY,
@@ -233,6 +287,260 @@ export async function initSchema() {
UNIQUE(room_id, date)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS trips (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
notes TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'confirmed' CHECK (status IN ('pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled')),
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
sender_role VARCHAR(20) NOT NULL CHECK (sender_role IN ('user', 'admin')),
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_amenities (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
icon_svg TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_amenity_links (
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
amenity_id INTEGER REFERENCES room_amenities(id) ON DELETE CASCADE,
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)`);
// ─── Conversations & Direct Messages ───
await db.query(`
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
subject VARCHAR(255),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS conversation_participants (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(conversation_id, user_id)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS direct_messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_conversation_participants_user ON conversation_participants(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation ON direct_messages(conversation_id, created_at DESC)`);
// ─── Bulk Messages ───
await db.query(`
CREATE TABLE IF NOT EXISTS bulk_messages (
id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id),
subject VARCHAR(255),
content TEXT NOT NULL,
recipient_type VARCHAR(20) NOT NULL DEFAULT 'all_customers',
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS bulk_message_recipients (
id SERIAL PRIMARY KEY,
bulk_message_id INTEGER NOT NULL REFERENCES bulk_messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
read_at TIMESTAMP,
UNIQUE(bulk_message_id, user_id)
);
`);
// 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)`);
// Room assignments - admins assign registered users to rooms
await db.query(`
CREATE TABLE IF NOT EXISTS room_assignments (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, check_in, check_out)
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_room ON room_assignments(room_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_user ON room_assignments(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_dates ON room_assignments(check_in, check_out)`);
// 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() {
@@ -250,6 +558,20 @@ export async function migrateFromLegacyPlaces() {
await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
// Add room status columns for housekeeping management
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS clean_status VARCHAR(20) DEFAULT 'clean'`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS notes TEXT`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS last_cleaned TIMESTAMP`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assigned_staff_id INTEGER REFERENCES users(id)`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS priority VARCHAR(10) DEFAULT 'normal'`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS issues_count INTEGER DEFAULT 0`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS is_vip BOOLEAN DEFAULT FALSE`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_time TIME`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_date DATE`);
// Add payment_status to room_reservations
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) DEFAULT 'pending'`);
}
export async function seed() {
@@ -314,6 +636,60 @@ export async function seed() {
if (configTaglineRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', tagline]);
}
const { rows: calendarRows } = await db.query(`SELECT id FROM calendar_events`);
if (calendarRows.length === 0) {
await db.query(
`INSERT INTO calendar_events (date, title, type, description, color, active) VALUES ($1, $2, $3, $4, $5, $6)`,
['2026-06-30', 'Welcome Dinner', 'event', 'Andean tasting menu', '#b45309', true]
);
}
const { rows: socialRows } = await db.query(`SELECT id FROM social_events`);
if (socialRows.length === 0) {
await db.query(
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order) VALUES ($1, $2, $3, $4, '{}', null, $5, $6)`,
['Sunset Tasting', 'Local wines on the terrace.', 'USD 35', '2026-07-04', true, 1]
);
}
const { rows: socialReservationRows } = await db.query(`SELECT id FROM social_event_reservations`);
if (socialReservationRows.length === 0) {
const { rows: adminIdRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
if (adminIdRows.length > 0) {
await db.query(
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes, status) SELECT $1, id, $2, $3, $4 FROM social_events LIMIT 1`,
[adminIdRows[0].id, 2, 'Anniversary', 'confirmed']
);
}
}
const { rows: amenityRows } = await db.query(`SELECT id FROM room_amenities`);
if (amenityRows.length === 0) {
const defaultAmenities = [
['Wi-Fi', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>', 1],
['TV', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg>', 2],
['Shower', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V4"/><path d="M4 9h4"/><path d="M6 9v12"/><path d="M10 4h10a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H10"/><circle cx="6" cy="14" r="1"/><circle cx="6" cy="18" r="1"/><circle cx="10" cy="14" r="1"/><circle cx="10" cy="18" r="1"/></svg>', 3],
['Bathtub', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16a1 1 0 0 1 1 1v3a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4v-3a1 1 0 0 1 1-1z"/><path d="M6 12V5a2 2 0 0 1 2-2h3v2.25"/><circle cx="12" cy="5" r=".5"/></svg>', 4],
['Fireplace', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.14-4.052 1.5-5 .5 2 2 4 2 6a2.5 2.5 0 0 0 5 0c0-1.38-.5-2-1-3-.5-1-1-2-1-3.5 1.5 1 3 2.5 3 5a4 4 0 1 1-8 0"/><path d="M4 19h16a2 2 0 0 0 2-2v-2H2v2a2 2 0 0 0 2 2z"/></svg>', 5],
['Minibar', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3z"/><path d="M5 9v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>', 6],
['Coffee Maker', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>', 7],
['Safe', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>', 8],
['Heating', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/></svg>', 9],
['Balcony', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><line x1="3" y1="17" x2="21" y2="17"/><line x1="7" y1="11" x2="7" y2="21"/><line x1="12" y1="11" x2="12" y2="21"/><line x1="17" y1="11" x2="17" y2="21"/></svg>', 10],
['King Bed', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4"/><path d="M2 14v-2a2 2 0 0 1 2-2h1"/><path d="M22 14v-2a2 2 0 0 0-2-2h-1"/><path d="M5 4h14a2 2 0 0 1 2 2v2H3V6a2 2 0 0 1 2-2z"/><rect x="3" y="10" width="18" height="4" rx="1"/></svg>', 11],
['Queen Bed', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3"/><path d="M2 13v-2a2 2 0 0 1 2-2h1"/><path d="M18 13v-2a2 2 0 0 0-2-2h-1"/><path d="M4 5h10a2 2 0 0 1 2 2v1H2V7a2 2 0 0 1 2-2z"/><rect x="2" y="9" width="14" height="3" rx="1"/></svg>', 13],
['2 Queen Beds', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 15v-2a1.5 1.5 0 0 1 1.5-1.5H10a1.5 1.5 0 0 1 1.5 1.5v2"/><path d="M12.5 15v-2a1.5 1.5 0 0 1 1.5-1.5H21.5a1.5 1.5 0 0 1 1.5 1.5v2"/><path d="M1 11V9.5A1.5 1.5 0 0 1 2.5 8h8A1.5 1.5 0 0 1 12 9.5V11"/><path d="M12 11V9.5A1.5 1.5 0 0 1 13.5 8h8A1.5 1.5 0 0 1 23 9.5V11"/><rect x="1" y="8" width="11" height="2.5" rx="0.75"/><rect x="12" y="8" width="11" height="2.5" rx="0.75"/></svg>', 14],
['Mountain View', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>', 12],
];
for (const [name, icon_svg, sort_order] of defaultAmenities) {
await db.query(
'INSERT INTO room_amenities (name, icon_svg, sort_order) VALUES ($1, $2, $3)',
[name, icon_svg, sort_order]
);
}
}
}
export async function resetDatabase() {
+45 -23
View File
@@ -1,40 +1,62 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Simple middleware for Next.js Edge Runtime
// This is a basic implementation that doesn't verify tokens
// In a production environment, you would want to verify the token
export function middleware(request: NextRequest) {
// Paths that require authentication
const PROTECTED_PATHS = ['/admin'];
// Paths that require no authentication (login page should not redirect logged-in users)
const AUTH_PATHS = ['/login'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Protect admin routes
if (pathname.startsWith('/admin')) {
// Get session token from cookies
const token = request.cookies.get('session')?.value;
// Check if this is a protected path
const isProtectedPath = PROTECTED_PATHS.some(path => pathname.startsWith(path));
const isAuthPath = AUTH_PATHS.some(path => pathname.startsWith(path));
// If no token, redirect to login
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
if (isProtectedPath) {
// Get session token from cookie
const sessionToken = request.cookies.get('session')?.value;
if (!sessionToken) {
// No session, redirect to login
const loginUrl = new URL('/login', request.url);
return NextResponse.redirect(loginUrl);
}
// Note: In a real implementation, you would verify the token here
// For now, we're just checking that a token exists
// Session exists - actual auth validation happens in API routes
// We can't verify JWT in Edge runtime without external libraries
// The login page and API routes handle full auth checks
}
// Redirect authenticated users from login page to home
if (pathname === '/login') {
const token = request.cookies.get('session')?.value;
if (token) {
// In a real implementation, you would verify the token and check role
// For now, we'll just redirect to home
return NextResponse.redirect(new URL('/', request.url));
}
// Add security headers to all responses
const response = NextResponse.next();
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy - allow inline styles for Next.js
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob: https:; " +
"font-src 'self' data:; " +
"connect-src 'self'; " +
"frame-ancestors 'none';"
);
// HSTS for production (assuming TLS termination at proxy)
if (process.env.NODE_ENV === 'production') {
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return NextResponse.next();
return response;
}
// Configure which paths the middleware should run on
export const config = {
matcher: ['/admin/:path*', '/login'],
matcher: ['/admin/:path*', '/login', '/api/:path*'],
};
+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: [] } });
});
});