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 #!/bin/bash
set -e
# Database initialization script for La Huasca (local version)
# This script creates the necessary tables and seeds initial data
set -e # Exit on any error
echo "Initializing La Huasca database (local version)..." echo "Initializing La Huasca database (local version)..."
# Execute the schema directly
echo "Executing database schema..."
sudo -u postgres psql -d lahuasca << 'EOF' sudo -u postgres psql -d lahuasca << 'EOF'
-- Create all tables -- Create all tables
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -19,6 +13,11 @@ CREATE TABLE IF NOT EXISTS users (
comments_disabled BOOLEAN DEFAULT FALSE, comments_disabled BOOLEAN DEFAULT FALSE,
first_name VARCHAR(100), first_name VARCHAR(100),
last_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() created_at TIMESTAMP DEFAULT NOW()
); );
@@ -123,7 +122,7 @@ CREATE TABLE IF NOT EXISTS slides (
title VARCHAR(255), title VARCHAR(255),
subtitle TEXT, subtitle TEXT,
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL, image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
image_url VARCHAR(500), image_url TEXT,
active BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
@@ -171,7 +170,19 @@ CREATE TABLE IF NOT EXISTS calendar_events (
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL, type VARCHAR(100) NOT NULL,
description TEXT, 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 ( CREATE TABLE IF NOT EXISTS user_sessions (
@@ -181,31 +192,45 @@ CREATE TABLE IF NOT EXISTS user_sessions (
created_at TIMESTAMP DEFAULT NOW() 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 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 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 first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_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 -- Fix slides.image_url to TEXT (was VARCHAR(500), too short for base64)
-- Default admin user (password: admin) ALTER TABLE slides ALTER COLUMN image_url TYPE TEXT;
-- Seed initial data
INSERT INTO users (username, password_hash, role) VALUES INSERT INTO users (username, password_hash, role) VALUES
('admin', '$2a$10$8K1p/a0dhrxiowP.dnkgNORTWgdEDHn5L2/xjpEWuC.QQv4rKO9jO', 'admin') ('admin', '$2a$10$8K1p/a0dhrxiowP.dnkgNORTWgdEDHn5L2/xjpEWuC.QQv4rKO9jO', 'admin')
ON CONFLICT (username) DO NOTHING; ON CONFLICT (username) DO NOTHING;
-- Default config values
INSERT INTO config (key, value) VALUES INSERT INTO config (key, value) VALUES
('wifi_password', 'LaHuascaGuest2026'), ('wifi_password', 'LaHuascaGuest2026'),
('tagline', 'Hotel · Restaurant · Andean Highlands') ('tagline', 'Hotel · Restaurant · Andean Highlands')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
-- Sample offers INSERT INTO offers (title, description) VALUES
INSERT INTO offers (title, description, active, sort_order) VALUES ('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.'),
('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.', true, 1), ('Sunset Dinner', 'Three-course menu on the terrace every Friday.')
('Sunset Dinner', 'Three-course menu on the terrace every Friday.', true, 2)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Sample benefits
INSERT INTO benefits (text) VALUES INSERT INTO benefits (text) VALUES
('Priority booking for peak weekends'), ('Priority booking for peak weekends'),
('Late checkout on availability'), ('Late checkout on availability'),
@@ -214,8 +239,6 @@ INSERT INTO benefits (text) VALUES
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
EOF EOF
# Verify that the tables were created
echo "Verifying database tables..." echo "Verifying database tables..."
sudo -u postgres psql -d lahuasca -c "\dt" sudo -u postgres psql -d lahuasca -c "\dt"
echo "Database initialization completed successfully!" 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, title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL, type VARCHAR(100) NOT NULL,
description TEXT, 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 ( CREATE TABLE IF NOT EXISTS user_sessions (
+1
View File
@@ -1,3 +1,4 @@
# WARNING: Replace CHANGE_ME_* values before applying
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: 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", "@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"jose": "^5.6.3", "jose": "^5.6.3",
"next": "14.2.28", "next": "^14.2.35",
"pg": "^8.12.0", "pg": "^8.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"sharp": "^0.35.2"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -28,6 +29,7 @@
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vitest": "^4.1.9" "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>
);
}
+2143 -80
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 { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth'; import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; 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) { export async function GET(request: NextRequest) {
try { try {
@@ -9,7 +52,7 @@ export async function GET(request: NextRequest) {
const category = searchParams.get('category'); const category = searchParams.get('category');
const roomId = searchParams.get('room_id'); 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 conditions: string[] = [];
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
@@ -28,7 +71,7 @@ export async function GET(request: NextRequest) {
} }
const { rows } = await db.query(query, params); 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) { } catch (error) {
if ((error as Error).message === 'Unauthorized') { if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 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 }); 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( 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', '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, data, category || 'other', room_id || null, location_target || 'gallery'] [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) { } catch (error) {
if ((error as Error).message === 'Unauthorized') { if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 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 { NextResponse } from 'next/server';
import { requireRole, createUser } from '@/lib/auth';
import { db } from '@/lib/db'; 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() { export async function GET() {
try { try {
await requireRole('admin'); 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) { const { rows } = await db.query(`
try { SELECT id, username, first_name, last_name, role, email, created_at
await requireRole('admin'); FROM users
const body = await request.json(); ORDER BY role, first_name, last_name
const { username, password, role = 'user' } = body; `);
if (!username || !password) { return NextResponse.json({ users: rows });
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 }); } catch (error) {
} console.error('Get users error:', error);
return NextResponse.json(
if (role !== 'admin' && role !== 'user') { { error: getErrorMessage(error, 'Failed to get users') },
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 }); { status: 500 }
}
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]
); );
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); 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) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 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 }); return NextResponse.json({ authenticated: false }, { status: 401 });
} }
const { rows } = await db.query( 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] [session.id]
); );
const user = rows[0] || {}; const user = rows[0] || {};
@@ -19,6 +19,7 @@ export async function GET() {
id: session.id, id: session.id,
first_name: user.first_name || null, first_name: user.first_name || null,
last_name: user.last_name || null, last_name: user.last_name || null,
email: user.email || null,
comments_disabled: user.comments_disabled === true, 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 { NextResponse } from 'next/server';
import { createUser } from '@/lib/auth'; import { createUser } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
export async function POST() { export async function POST() {
try { try {
@@ -12,7 +13,10 @@ export async function POST() {
const user = await createUser(username, password, 'admin'); const user = await createUser(username, password, 'admin');
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } }); return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
} catch (err: any) { } catch (err: unknown) {
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 }); 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: idStr } = await params;
const id = parseInt(idStr, 10); const id = parseInt(idStr, 10);
const body = await request.json(); 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( 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 `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 = $8 RETURNING *`, WHERE id = $10 RETURNING *`,
[category, name, description || '', price, is_daily_special, active, sort_order || 0, id] [category, name, description || '', price, is_daily_special, active, sort_order || 0, photos || [], featured_photo || null, id]
); );
if (rows.length === 0) { 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 } // 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( db.query(
'UPDATE menu_items SET sort_order = $1 WHERE id = $2', '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 { try {
await requireRole('admin'); await requireRole('admin');
const body = await request.json(); 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 === '') { if (!category || !name || price === undefined || price === '') {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order) `INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order, photos, featured_photo)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`, 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] }); 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 photoUrl = searchParams.get('photo_url') || null;
const { rows } = await db.query( 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 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`, ORDER BY created_at DESC`,
[roomId, photoUrl] [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 lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null;
const { rows: inserted } = await db.query( const { rows: inserted } = await db.query(
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text) `INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text, status)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING *`,
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()] [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) { } catch (err: any) {
return NextResponse.json( return NextResponse.json(
{ error: err.message || 'Failed to post comment' }, { error: err.message || 'Failed to post comment' },
+65 -29
View File
@@ -1,53 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; 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 { try {
const roomId = parseInt(params.id, 10); const roomId = parseInt(params.id, 10);
if (isNaN(roomId)) { const session = await getSession();
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
// 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([ return NextResponse.json({ totalLikes, likedByUser });
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,
});
} catch (err: any) { } catch (err: any) {
// Table may not exist yet — return safe defaults return NextResponse.json({ error: err.message || 'Failed to get likes' }, { status: 500 });
return NextResponse.json({ count: 0, user_liked: false });
} }
} }
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 { try {
const roomId = parseInt(params.id, 10); const session = await getSession();
if (isNaN(roomId)) { if (!session) {
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
// TODO: get user_id from session/auth instead of hardcoded const roomId = parseInt(params.id, 10);
const userId = 1;
// Toggle: if already liked, unlike; otherwise like // Check if already liked
const existing = await db.query( const { rows: existing } = await db.query(
'SELECT id FROM room_likes WHERE room_id = $1 AND user_id = $2', 'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, userId] [roomId, session.id]
); );
if (existing.rows.length > 0) { let liked: boolean;
await db.query('DELETE FROM room_likes WHERE id = $1', [existing.rows[0].id]); 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 { } 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) { } catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to toggle like' }, { status: 500 }); 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 }); if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]); const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]);
if (!rows.length) return NextResponse.json({ error: 'Room not found' }, { status: 404 }); 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) { } catch (error: any) {
console.error('GET /api/rooms/[id]:', error); console.error('GET /api/rooms/[id]:', error);
return NextResponse.json({ error: error.message }, { status: 500 }); 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); const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const body = await request.json(); 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) { if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Photos are stored in images table, not rooms.photos
const { rows } = await db.query( 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 *`, `UPDATE rooms SET name = $1, description = $2, price = $3, sort_order = $4, featured_photo = $5, active = $6 WHERE id = $7 RETURNING *`,
[name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id] [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) { } catch (error: any) {
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
console.error('PUT /api/rooms/[id]:', error); console.error('PUT /api/rooms/[id]:', error);
+80 -4
View File
@@ -5,7 +5,43 @@ import { db } from '@/lib/db';
export async function GET() { export async function GET() {
try { try {
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id'); 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) { } catch (error: any) {
console.error('GET /api/rooms error:', error); console.error('GET /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to fetch rooms' }, { status: 500 }); return NextResponse.json({ error: error.message || 'Failed to fetch rooms' }, { status: 500 });
@@ -16,22 +52,62 @@ export async function POST(request: NextRequest) {
try { try {
await requireRole('admin'); await requireRole('admin');
const body = await request.json(); const body = await request.json();
const { name, description, price, photos, sort_order, featured_photo } = body; const { name, description, price, sort_order, featured_photo, amenity_ids } = body;
if (!name || !description || price === undefined) { if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Photos are now stored in images table, not rooms.photos
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo) `INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`, 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) { } catch (error: any) {
console.error('POST /api/rooms error:', error); console.error('POST /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 }); 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 { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; 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) { export async function GET(request: NextRequest) {
try { 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 session = await requireAuth();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const socialEventId = searchParams.get('social_event_id'); const socialEventId = searchParams.get('social_event_id');
const status = searchParams.get('status');
let query = ` 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 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 JOIN social_events se ON se.id = ser.social_event_id
`; `;
const params: (number | string)[] = []; const params: (number | string)[] = [];
if (session.role === 'admin') { if (session.role === 'admin') {
const conditions: string[] = [];
let paramCount = 1;
if (socialEventId) { if (socialEventId) {
query += ' WHERE ser.social_event_id = $1'; conditions.push(`ser.social_event_id = $${paramCount++}`);
params.push(parseInt(socialEventId, 10)); 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 { } else {
query += ' WHERE ser.user_id = $1'; query += ' WHERE ser.user_id = $1';
params.push(session.id); params.push(session.id);
@@ -41,23 +52,38 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const session = await requireAuth();
const body = await request.json(); 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) { if (!social_event_id || !guests) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); 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( const { rows } = await db.query(
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes) `INSERT INTO social_event_reservations
VALUES ($1, $2, $3, $4) (user_id, social_event_id, guests, notes, status, contact_name, contact_email, contact_phone, contact_method)
ON CONFLICT (user_id, social_event_id) VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
DO UPDATE SET guests = EXCLUDED.guests, notes = EXCLUDED.notes, status = 'pending'
RETURNING *`, 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] }); return NextResponse.json({ data: rows[0] });
} catch (error: any) { } catch (error: any) {
console.error('POST /api/social_event_reservations error:', error); 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'); await requireRole('admin');
const id = parseInt(params.id, 10); const id = parseInt(params.id, 10);
const body = await request.json(); const body = await request.json();
const { name, description, price, 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( const { rows } = await db.query(
`UPDATE social_events `UPDATE social_events
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8 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=$9 WHERE id=$11
RETURNING *`, 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 }); if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ data: rows[0] }); 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 { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export async function GET() { export async function GET(request: NextRequest) {
try { 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 se.*,
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count (SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
FROM social_events se 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 }); return NextResponse.json({ data: rows });
} catch (error: any) { } catch (error: any) {
console.error('GET /api/social_events error:', error); console.error('GET /api/social_events error:', error);
@@ -22,12 +33,12 @@ export async function POST(request: NextRequest) {
try { try {
await requireRole('admin'); await requireRole('admin');
const body = await request.json(); const body = await request.json();
const { name, description, price, 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( const { rows } = await db.query(
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order) `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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`, 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] }); return NextResponse.json({ data: rows[0] });
} catch (error: any) { } 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 { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const dynamic = 'force-dynamic'; // Prevents static generation export const dynamic = 'force-dynamic';
export async function GET() { export async function GET() {
try { try {
@@ -32,12 +32,34 @@ export async function GET() {
); );
const wifiPassword = configRows[0]?.value || ''; 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({ return NextResponse.json({
reservations, reservations,
coupons, coupons,
offers, offers,
benefits: benefits.map((b: { text: string }) => b.text), benefits: benefits.map((b: { text: string }) => b.text),
wifiPassword, wifiPassword,
trips,
weather,
}); });
} catch (error) { } catch (error) {
if ((error as Error).message === 'Unauthorized') { 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], weathercode: daily.weathercode?.[0],
summary: codeMap[daily.weathercode?.[0]] || 'Unknown', 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) { } catch (error: any) {
+492 -29
View File
@@ -9,6 +9,21 @@ interface MenuItem {
description: string; description: string;
price: string; price: string;
is_daily_special: boolean; 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'; const gold = '#E8A849';
@@ -18,15 +33,60 @@ const warm = '#a6683c';
export default function CoffeePage() { export default function CoffeePage() {
const [items, setItems] = useState<MenuItem[]>([]); 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 [loading, setLoading] = useState(true);
const [error, setError] = useState(''); 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(() => { useEffect(() => {
fetch('/api/menu') Promise.all([
.then(async (res) => { fetch('/api/menu').then(async (res) => {
if (!res.ok) throw new Error('Failed to load menu'); if (!res.ok) throw new Error('Failed to load menu');
const json = await res.json(); return res.json();
setItems(json.data || []); }),
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)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -35,12 +95,111 @@ export default function CoffeePage() {
const categories = Array.from(new Set(items.map((i) => i.category))); const categories = Array.from(new Set(items.map((i) => i.category)));
const daily = items.find((i) => i.is_daily_special); 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 ( return (
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}> <main style={{ padding: 'clamp(1rem, 5vw, 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> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}> <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. Locally sourced coffee, fresh pastries, and seasonal plates.
</p> </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>} {loading && <p style={{ color: warm }}>Loading menu...</p>}
{error && <p style={{ color: '#c23b22' }}>{error}</p>} {error && <p style={{ color: '#c23b22' }}>{error}</p>}
@@ -50,10 +209,10 @@ export default function CoffeePage() {
style={{ style={{
background: `linear-gradient(135deg, ${navy} 0%, #0e2f47 100%)`, background: `linear-gradient(135deg, ${navy} 0%, #0e2f47 100%)`,
color: cream, color: cream,
borderRadius: '20px', borderRadius: '16px',
padding: '1.5rem 2rem', padding: 'clamp(1rem, 4vw, 1.5rem) clamp(1.25rem, 5vw, 2rem)',
marginBottom: '2rem', marginBottom: '1.5rem',
boxShadow: '0 6px 20px rgba(1,13,30,0.2)', boxShadow: '0 4px 16px rgba(1,13,30,0.2)',
}} }}
> >
<span <span
@@ -61,62 +220,366 @@ export default function CoffeePage() {
display: 'inline-block', display: 'inline-block',
background: gold, background: gold,
color: navy, color: navy,
fontSize: '12px', fontSize: '11px',
fontWeight: 700, fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.1em', letterSpacing: '0.08em',
padding: '0.35rem 0.8rem', padding: '0.3rem 0.7rem',
borderRadius: '999px', borderRadius: '999px',
marginBottom: '0.75rem', marginBottom: '0.5rem',
}} }}
> >
Menu of the Day Menu of the Day
</span> </span>
<h2 style={{ margin: '0 0 0.5rem', fontSize: '26px' }}>{daily.name}</h2> <h2 style={{ margin: '0 0 0.35rem', fontSize: 'clamp(20px, 4vw, 26px)' }}>{daily.name}</h2>
<p style={{ margin: '0 0 1rem', opacity: 0.85 }}>{daily.description}</p> <p style={{ margin: '0 0 0.75rem', opacity: 0.85, fontSize: 'clamp(14px, 3vw, 16px)' }}>{daily.description}</p>
<strong style={{ color: gold, fontSize: '20px' }}>${daily.price}</strong> <strong style={{ color: gold, fontSize: 'clamp(18px, 4vw, 20px)' }}>${daily.price}</strong>
</section> </section>
)} )}
{!loading && items.length === 0 && <p style={{ color: '#777' }}>No menu items yet.</p>} {!loading && items.length === 0 && <p style={{ color: '#777' }}>No menu items yet.</p>}
{categories.map((category) => ( {categories.map((category) => (
<section key={category} style={{ marginBottom: '2rem' }}> <section key={category} style={{ marginBottom: '1.5rem' }}>
<h2 <h2
style={{ style={{
fontSize: '20px', fontSize: 'clamp(16px, 4vw, 20px)',
color: navy, color: navy,
borderBottom: '2px solid ' + warm, borderBottom: '2px solid ' + warm,
display: 'inline-block', display: 'inline-block',
paddingBottom: '0.3rem', paddingBottom: '0.25rem',
margin: '0 0 1rem', margin: '0 0 0.75rem',
}} }}
> >
{category} {category}
</h2> </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 {items
.filter((i) => i.category === category) .filter((i) => i.category === category)
.map((item) => ( .map((item) => (
<div <div
key={item.id} key={item.id}
style={{ style={{
padding: '1.25rem', padding: 'clamp(1rem, 4vw, 1.25rem)',
background: cream, background: cream,
borderRadius: '16px', borderRadius: '12px',
boxShadow: '0 1px 3px rgba(1,13,30,0.08)', boxShadow: '0 1px 3px rgba(1,13,30,0.08)',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.5rem' }}> {(item.photos && item.photos.length > 0) && (
<strong style={{ color: navy }}>{item.name}</strong> <div style={{ marginBottom: '0.6rem', background: '#e8e4dc', borderRadius: '10px', overflow: 'hidden' }}>
<span style={{ color: warm, fontWeight: 700 }}>${item.price}</span> <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>
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{item.description}</p> )}
<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: 'clamp(13px, 3vw, 14px)', lineHeight: 1.4 }}>{item.description}</p>
</div> </div>
))} ))}
</div> </div>
</section> </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> </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 { .navbar-actions {
margin-left: auto; 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 { .navbar-btn-ghost {
+1 -1
View File
@@ -4,7 +4,7 @@ import './globals.css';
export const metadata = { export const metadata = {
title: 'La Huasca', title: 'La Huasca',
description: 'Hotel, restaurant, and vineyard in the Honduran highlands', description: 'Hotel and restaurant in the Ecuadorian highlands',
}; };
export default function RootLayout({ export default function RootLayout({
+354 -12
View File
@@ -1,15 +1,59 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
type TermsData = {
id?: number;
language: string;
title: string;
content: string;
version: string;
};
export default function LoginPage() { export default function LoginPage() {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = 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 [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const router = useRouter(); 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(); e.preventDefault();
setError(null); setError(null);
@@ -27,9 +71,82 @@ export default function LoginPage() {
} }
const data = await res.json(); 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'); 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 accent = '#E8A849';
const navy = '#010D1E'; const navy = '#010D1E';
const cream = '#f5f0e8'; const cream = '#f5f0e8';
@@ -46,34 +163,74 @@ export default function LoginPage() {
}} }}
> >
<form <form
onSubmit={handleSubmit} onSubmit={mode === 'login' ? handleLogin : handleRegister}
style={{ style={{
width: '100%', width: '100%',
maxWidth: '26rem', maxWidth: '28rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '1.25rem', gap: '1rem',
padding: '2.5rem', padding: '2.5rem',
background: cream, background: cream,
borderRadius: '16px', borderRadius: '16px',
boxShadow: '0 6px 18px rgba(14,47,71,0.12)', 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 <h1
style={{ style={{
margin: '0 0 0.25rem', margin: '0',
fontSize: 'clamp(28px, 4vw, 40px)', fontSize: 'clamp(28px, 4vw, 40px)',
fontWeight: 400, fontWeight: 400,
fontStyle: 'italic', fontStyle: 'italic',
color: navy, color: navy,
}} }}
> >
Welcome back {mode === 'login'
? (preferredLanguage === 'es' ? 'Bienvenido de nuevo' : 'Welcome back')
: (preferredLanguage === 'es' ? 'Crear cuenta' : 'Create account')}
</h1> </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' }}> <label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
Username {preferredLanguage === 'es' ? 'Usuario' : 'Username'}
<input <input
type="text" type="text"
value={username} value={username}
@@ -91,7 +248,7 @@ export default function LoginPage() {
/> />
</label> </label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}> <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 <input
type="password" type="password"
value={password} value={password}
@@ -108,7 +265,161 @@ export default function LoginPage() {
required required
/> />
</label> </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>} {error && <p style={{ color: '#c23b22', margin: 0, fontSize: '14px' }}>{error}</p>}
<button <button
type="submit" type="submit"
style={{ style={{
@@ -131,11 +442,42 @@ export default function LoginPage() {
e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.boxShadow = 'none';
}} }}
> >
Log in {mode === 'login'
? (preferredLanguage === 'es' ? 'Iniciar sesión' : 'Log in')
: (preferredLanguage === 'es' ? 'Registrarse' : 'Register')}
</button> </button>
<p style={{ margin: '0.5rem 0 0', fontSize: '13px', color: '#777', textAlign: 'center' }}> <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> </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> </form>
</main> </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>
);
}
+98 -51
View File
@@ -5,6 +5,7 @@ import Slideshow from '@/components/Slideshow';
import CalendarStrip from '@/components/CalendarStrip'; import CalendarStrip from '@/components/CalendarStrip';
import WeatherWidget from '@/components/WeatherWidget'; import WeatherWidget from '@/components/WeatherWidget';
import LanguageSwitcher from '@/components/LanguageSwitcher'; import LanguageSwitcher from '@/components/LanguageSwitcher';
import Link from 'next/link';
import { t, useLanguage } from '@/lib/i18n'; import { t, useLanguage } from '@/lib/i18n';
const gold = '#E8A849'; const gold = '#E8A849';
@@ -13,94 +14,140 @@ const cream = '#f5f0e8';
const homeCopy = { const homeCopy = {
en: { en: {
title: 'La Huasca', tagline: 'Welcome to the Heart of Imbabura',
subtitle: 'A mountain retreat with warm hospitality and local flavor.', cta_rooms: 'Book a Room',
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'], cta_menu: 'View Our Menu',
cta_contact: 'Contact Us',
}, },
es: { es: {
title: 'La Huasca', tagline: 'Bienvenidos al Corazón de Imbabura',
subtitle: 'Un refugio de montaña con hospitalidad cálida y sabor local.', cta_rooms: 'Reservar Habitación',
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'], cta_menu: 'Ver Menú',
cta_contact: 'Contáctenos',
}, },
} as const; } as const;
const fallbackTaglineKey = 'common.home_tagline';
export default function HomePage() { export default function HomePage() {
const language = useLanguage(); 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]; const copy = homeCopy[language];
return ( return (
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}> <main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
<LanguageSwitcher /> <LanguageSwitcher />
<div style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}> <div style={{ position: 'absolute', top: '4rem', right: '1rem', zIndex: 10 }}>
<WeatherWidget /> <WeatherWidget />
</div> </div>
<Slideshow /> <Slideshow />
{/* Compact CTA section */}
<section <section
style={{ style={{
padding: 'clamp(3rem, 9vw, 7rem) 2rem', padding: 'clamp(1.5rem, 4vw, 2.5rem) 2rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
textAlign: 'center', textAlign: 'center',
gap: '1.5rem',
}} }}
> >
<p <p
style={{ style={{
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '12px', fontSize: '11px',
letterSpacing: '0.18em', letterSpacing: '0.18em',
textTransform: 'uppercase', textTransform: 'uppercase',
color: gold, color: gold,
margin: '0 0 1rem', margin: 0,
}} }}
> >
{tagline} {copy.tagline}
</p> </p>
<h1
{/* CTA Buttons */}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', justifyContent: 'center' }}>
<Link
href="/rooms"
style={{ style={{
fontSize: 'clamp(44px, 7vw, 84px)', display: 'inline-flex',
fontWeight: 400, alignItems: 'center',
fontStyle: 'italic', gap: '0.5rem',
lineHeight: 1.08, padding: '0.875rem 1.75rem',
margin: '0 0 1.25rem', background: gold,
color: cream, 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.title} 🛏 {copy.cta_rooms}
</h1> </Link>
<p style={{ fontSize: '18px', lineHeight: 1.55, color: 'rgba(245,240,232,0.78)', maxWidth: '52ch', margin: '0 0 2rem' }}> <Link
{copy.highlights.map((key, index) => ( href="/coffee"
<span key={key}> style={{
{t(key, language)} display: 'inline-flex',
{index < copy.highlights.length - 1 ? ' · ' : ''} alignItems: 'center',
</span> gap: '0.5rem',
))} padding: '0.875rem 1.75rem',
</p> background: 'transparent',
<p style={{ fontSize: '16px', lineHeight: 1.65, color: 'rgba(245,240,232,0.7)', maxWidth: '58ch', margin: 0 }}> color: cream,
{copy.subtitle} fontWeight: 600,
</p> 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> </section>
<CalendarStrip /> <CalendarStrip />
+563 -42
View File
@@ -1,6 +1,9 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { formatDisplayDate } from '@/lib/date';
import RoomCalendar from '@/components/RoomCalendar';
import OptimizedImage from '@/components/OptimizedImage';
interface Room { interface Room {
id: number; id: number;
@@ -10,6 +13,7 @@ interface Room {
photos: string[]; photos: string[];
featured_photo: string | null; featured_photo: string | null;
active?: boolean; active?: boolean;
amenities: Array<{ id: number; name: string; icon_svg: string }>;
} }
interface RoomComment { interface RoomComment {
@@ -29,6 +33,7 @@ interface User {
role: 'admin' | 'user'; role: 'admin' | 'user';
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
email?: string;
comments_disabled: boolean; comments_disabled: boolean;
} }
@@ -57,6 +62,15 @@ export default function RoomsPage() {
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({}); 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(() => { useEffect(() => {
fetch('/api/rooms') fetch('/api/rooms')
.then(async (res) => { .then(async (res) => {
@@ -73,7 +87,21 @@ export default function RoomsPage() {
fetch('/api/auth/me', { credentials: 'same-origin' }) fetch('/api/auth/me', { credentials: 'same-origin' })
.then((r) => r.json()) .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(() => {}); .catch(() => {});
}, []); }, []);
@@ -138,23 +166,185 @@ export default function RoomsPage() {
const getPhoto = (room: Room, index: number): string | null => { const getPhoto = (room: Room, index: number): string | null => {
if (!room.photos || room.photos.length === 0) return 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 => { const getActiveIndex = (room: Room): number => {
return activePhotoByRoom[room.id] ?? 0; 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); const displayedRooms = rooms.filter((r) => r.active !== false);
return ( return (
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}> <main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}>
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms &amp; Suites</h1> <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' }}> <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> </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>} {error && <p style={{ color: '#c23b22' }}>{error}</p>}
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</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)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
> >
{/* ─── Main Image ─── */} {/* ─── Main Image ─── */}
<div style={{ position: 'relative', width: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '300px' }}>
{activeSrc ? ( {activeSrc ? (
activeSrc.startsWith('data:') ? (
<img <img
src={activeSrc} src={activeSrc}
alt={room.name} alt={room.name}
style={{ style={{ width: '100%', height: '300px', objectFit: 'cover' }}
width: '100%',
height: '300px',
objectFit: 'cover',
display: 'block',
}}
/> />
) : (
<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' }}> <div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
No photo No photo
</div> </div>
)} )}
{/* Like button overlay */} {/* Like button with count */}
<button <button
onClick={() => toggleLike(room.id)} onClick={() => toggleLike(room.id)}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 12, top: 12,
left: 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', display: 'flex',
alignItems: 'center', 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', 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)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
aria-label="Like this room" aria-label="Like this room"
> >
{hasLiked ? '\u2764\uFE0F' : '\u2661'} <span style={{ fontSize: '16px' }}>{hasLiked ? '❤️' : '♡'}</span>
<span>{totalLikes}</span>
</button> </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> </div>
{/* ─── Thumbnail Strip ─── */} {/* ─── Thumbnail Strip ─── */}
@@ -278,7 +459,21 @@ export default function RoomsPage() {
}} }}
> >
{p ? ( {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' }} /> <div style={{ width: '100%', height: '100%', background: '#ddd' }} />
)} )}
@@ -290,13 +485,105 @@ export default function RoomsPage() {
{/* ─── Room Info ─── */} {/* ─── Room Info ─── */}
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}> <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> <h2 style={{ margin: 0, color: navy, fontSize: '24px' }}>{room.name}</h2>
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p> <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' }}> <div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span> <span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
<span style={{ fontSize: '13px', color: '#777' }}>per night</span> <span style={{ fontSize: '13px', color: '#777' }}>per night</span>
</div> </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 */} {/* Comments button — cream */}
<button <button
onClick={() => setExpandedRoom(isExpanded ? null : room.id)} onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
@@ -378,7 +665,7 @@ export default function RoomsPage() {
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''} {c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
</strong> </strong>
<span style={{ fontSize: '12px', color: '#777' }}> <span style={{ fontSize: '12px', color: '#777' }}>
{new Date(c.created_at).toLocaleDateString()} {formatDisplayDate(c.created_at)}
</span> </span>
</div> </div>
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p> <p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
@@ -401,6 +688,240 @@ export default function RoomsPage() {
); );
})} })}
</div> </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> </main>
); );
} }
+8 -7
View File
@@ -1,6 +1,12 @@
'use client'; '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 { interface SocialEvent {
id: number; id: number;
@@ -23,11 +29,6 @@ interface User {
comments_disabled?: boolean; comments_disabled?: boolean;
} }
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
const warm = '#a6683c';
export default function SocialEventsPage() { export default function SocialEventsPage() {
const [events, setEvents] = useState<SocialEvent[]>([]); const [events, setEvents] = useState<SocialEvent[]>([]);
const [loading, setLoading] = useState(true); 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> <h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{event.name}</h2>
{event.date && ( {event.date && (
<p style={{ margin: '0 0 0.5rem', color: warm, fontSize: '15px', fontWeight: 600 }}> <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>
)} )}
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{event.description}</p> <p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{event.description}</p>
+301 -129
View File
@@ -1,34 +1,36 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { formatDisplayDate, formatDisplayDateTime } from '@/lib/date';
interface Reservation { interface Trip {
id: number; id: number;
date: string; room_id: number | null;
time: string; room_name: string | null;
guests: number; check_in: string;
table_name: string; check_out: string;
notes: string | null;
status: string;
} }
interface Coupon { interface Message {
id: number; id: number;
code: string; sender_role: 'user' | 'admin';
discount: string; message: string;
created_at: string;
} }
interface Offer { interface Weather {
id: number; current: { celsius: number; fahrenheit: number; summary: string; windspeed: number; weathercode: number };
title: string; today: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
description: string;
} }
interface PortalData { interface PortalData {
reservations: Reservation[]; trips: Trip[];
coupons: Coupon[];
offers: Offer[];
benefits: string[];
wifiPassword: string; wifiPassword: string;
messages: Message[];
weather?: Weather;
} }
const gold = '#E8A849'; const gold = '#E8A849';
@@ -40,10 +42,13 @@ export default function UserPage() {
const router = useRouter(); const router = useRouter();
const [data, setData] = useState<PortalData | null>(null); const [data, setData] = useState<PortalData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { const fetchData = async () => {
fetch('/api/user/portal', { credentials: 'same-origin' }) try {
.then(async (res) => { const res = await fetch('/api/user/portal', { credentials: 'same-origin' });
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
router.push('/login'); router.push('/login');
@@ -51,13 +56,62 @@ export default function UserPage() {
} }
throw new Error('Failed to load portal data'); throw new Error('Failed to load portal data');
} }
return res.json(); const json = await res.json();
}) setData(json);
.then((json) => setData(json)) } catch (err) {
.catch((err) => console.error(err)) console.error(err);
.finally(() => setLoading(false)); } 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(() => {
fetchData();
}, [router]); }, [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) { if (loading) {
return ( return (
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}> <main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
@@ -67,135 +121,253 @@ export default function UserPage() {
} }
if (!data) { 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 ( return (
<main style={{ padding: '2rem', maxWidth: '56rem', margin: '0 auto' }}> <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 2rem' }}> <h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 1.5rem' }}>
Member Portal Your Portal
</h1> </h1>
<Section title="Future Reservations"> {/* Weather Widget - Always visible below navbar */}
{data.reservations.length === 0 ? ( {data.weather && (
<p style={{ color: '#777' }}>No upcoming reservations.</p> <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' }}>
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ fontSize: '32px' }}>
{data.reservations.map((r) => ( {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 ? '🌧️' : '☀️'}
<li </div>
key={r.id} <div>
style={{ <div style={{ fontSize: '22px', fontWeight: 600, color: '#fff' }}>{data.weather.current.celsius}°C</div>
padding: '1rem 1.25rem', <div style={{ fontSize: '11px', color: 'rgba(255,255,255,0.7)' }}>{data.weather.current.summary}</div>
background: cream, </div>
borderRadius: '16px', </div>
boxShadow: '0 1px 2px rgba(1,13,30,0.08)', <div style={{ display: 'flex', gap: '1.5rem', flex: 1, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
color: navy, <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>
<strong style={{ color: warm }}>{r.date}</strong> at {r.time} · {r.guests} guests · {r.table_name} </div>
</li> <div style={{ textAlign: 'center' }}>
))} <div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Low</div>
</ul> <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="Discount Coupons"> {/* Welcome Message - Show on reservation day */}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}> {activeTrip && (
{data.coupons.map((c) => ( <Section title="Welcome!" highlight>
<div <div style={{ background: 'linear-gradient(135deg, #E8A849 0%, #d4963f 100%)', padding: '1.5rem', borderRadius: '16px', color: '#fff' }}>
key={c.id} <p style={{ margin: '0 0 0.5rem', fontSize: '20px', fontWeight: 600 }}>
style={{ Welcome to Hosteria La Huasca!
padding: '1.25rem', </p>
background: cream, <p style={{ margin: '0', opacity: 0.9 }}>
border: '2px dashed ' + warm, Your room{activeTrip.room_name ? ` (${activeTrip.room_name})` : ''} is ready. Check-out: {formatDisplayDate(activeTrip.check_out, { weekday: 'long', month: 'short', day: 'numeric' })}
borderRadius: '16px', </p>
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>
</div>
))}
</div> </div>
</Section> </Section>
)}
<Section title="Offers"> {/* Future Trip Info */}
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> {upcomingTrip && (
{data.offers.map((o) => ( <Section title="Your Upcoming Trip">
<li <div style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
key={o.id} <div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
style={{ <div>
padding: '1rem 1.25rem', <div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-in</div>
background: cream, <div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{formatDisplayDate(upcomingTrip.check_in, { weekday: 'short', month: 'short', day: 'numeric' })}</div>
borderRadius: '16px', </div>
color: navy, <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>
<strong style={{ color: warm }}>{o.title}</strong> {o.description} </div>
</li> {upcomingTrip.room_name && (
))} <div>
</ul> <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> </Section>
)}
{/* WiFi Password */}
<Section title="WiFi Password"> <Section title="WiFi Password">
<div <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)' }}>
style={{ {data.wifiPassword || 'Not available'}
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> </div>
</Section> </Section>
<Section title="Member Benefits"> {/* Change Password */}
<ul style={{ padding: 0, listStyle: 'none', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}> <Section title="Change Password">
{data.benefits.map((b) => ( <ChangePassword />
<li </Section>
key={b}
style={{ {/* Chat with Admins */}
padding: '0.9rem 1.1rem', <Section title="Message Staff">
background: '#e8eff3', <div style={{ background: cream, borderRadius: '16px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
borderLeft: '4px solid ' + gold, <div style={{ maxHeight: '300px', overflowY: 'auto', padding: '1rem' }}>
borderRadius: '0 12px 12px 0', {data.messages.length === 0 ? (
color: navy, <div style={{ textAlign: 'center', color: '#777', padding: '2rem' }}>
}} No messages yet. Send a message to contact our staff.
> </div>
{b} ) : (
</li> data.messages.map((m) => (
))} <div key={m.id} style={{ display: 'flex', justifyContent: m.sender_role === 'user' ? 'flex-end' : 'flex-start', marginBottom: '0.75rem' }}>
</ul> <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> </Section>
</main> </main>
); );
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children, highlight }: { title: string; children: React.ReactNode; highlight?: boolean }) {
return ( return (
<section style={{ marginBottom: '2.5rem' }}> <section style={{ marginBottom: '2rem' }}>
<h2 <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' }}>
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',
}}
>
{title} {title}
</h2> </h2>
{children} {children}
</section> </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>
);
}
+85 -29
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { getEcuadorToday, getEcuadorDateFromToday, daysUntilEcuador, getMonthDayEcuador } from '@/lib/date';
interface CalendarEvent { interface CalendarEvent {
id: number; id: number;
@@ -12,8 +13,32 @@ interface CalendarEvent {
} }
interface WeatherData { interface WeatherData {
current?: { celsius: number; fahrenheit: number; summary: string }; current?: { celsius: number; fahrenheit: number; summary: string; weathercode: number };
today?: { maxC: number; minC: number; maxF: number; minF: number; summary: string }; 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'; const gold = '#E8A849';
@@ -26,22 +51,6 @@ const typeEmoji: Record<CalendarEvent['type'], string> = {
event: '📌', 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() { export default function CalendarStrip() {
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [weather, setWeather] = useState<WeatherData | null>(null); const [weather, setWeather] = useState<WeatherData | null>(null);
@@ -60,21 +69,40 @@ export default function CalendarStrip() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
const cards = events.map((e) => ({ ...e, isWeather: false })).concat( // Build weather cards: today, tomorrow, day after
weather const weatherCards = weather
? [ ? [
{ {
id: 9999, id: 9997,
date: new Date().toISOString().split('T')[0], date: getEcuadorToday(),
title: `${weather.current?.celsius ?? '--'}°C / ${weather.current?.fahrenheit ?? '--'}°F ${weather.current?.summary ?? ''}`, title: `${getWeatherIcon(weather.today?.weathercode)} ${weather.current?.celsius ?? '--'}°C`,
type: 'weather' as const, type: 'weather' as const,
description: `Today ${weather.today?.maxC ?? '--'}°C / ${weather.today?.maxF ?? '--'}°F · ${weather.today?.minC ?? '--'}°C / ${weather.today?.minF ?? '--'}°F`, description: `Today: ${weather.today?.maxC ?? '--'}° / ${weather.today?.minC ?? '--'}°C · ${weather.today?.summary ?? ''}`,
color: '#4FC3F7', color: '#4FC3F7',
isWeather: true, 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; if (cards.length === 0) return null;
@@ -86,22 +114,50 @@ export default function CalendarStrip() {
overflow: 'hidden', overflow: 'hidden',
borderTop: `1px solid rgba(232,168,73,0.25)`, borderTop: `1px solid rgba(232,168,73,0.25)`,
borderBottom: `1px solid rgba(232,168,73,0.25)`, borderBottom: `1px solid rgba(232,168,73,0.25)`,
position: 'relative',
}} }}
onMouseEnter={() => setPaused(true)} onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)} 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 <div
style={{ style={{
display: 'flex', display: 'flex',
gap: '1rem', gap: '1rem',
padding: '0 1rem', padding: '0 1rem',
width: 'max-content', width: 'max-content',
animation: paused ? 'none' : 'marquee 38s linear infinite', animation: 'marquee 60s linear infinite',
animationPlayState: paused ? 'paused' : 'running',
}} }}
ref={trackRef} ref={trackRef}
> >
{[...cards, ...cards].map((e, i) => { {[...cards, ...cards].map((e, i) => {
const { month, day } = formatMonthDay(e.date); const { month, day } = getMonthDayEcuador(e.date);
const accent = e.color || gold; const accent = e.color || gold;
return ( return (
<div <div
@@ -156,7 +212,7 @@ export default function CalendarStrip() {
{e.description || ''} {e.description || ''}
</p> </p>
<div style={{ marginTop: '0.8rem', fontSize: '12px', color: 'rgba(245,240,232,0.5)' }}> <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>
</div> </div>
); );
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { FacebookIcon, InstagramIcon, WhatsAppIcon } from './icons'; import { FacebookIcon, InstagramIcon, WhatsAppIcon } from './icons';
interface Config { interface Config {
tagline?: string;
facebook_url?: string; facebook_url?: string;
instagram_url?: string; instagram_url?: string;
whatsapp_url?: string; whatsapp_url?: string;
@@ -47,7 +48,7 @@ export default function Footer() {
<div> <div>
<h3 style={{ color: gold, fontSize: '18px', margin: '0 0 1rem', fontStyle: 'italic' }}>La Huasca</h3> <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 }}> <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> </p>
</div> </div>
+23 -12
View File
@@ -4,22 +4,33 @@ import { Language, setLanguage, useLanguage } from '@/lib/i18n';
function Flag({ language }: { language: Language }) { function Flag({ language }: { language: Language }) {
if (language === 'en') { if (language === 'en') {
// Union Jack (UK flag) - simplified but recognizable
return ( return (
<svg width="24" height="16" viewBox="0 0 24 16" fill="none"> <svg width="24" height="16" viewBox="0 0 60 30" fill="none" aria-hidden="true">
<rect width="24" height="16" fill="white" /> {/* Blue background */}
<rect x="0" y="6" width="24" height="4" fill="#CE1124" /> <rect width="60" height="30" fill="#012169"/>
<rect x="10" y="0" width="4" height="16" fill="#CE1124" /> {/* 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> </svg>
); );
} }
return ( return (
<svg width="24" height="16" viewBox="0 0 24 16" fill="none"> <svg width="24" height="16" viewBox="0 0 24 16" fill="none" aria-hidden="true">
<rect width="24" height="16" fill="white" /> <rect width="24" height="8" fill="#FFD100" />
<rect y="0" width="24" height="5.33" fill="#EF3340" /> <rect y="8" width="24" height="4" fill="#073685" />
<rect y="10.67" width="24" height="5.33" fill="#EF3340" /> <rect y="12" width="24" height="4" fill="#EF3340" />
<rect y="5.33" width="24" height="5.33" fill="#073685" /> <circle cx="12" cy="8.2" r="2.2" fill="#FFFFFF" opacity="0.95" />
<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" /> <circle cx="12" cy="8.2" r="1.1" fill="#EF3340" opacity="0.8" />
</svg> </svg>
); );
} }
@@ -37,8 +48,8 @@ export default function LanguageSwitcher() {
onClick={toggleLanguage} onClick={toggleLanguage}
style={{ style={{
position: 'fixed', position: 'fixed',
top: '1rem', top: '0.5rem',
left: '1rem', right: '7rem',
zIndex: 1000, zIndex: 1000,
background: 'rgba(255, 255, 255, 0.9)', background: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ddd', 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; link.href = data.data;
}) })
.catch(() => {}); .catch(() => {});
}, []); }, [pathname]);
const handleLogout = async () => { const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
@@ -85,6 +85,17 @@ export default function Navbar() {
})} })}
</ul> </ul>
<div className="navbar-actions"> <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 ? ( {auth?.authenticated ? (
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button> <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'; import { useState, useEffect } from 'react';
function celsiusToFahrenheit(c: number): number {
return Math.round((c * 9/5) + 32);
}
export default function WeatherWidget() { export default function WeatherWidget() {
const [weather, setWeather] = useState<any>(null); const [weather, setWeather] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<any>(null);
// Load weather configuration
useEffect(() => { 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 () => { const fetchWeather = async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Use the first city in the list for now const res = await fetch('/api/weather');
const city = config.cities[0];
const res = await fetch(`/api/weather?lat=${city.lat}&lon=${city.lon}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setWeather(data); setWeather(data.data);
} else { } else {
throw new Error('Failed to fetch weather data'); throw new Error('Failed to fetch weather data');
} }
} catch (err) { } catch (err) {
console.error('Weather API error:', err); console.error('Weather API error:', err);
setError('Failed to load weather data'); setError('Failed to load weather');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchWeather(); fetchWeather();
}, [config]); }, []);
if (!config?.enabled) return null;
if (loading) { if (loading) {
return ( return (
<div style={{ <div style={{
background: 'rgba(255, 255, 255, 0.9)', background: 'rgba(255, 255, 255, 0.9)',
borderRadius: '12px', borderRadius: '12px',
padding: '12px 16px', padding: '40px 16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)', boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
backdropFilter: 'blur(10px)', backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)' border: '1px solid rgba(255,255,255,0.2)'
@@ -78,49 +52,57 @@ export default function WeatherWidget() {
} }
if (error || !weather) { if (error || !weather) {
return ( return null;
<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>
);
} }
const current = weather.current; const current = weather.current;
const temp = current.celsius; const today = weather.today;
const icon = '🌤️';
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 ( return (
<div style={{ <div style={{
background: 'rgba(255, 255, 255, 0.9)', background: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px', borderRadius: '12px',
padding: '12px 16px', 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)', backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.3)',
minWidth: '180px' minWidth: '160px'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '20px' }}>{icon}</span> <span style={{ fontSize: '28px' }}>{icon}</span>
<div> <div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}> <div style={{ fontSize: '18px', fontWeight: '600', color: '#1a1a2e' }}>
{temp}°C {current.celsius}°C / {tempF}°F
</div> </div>
{config.cities[0] && ( <div style={{ fontSize: '11px', color: '#666', marginTop: '2px' }}>
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}> {current.summary}
{config.cities[0].name}
</div>
)}
</div> </div>
</div> </div>
<div style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}> </div>
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>
</div> </div>
); );
+1 -10
View File
@@ -18,17 +18,8 @@ export function useAuth() {
}, []); }, []);
const login = useCallback((username: string, password: string): boolean => { const login = useCallback((username: string, password: string): boolean => {
// TODO: Implement server-side authentication via API call
if (typeof window === 'undefined') return false; 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; 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); 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 hash = await hashPassword(password);
const { rows } = await db.query( 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', `INSERT INTO users (username, password_hash, role, first_name, last_name, email, phone, country, preferred_language)
[username, hash, role] 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]; return rows[0];
} }
export async function authenticateUser(username: string, password: string) { 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; if (rows.length === 0) return null;
const user = rows[0]; const user = rows[0];
const valid = await verifyPassword(password, user.password_hash); const valid = await verifyPassword(password, user.password_hash);
if (!valid) return null; 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 }) { 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 }) const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d') .setExpirationTime('4h') // 4 hours for security (reduced from 7 days)
.sign(getSecret()); .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; 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 };
}
+381 -5
View File
@@ -20,6 +20,8 @@ export async function initSchema() {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
data TEXT NOT NULL, data TEXT NOT NULL,
thumbnail TEXT,
medium TEXT,
category VARCHAR(100) DEFAULT 'other', category VARCHAR(100) DEFAULT 'other',
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL, room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
location_target VARCHAR(100) DEFAULT 'gallery', 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(` await db.query(`
CREATE TABLE IF NOT EXISTS reservations ( CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -100,10 +106,16 @@ export async function initSchema() {
is_daily_special BOOLEAN DEFAULT FALSE, is_daily_special BOOLEAN DEFAULT FALSE,
active BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW() 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(` await db.query(`
CREATE TABLE IF NOT EXISTS places ( CREATE TABLE IF NOT EXISTS places (
id SERIAL PRIMARY KEY, 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(` await db.query(`
CREATE TABLE IF NOT EXISTS reviews ( CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -168,11 +184,17 @@ export async function initSchema() {
photos TEXT[] DEFAULT '{}', photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500), featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE,
is_private BOOLEAN DEFAULT FALSE,
max_guests INTEGER,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW() 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(` await db.query(`
CREATE TABLE IF NOT EXISTS social_event_reservations ( CREATE TABLE IF NOT EXISTS social_event_reservations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -186,16 +208,16 @@ export async function initSchema() {
); );
`); `);
await db.query(` await db.query(`CREATE TABLE IF NOT EXISTS calendar_events (
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE, date DATE NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL, type VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
color VARCHAR(20) NOT NULL color VARCHAR(20) NOT NULL,
); active BOOLEAN DEFAULT TRUE,
`); created_at TIMESTAMP DEFAULT NOW()
);`);
await db.query(` await db.query(`
CREATE TABLE IF NOT EXISTS user_sessions ( 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); 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(` await db.query(`
CREATE TABLE IF NOT EXISTS availability ( CREATE TABLE IF NOT EXISTS availability (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -233,6 +287,260 @@ export async function initSchema() {
UNIQUE(room_id, date) 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() { export async function migrateFromLegacyPlaces() {
@@ -250,6 +558,20 @@ export async function migrateFromLegacyPlaces() {
await db.query(` await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500); 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() { export async function seed() {
@@ -314,6 +636,60 @@ export async function seed() {
if (configTaglineRows.length === 0) { if (configTaglineRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', tagline]); 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() { export async function resetDatabase() {
+45 -23
View File
@@ -1,40 +1,62 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
// Simple middleware for Next.js Edge Runtime // Paths that require authentication
// This is a basic implementation that doesn't verify tokens const PROTECTED_PATHS = ['/admin'];
// In a production environment, you would want to verify the token // Paths that require no authentication (login page should not redirect logged-in users)
export function middleware(request: NextRequest) { const AUTH_PATHS = ['/login'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Protect admin routes // Check if this is a protected path
if (pathname.startsWith('/admin')) { const isProtectedPath = PROTECTED_PATHS.some(path => pathname.startsWith(path));
// Get session token from cookies const isAuthPath = AUTH_PATHS.some(path => pathname.startsWith(path));
const token = request.cookies.get('session')?.value;
// If no token, redirect to login if (isProtectedPath) {
if (!token) { // Get session token from cookie
return NextResponse.redirect(new URL('/login', request.url)); 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 // Session exists - actual auth validation happens in API routes
// For now, we're just checking that a token exists // 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 // Add security headers to all responses
if (pathname === '/login') { const response = NextResponse.next();
const token = request.cookies.get('session')?.value;
if (token) { response.headers.set('X-Content-Type-Options', 'nosniff');
// In a real implementation, you would verify the token and check role response.headers.set('X-Frame-Options', 'DENY');
// For now, we'll just redirect to home response.headers.set('X-XSS-Protection', '1; mode=block');
return NextResponse.redirect(new URL('/', request.url)); 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 // Configure which paths the middleware should run on
export const config = { 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 () => { it('authenticates a user with the stored password hash', async () => {
const passwordHash = await hashPassword('secret-password'); const passwordHash = await hashPassword('secret-password');
query.mockResolvedValueOnce({ 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({ await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({
id: 2, id: 2,
username: 'bob', username: 'bob',
role: 'admin', 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 () => { it('returns active rooms', async () => {
// Mock rooms query
query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] }); 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 response = await GET();
const body = await response.json(); const body = await response.json();
expect(response.status).toBe(200); 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 () => { it('creates a room for admins', async () => {
@@ -52,6 +57,6 @@ describe('/api/rooms', () => {
expect(requireRole).toHaveBeenCalledWith('admin'); expect(requireRole).toHaveBeenCalledWith('admin');
expect(response.status).toBe(200); 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: [] } });
}); });
}); });