Compare commits
74 Commits
f771ad751b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 288767f6c0 | |||
| 9286f3e323 | |||
| 4e514c2fbb | |||
| bfc627f451 | |||
| 60afc2349e | |||
| 9557fa7d07 | |||
| 200784fa49 | |||
| 0785facd9e | |||
| 6c8d70d41c | |||
| 1e66e918ac | |||
| dc0c5fd289 | |||
| 0ca4f961f6 | |||
| 361fd67e49 | |||
| 5b30d0ca24 | |||
| d730961c96 | |||
| 09bbe0a441 | |||
| ee68dc2897 | |||
| d0be23551a | |||
| 223b6e4670 | |||
| b71f66a177 | |||
| f7bb5ed752 | |||
| d9049d2294 | |||
| ae6ea0ea46 | |||
| 72b507e434 | |||
| a8df420f36 | |||
| 4fb97e1f10 | |||
| c98824c8d7 | |||
| 81c12ace7b | |||
| 73be5d2f22 | |||
| 2353198924 | |||
| 7eae73eefb | |||
| c829e27bf6 | |||
| c42b1c3cc8 | |||
| 2288c8563d | |||
| 04d5f8c727 | |||
| e299c07912 | |||
| 3bdaee333b | |||
| ef2bfa5f05 | |||
| 271457dfa5 | |||
| dcc996c64a | |||
| 9424e260c2 | |||
| b46d490be2 | |||
| f9c8d48631 | |||
| 7b72994d4a | |||
| d6ca208fc6 | |||
| 3c5f911f8f | |||
| d67759485a | |||
| d753a3713f | |||
| 2dc949553e | |||
| 91fce014fd | |||
| 262cd4745c | |||
| 0c00bfe23a | |||
| 8cc1d4bb96 | |||
| e0a82b945f | |||
| a2a0335b7d | |||
| 657af63e73 | |||
| 6b7720d2d8 | |||
| 765465348e | |||
| 11d8f4a456 | |||
| 25428e1246 | |||
| 59698686d5 | |||
| 291df16f1f | |||
| 658a3f00a7 | |||
| 9f418d6b19 | |||
| a892e9e132 | |||
| a1d88af78b | |||
| abf46b9075 | |||
| 6b0db0e3cf | |||
| 4d2c26d3af | |||
| cac82e6d9d | |||
| 7f81833936 | |||
| f99da73118 | |||
| c2c2217028 | |||
| 6121c76d97 |
+43
-20
@@ -1,14 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Database initialization script for La Huasca (local version)
|
||||
# This script creates the necessary tables and seeds initial data
|
||||
|
||||
set -e # Exit on any error
|
||||
set -e
|
||||
|
||||
echo "Initializing La Huasca database (local version)..."
|
||||
|
||||
# Execute the schema directly
|
||||
echo "Executing database schema..."
|
||||
sudo -u postgres psql -d lahuasca << 'EOF'
|
||||
-- Create all tables
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -19,6 +13,11 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
comments_disabled BOOLEAN DEFAULT FALSE,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
country VARCHAR(100),
|
||||
preferred_language VARCHAR(10) DEFAULT 'es',
|
||||
terms_accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -123,7 +122,7 @@ CREATE TABLE IF NOT EXISTS slides (
|
||||
title VARCHAR(255),
|
||||
subtitle TEXT,
|
||||
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
|
||||
image_url VARCHAR(500),
|
||||
image_url TEXT,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
@@ -171,7 +170,19 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(20) NOT NULL
|
||||
color VARCHAR(20) NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS terms_of_service (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
version VARCHAR(20) DEFAULT '1.0',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
@@ -181,31 +192,45 @@ CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add missing columns if they don't exist
|
||||
CREATE TABLE IF NOT EXISTS availability (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'available' CHECK (status IN ('available', 'booked', 'maintenance')),
|
||||
price NUMERIC(10,2),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(room_id, date)
|
||||
);
|
||||
|
||||
-- Add missing columns
|
||||
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS country VARCHAR(100);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT 'es';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMP;
|
||||
|
||||
-- Insert initial data
|
||||
-- Default admin user (password: admin)
|
||||
-- Fix slides.image_url to TEXT (was VARCHAR(500), too short for base64)
|
||||
ALTER TABLE slides ALTER COLUMN image_url TYPE TEXT;
|
||||
|
||||
-- Seed initial data
|
||||
INSERT INTO users (username, password_hash, role) VALUES
|
||||
('admin', '$2a$10$8K1p/a0dhrxiowP.dnkgNORTWgdEDHn5L2/xjpEWuC.QQv4rKO9jO', 'admin')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Default config values
|
||||
INSERT INTO config (key, value) VALUES
|
||||
('wifi_password', 'LaHuascaGuest2026'),
|
||||
('tagline', 'Hotel · Restaurant · Andean Highlands')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Sample offers
|
||||
INSERT INTO offers (title, description, active, sort_order) VALUES
|
||||
('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.', true, 1),
|
||||
('Sunset Dinner', 'Three-course menu on the terrace every Friday.', true, 2)
|
||||
INSERT INTO offers (title, description) VALUES
|
||||
('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.'),
|
||||
('Sunset Dinner', 'Three-course menu on the terrace every Friday.')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Sample benefits
|
||||
INSERT INTO benefits (text) VALUES
|
||||
('Priority booking for peak weekends'),
|
||||
('Late checkout on availability'),
|
||||
@@ -214,8 +239,6 @@ INSERT INTO benefits (text) VALUES
|
||||
ON CONFLICT DO NOTHING;
|
||||
EOF
|
||||
|
||||
# Verify that the tables were created
|
||||
echo "Verifying database tables..."
|
||||
sudo -u postgres psql -d lahuasca -c "\dt"
|
||||
|
||||
echo "Database initialization completed successfully!"
|
||||
+3
-1
@@ -180,7 +180,9 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(20) NOT NULL
|
||||
color VARCHAR(20) NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# WARNING: Replace CHANGE_ME_* values before applying
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
|
||||
Generated
+1154
-48
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -13,10 +13,11 @@
|
||||
"@chenglou/pretext": "^0.0.8",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jose": "^5.6.3",
|
||||
"next": "14.2.28",
|
||||
"next": "^14.2.35",
|
||||
"pg": "^8.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"sharp": "^0.35.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"jsdom": "^29.1.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { formatDisplayDateTime } from '@/lib/date';
|
||||
|
||||
type User = { id: number; username: string; first_name: string; last_name: string; role: string; email: string };
|
||||
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Conversation = {
|
||||
id: number;
|
||||
subject: string | null;
|
||||
last_message: string | null;
|
||||
last_message_at: string | null;
|
||||
message_count: number;
|
||||
unread_count: number;
|
||||
participants: Participant[];
|
||||
};
|
||||
|
||||
export default function AdminMessagesPage() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedConv, setSelectedConv] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [showBulk, setShowBulk] = useState(false);
|
||||
const [bulkSubject, setBulkSubject] = useState('');
|
||||
const [bulkContent, setBulkContent] = useState('');
|
||||
const [bulkType, setBulkType] = useState<'all_customers' | 'specific_users' | 'admins'>('all_customers');
|
||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConv) {
|
||||
fetchMessages(selectedConv);
|
||||
}
|
||||
}, [selectedConv]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [convRes, usersRes] = await Promise.all([
|
||||
fetch('/api/admin/conversations'),
|
||||
fetch('/api/admin/users'),
|
||||
]);
|
||||
|
||||
if (convRes.ok) {
|
||||
const data = await convRes.json();
|
||||
setConversations(data.conversations || []);
|
||||
}
|
||||
if (usersRes.ok) {
|
||||
const data = await usersRes.json();
|
||||
setUsers(data.users || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async (convId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${convId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(data.messages || []);
|
||||
setParticipants(data.participants || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.trim() || !selectedConv) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newMessage.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setNewMessage('');
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const sendBulkMessage = async () => {
|
||||
if (!bulkContent.trim()) return;
|
||||
|
||||
if (bulkType === 'specific_users' && selectedUsers.length === 0) {
|
||||
alert('Please select at least one user');
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/bulk-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: bulkSubject || null,
|
||||
content: bulkContent.trim(),
|
||||
recipient_type: bulkType,
|
||||
recipient_ids: bulkType === 'specific_users' ? selectedUsers : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
alert(`Message sent to ${data.sent_count} users`);
|
||||
setShowBulk(false);
|
||||
setBulkSubject('');
|
||||
setBulkContent('');
|
||||
setSelectedUsers([]);
|
||||
fetchData();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to send');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send bulk message:', err);
|
||||
alert('Failed to send message');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayName = (p: Participant | User) => {
|
||||
return p.first_name || p.username;
|
||||
};
|
||||
|
||||
const customerConvs = conversations.filter(c => c.participants.some(p => p.role === 'customer'));
|
||||
const adminConvs = conversations.filter(c => c.participants.every(p => p.role === 'admin'));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Messages</h1>
|
||||
<button
|
||||
onClick={() => setShowBulk(true)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
Send Bulk Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Conversations Panel */}
|
||||
<div className="lg:col-span-1 bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b bg-gray-50 font-medium">Customer Conversations</div>
|
||||
<div className="divide-y max-h-[300px] overflow-y-auto">
|
||||
{customerConvs.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No customer conversations</div>
|
||||
) : (
|
||||
customerConvs.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="font-medium text-sm">
|
||||
{conv.participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ')}
|
||||
</div>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b bg-gray-50 font-medium mt-4">Admin Conversations</div>
|
||||
<div className="divide-y max-h-[300px] overflow-y-auto">
|
||||
{adminConvs.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No admin conversations</div>
|
||||
) : (
|
||||
adminConvs.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<div className="font-medium text-sm">
|
||||
{conv.subject || conv.participants.map(getDisplayName).join(', ')}
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Panel */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow flex flex-col h-[700px]">
|
||||
{selectedConv ? (
|
||||
<>
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<div className="font-medium">
|
||||
Conversation with: {participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ') || 'Admins'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'admin' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[70%] ${msg.role === 'admin' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium">
|
||||
{msg.first_name || msg.username}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${msg.role === 'admin' ? 'bg-blue-500' : 'bg-gray-200 text-gray-600'}`}>
|
||||
{msg.role}
|
||||
</span>
|
||||
</div>
|
||||
<div>{msg.content}</div>
|
||||
<div className={`text-xs mt-1 ${msg.role === 'admin' ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
{formatDisplayDateTime(msg.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t bg-gray-50">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Type a reply..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!newMessage.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
Select a conversation to view messages
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Message Modal */}
|
||||
{showBulk && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold mb-4">Send Bulk Message</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Recipients</label>
|
||||
<select
|
||||
value={bulkType}
|
||||
onChange={e => setBulkType(e.target.value as typeof bulkType)}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="all_customers">All Customers</option>
|
||||
<option value="specific_users">Specific Users</option>
|
||||
<option value="admins">Admins Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{bulkType === 'specific_users' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Select Users</label>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg p-2 space-y-1">
|
||||
{users.filter(u => u.role === 'customer').map(user => (
|
||||
<label key={user.id} className="flex items-center gap-2 p-1 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setSelectedUsers(prev => [...prev, user.id]);
|
||||
} else {
|
||||
setSelectedUsers(prev => prev.filter(id => id !== user.id));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">{user.first_name || user.username} ({user.email})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{selectedUsers.length} selected</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bulkSubject}
|
||||
onChange={e => setBulkSubject(e.target.value)}
|
||||
placeholder="Message subject"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
|
||||
<textarea
|
||||
value={bulkContent}
|
||||
onChange={e => setBulkContent(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
rows={5}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowBulk(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={sendBulkMessage}
|
||||
disabled={!bulkContent.trim() || sending}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{sending ? 'Sending...' : 'Send Message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2181
-118
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import sharp from 'sharp';
|
||||
|
||||
interface ImageSizes {
|
||||
thumbnail: string;
|
||||
medium: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
async function generateImageSizes(base64Data: string): Promise<ImageSizes> {
|
||||
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return { thumbnail: base64Data, medium: base64Data, data: base64Data };
|
||||
}
|
||||
|
||||
const ext = matches[1];
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
|
||||
// Convert to WebP for better compression
|
||||
const toBase64 = (buf: Buffer) => `data:image/webp;base64,${buf.toString('base64')}`;
|
||||
|
||||
// Generate multiple sizes in parallel
|
||||
const [thumbnail, medium] = await Promise.all([
|
||||
sharp(buffer)
|
||||
.resize(400, 300, { fit: 'cover' })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(800, 600, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
// Also convert full image to WebP
|
||||
const fullImage = await sharp(buffer)
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
thumbnail: toBase64(thumbnail),
|
||||
medium: toBase64(medium),
|
||||
data: toBase64(fullImage),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -9,7 +52,7 @@ export async function GET(request: NextRequest) {
|
||||
const category = searchParams.get('category');
|
||||
const roomId = searchParams.get('room_id');
|
||||
|
||||
let query = 'SELECT id, filename, data, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC';
|
||||
let query = 'SELECT id, filename, data, thumbnail, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC';
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -28,7 +71,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ images: rows, count: rows.length });
|
||||
return NextResponse.json({ data: rows, count: rows.length });
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -48,12 +91,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate multiple sizes for responsive images (thumbnail, medium, full)
|
||||
const sizes = await generateImageSizes(data);
|
||||
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO images (filename, data, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5) RETURNING id, filename, data, category, room_id, location_target, uploaded_at',
|
||||
[filename, data, category || 'other', room_id || null, location_target || 'gallery']
|
||||
'INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, filename, data, thumbnail, medium, category, room_id, location_target, uploaded_at',
|
||||
[filename, sizes.data, sizes.thumbnail, sizes.medium, category || 'other', room_id || null, location_target || 'gallery']
|
||||
);
|
||||
|
||||
return NextResponse.json({ image: rows[0] });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole, createUser } from '@/lib/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Admin: Get all users for recipient selection
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, created_at FROM users ORDER BY created_at DESC');
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { username, password, role = 'user' } = body;
|
||||
const { rows } = await db.query(`
|
||||
SELECT id, username, first_name, last_name, role, email, created_at
|
||||
FROM users
|
||||
ORDER BY role, first_name, last_name
|
||||
`);
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (role !== 'admin' && role !== 'user') {
|
||||
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await createUser(username, password, role as 'admin' | 'user');
|
||||
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { id, first_name, last_name, comments_disabled } = body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
'UPDATE users SET first_name = $1, last_name = $2, comments_disabled = $3 WHERE id = $4 RETURNING id, username, role, first_name, last_name, comments_disabled, created_at',
|
||||
[first_name || null, last_name || null, comments_disabled === true, id]
|
||||
return NextResponse.json({ users: rows });
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to get users') },
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to update user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
|
||||
}
|
||||
await db.query('DELETE FROM users WHERE id = $1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to delete user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
await createSession(user);
|
||||
|
||||
return NextResponse.json({ role: user.role, username: user.username });
|
||||
return NextResponse.json({
|
||||
role: user.role,
|
||||
username: user.username,
|
||||
preferred_language: user.preferred_language,
|
||||
terms_accepted_at: user.terms_accepted_at,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function GET() {
|
||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, username, role, first_name, last_name, comments_disabled FROM users WHERE id = $1',
|
||||
'SELECT id, username, role, first_name, last_name, email, comments_disabled FROM users WHERE id = $1',
|
||||
[session.id]
|
||||
);
|
||||
const user = rows[0] || {};
|
||||
@@ -19,6 +19,7 @@ export async function GET() {
|
||||
id: session.id,
|
||||
first_name: user.first_name || null,
|
||||
last_name: user.last_name || null,
|
||||
email: user.email || null,
|
||||
comments_disabled: user.comments_disabled === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createUser } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
@@ -12,7 +13,10 @@ export async function POST() {
|
||||
|
||||
const user = await createUser(username, password, 'admin');
|
||||
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err, 'Failed to create bootstrap user') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
const body = await request.json();
|
||||
const { category, name, description, price, is_daily_special, active, sort_order } = body;
|
||||
const { category, name, description, price, is_daily_special, active, sort_order, photos, featured_photo } = body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[category, name, description || '', price, is_daily_special, active, sort_order || 0, id]
|
||||
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7, photos = $8, featured_photo = $9
|
||||
WHERE id = $10 RETURNING *`,
|
||||
[category, name, description || '', price, is_daily_special, active, sort_order || 0, photos || [], featured_photo || null, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
|
||||
@@ -13,10 +13,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Each entry: { id: number, sort_order: number }
|
||||
const updates = order.map((item: { id: number; sort_order: number }, index: number) =>
|
||||
const updates = order.map((item: { id: number; sort_order: number }) =>
|
||||
db.query(
|
||||
'UPDATE menu_items SET sort_order = $1 WHERE id = $2',
|
||||
[index, item.id]
|
||||
[item.sort_order, item.id]
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -18,17 +18,17 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { category, name, description, price, is_daily_special, sort_order } = body;
|
||||
const { category, name, description, price, is_daily_special, sort_order, photos, featured_photo } = body;
|
||||
|
||||
if (!category || !name || price === undefined || price === '') {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order, photos, featured_photo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[category, name, description || '', price, is_daily_special || false, sort_order || 0]
|
||||
[category, name, description || '', price, is_daily_special || false, sort_order || 0, photos || [], featured_photo || null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
|
||||
const photoUrl = searchParams.get('photo_url') || null;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin
|
||||
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin, status
|
||||
FROM room_comments
|
||||
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE
|
||||
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE AND status = 'approved'
|
||||
ORDER BY created_at DESC`,
|
||||
[roomId, photoUrl]
|
||||
);
|
||||
@@ -47,12 +47,12 @@ export async function POST(request: NextRequest, { params }: { params: { id: str
|
||||
const lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null;
|
||||
|
||||
const { rows: inserted } = await db.query(
|
||||
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING *`,
|
||||
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()]
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: inserted[0] });
|
||||
return NextResponse.json({ data: inserted[0], message: 'Comment submitted for review' });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Failed to post comment' },
|
||||
|
||||
@@ -1,53 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
// GET - check if user liked a room and get total likes
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const roomId = parseInt(params.id, 10);
|
||||
if (isNaN(roomId)) {
|
||||
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
|
||||
const session = await getSession();
|
||||
|
||||
// Get total likes
|
||||
const { rows: countRows } = await db.query(
|
||||
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
|
||||
[roomId]
|
||||
);
|
||||
const totalLikes = parseInt(countRows[0].count) || 0;
|
||||
|
||||
// Check if current user liked this room
|
||||
let likedByUser = false;
|
||||
if (session) {
|
||||
const { rows: likeRows } = await db.query(
|
||||
'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, session.id]
|
||||
);
|
||||
likedByUser = likeRows.length > 0;
|
||||
}
|
||||
|
||||
const [countResult, userLikeResult] = await Promise.all([
|
||||
db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]),
|
||||
db.query('SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2', [roomId, 1]),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
count: parseInt(countResult.rows[0].count, 10),
|
||||
user_liked: userLikeResult.rows.length > 0,
|
||||
});
|
||||
return NextResponse.json({ totalLikes, likedByUser });
|
||||
} catch (err: any) {
|
||||
// Table may not exist yet — return safe defaults
|
||||
return NextResponse.json({ count: 0, user_liked: false });
|
||||
return NextResponse.json({ error: err.message || 'Failed to get likes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
// POST - toggle like (add if not liked, remove if already liked)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const roomId = parseInt(params.id, 10);
|
||||
if (isNaN(roomId)) {
|
||||
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// TODO: get user_id from session/auth instead of hardcoded
|
||||
const userId = 1;
|
||||
const roomId = parseInt(params.id, 10);
|
||||
|
||||
// Toggle: if already liked, unlike; otherwise like
|
||||
const existing = await db.query(
|
||||
'SELECT id FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, userId]
|
||||
// Check if already liked
|
||||
const { rows: existing } = await db.query(
|
||||
'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, session.id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await db.query('DELETE FROM room_likes WHERE id = $1', [existing.rows[0].id]);
|
||||
let liked: boolean;
|
||||
if (existing.length > 0) {
|
||||
// Unlike - remove the like
|
||||
await db.query(
|
||||
'DELETE FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, session.id]
|
||||
);
|
||||
liked = false;
|
||||
} else {
|
||||
await db.query('INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)', [roomId, userId]);
|
||||
// Like - add the like
|
||||
await db.query(
|
||||
'INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)',
|
||||
[roomId, session.id]
|
||||
);
|
||||
liked = true;
|
||||
}
|
||||
|
||||
const countResult = await db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]);
|
||||
// Get updated count
|
||||
const { rows: countRows } = await db.query(
|
||||
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
|
||||
[roomId]
|
||||
);
|
||||
const totalLikes = parseInt(countRows[0].count) || 0;
|
||||
|
||||
return NextResponse.json({ count: parseInt(countResult.rows[0].count, 10) });
|
||||
// Update the rooms table for quick access
|
||||
await db.query(
|
||||
'UPDATE rooms SET likes_count = $1 WHERE id = $2',
|
||||
[totalLikes, roomId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ liked, totalLikes });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to toggle like' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,15 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]);
|
||||
if (!rows.length) return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
|
||||
// Get amenities for this room
|
||||
const { rows: amenityRows } = await db.query(
|
||||
'SELECT amenity_id FROM room_amenity_links WHERE room_id = $1',
|
||||
[id]
|
||||
);
|
||||
const amenity_ids = amenityRows.map(r => r.amenity_id);
|
||||
|
||||
return NextResponse.json({ data: { ...rows[0], amenity_ids } });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/rooms/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
@@ -21,17 +29,33 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const { name, description, price, photos, sort_order, featured_photo, active } = body;
|
||||
const { name, description, price, sort_order, featured_photo, active, amenity_ids } = body;
|
||||
|
||||
if (!name || !description || price === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Photos are stored in images table, not rooms.photos
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`,
|
||||
[name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id]
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, sort_order = $4, featured_photo = $5, active = $6 WHERE id = $7 RETURNING *`,
|
||||
[name, description, price, sort_order || 0, featured_photo || null, active !== false, id]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
|
||||
// Update amenities
|
||||
await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [id]);
|
||||
if (amenity_ids && amenity_ids.length > 0) {
|
||||
const values = amenity_ids.map((aid: number) => `(${id}, ${aid})`).join(',');
|
||||
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
|
||||
}
|
||||
|
||||
// Get photos from images table
|
||||
const { rows: imageRows } = await db.query(
|
||||
'SELECT id, data, thumbnail FROM images WHERE room_id = $1 ORDER BY id',
|
||||
[id]
|
||||
);
|
||||
const photos = imageRows.map((r: any) => r.thumbnail || r.data);
|
||||
|
||||
return NextResponse.json({ data: { ...rows[0], photos, amenity_ids: amenity_ids || [] } });
|
||||
} catch (error: any) {
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('PUT /api/rooms/[id]:', error);
|
||||
|
||||
@@ -5,7 +5,43 @@ import { db } from '@/lib/db';
|
||||
export async function GET() {
|
||||
try {
|
||||
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id');
|
||||
return NextResponse.json({ data: rows });
|
||||
|
||||
// Get amenity details with icons for all rooms
|
||||
const { rows: amenityRows } = await db.query(`
|
||||
SELECT ral.room_id, ra.id, ra.name, ra.icon_svg
|
||||
FROM room_amenity_links ral
|
||||
JOIN room_amenities ra ON ral.amenity_id = ra.id
|
||||
ORDER BY ra.sort_order
|
||||
`);
|
||||
|
||||
const amenityMap = new Map<number, Array<{id: number; name: string; icon_svg: string}>>();
|
||||
for (const row of amenityRows) {
|
||||
if (!amenityMap.has(row.room_id)) amenityMap.set(row.room_id, []);
|
||||
amenityMap.get(row.room_id)!.push({ id: row.id, name: row.name, icon_svg: row.icon_svg });
|
||||
}
|
||||
|
||||
// Get photos from images table for all rooms
|
||||
const { rows: imageRows } = await db.query(`
|
||||
SELECT room_id, data, thumbnail, medium
|
||||
FROM images
|
||||
WHERE room_id IS NOT NULL
|
||||
ORDER BY room_id, id
|
||||
`);
|
||||
|
||||
const imageMap = new Map<number, string[]>();
|
||||
for (const row of imageRows) {
|
||||
if (!imageMap.has(row.room_id)) imageMap.set(row.room_id, []);
|
||||
// Prefer thumbnail for display, fallback to data
|
||||
imageMap.get(row.room_id)!.push(row.thumbnail || row.data);
|
||||
}
|
||||
|
||||
const roomsWithAmenities = rows.map(r => ({
|
||||
...r,
|
||||
photos: imageMap.get(r.id) || [],
|
||||
amenities: amenityMap.get(r.id) || []
|
||||
}));
|
||||
|
||||
return NextResponse.json({ data: roomsWithAmenities });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/rooms error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to fetch rooms' }, { status: 500 });
|
||||
@@ -16,22 +52,62 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { name, description, price, photos, sort_order, featured_photo } = body;
|
||||
const { name, description, price, sort_order, featured_photo, amenity_ids } = body;
|
||||
|
||||
if (!name || !description || price === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Photos are now stored in images table, not rooms.photos
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, description, price, photos || [], sort_order || 0, featured_photo || null]
|
||||
[name, description, price, [], sort_order || 0, featured_photo || null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
const roomId = rows[0].id;
|
||||
|
||||
// Insert amenity links
|
||||
if (amenity_ids && amenity_ids.length > 0) {
|
||||
const values = amenity_ids.map((aid: number) => `(${roomId}, ${aid})`).join(',');
|
||||
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } });
|
||||
} catch (error: any) {
|
||||
console.error('POST /api/rooms error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { id, name, description, price, active, sort_order, featured_photo, amenity_ids } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Room ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Photos are stored in images table, not rooms.photos
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, active = $4, sort_order = $5, featured_photo = $6
|
||||
WHERE id = $7 RETURNING *`,
|
||||
[name, description, price, active ?? true, sort_order ?? 0, featured_photo || null, id]
|
||||
);
|
||||
|
||||
// Update amenity links
|
||||
await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [id]);
|
||||
if (amenity_ids && amenity_ids.length > 0) {
|
||||
const values = amenity_ids.map((aid: number) => `(${id}, ${aid})`).join(',');
|
||||
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } });
|
||||
} catch (error: any) {
|
||||
console.error('PUT /api/rooms error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update room' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
const PUBLIC_KEYS = ['logo','favicon','facebook_url','instagram_url','whatsapp_url','address','phone','email','wifi_password'];
|
||||
const PUBLIC_KEYS = ['logo','favicon','tagline','facebook_url','instagram_url','whatsapp_url','address','phone','email'];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit';
|
||||
import { withRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
// GET - Get a single social event reservation
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
await requireAuth();
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-social-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT ser.*, se.name as event_name
|
||||
FROM social_event_reservations ser
|
||||
JOIN social_events se ON se.id = ser.social_event_id
|
||||
WHERE ser.id = $1`,
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error fetching reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update reservation status (admin only)
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-social-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
|
||||
if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current status for audit log
|
||||
const { rows: beforeRows } = await db.query(
|
||||
'SELECT * FROM social_event_reservations WHERE id = $1',
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (beforeRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const oldStatus = beforeRows[0].status;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE social_event_reservations SET status = $1 WHERE id = $2 RETURNING *`,
|
||||
[status, params.id]
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog({
|
||||
userId: session.id,
|
||||
action: 'status_change',
|
||||
entityType: 'social_event_reservation',
|
||||
entityId: parseInt(params.id, 10),
|
||||
oldValues: { status: oldStatus },
|
||||
newValues: { status },
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete a reservation (admin only)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-social-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
// Get the reservation before deleting for audit log
|
||||
const { rows: beforeRows } = await db.query(
|
||||
'SELECT * FROM social_event_reservations WHERE id = $1',
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (beforeRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM social_event_reservations WHERE id = $1', [params.id]);
|
||||
|
||||
// Audit log
|
||||
await auditLog({
|
||||
userId: session.id,
|
||||
action: 'delete',
|
||||
entityType: 'social_event_reservation',
|
||||
entityId: parseInt(params.id, 10),
|
||||
oldValues: beforeRows[0],
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,31 @@ export async function GET(request: Request) {
|
||||
const session = await requireAuth();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const socialEventId = searchParams.get('social_event_id');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
let query = `
|
||||
SELECT ser.*, u.username, u.first_name, u.last_name, se.name as event_name
|
||||
SELECT ser.*, se.name as event_name
|
||||
FROM social_event_reservations ser
|
||||
JOIN users u ON u.id = ser.user_id
|
||||
JOIN social_events se ON se.id = ser.social_event_id
|
||||
`;
|
||||
const params: (number | string)[] = [];
|
||||
|
||||
if (session.role === 'admin') {
|
||||
const conditions: string[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (socialEventId) {
|
||||
query += ' WHERE ser.social_event_id = $1';
|
||||
conditions.push(`ser.social_event_id = $${paramCount++}`);
|
||||
params.push(parseInt(socialEventId, 10));
|
||||
}
|
||||
if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) {
|
||||
conditions.push(`ser.status = $${paramCount++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
} else {
|
||||
query += ' WHERE ser.user_id = $1';
|
||||
params.push(session.id);
|
||||
@@ -41,23 +52,38 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const body = await request.json();
|
||||
const { social_event_id, guests, notes } = body;
|
||||
const { social_event_id, guests, notes, contact_name, contact_email, contact_phone, contact_method } = body;
|
||||
|
||||
if (!social_event_id || !guests) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Try to authenticate, but allow guest submissions
|
||||
let userId: number | null = null;
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
userId = session.id;
|
||||
} catch {
|
||||
// Guest submission - require contact info
|
||||
if (!contact_name || !contact_method) {
|
||||
return NextResponse.json({ error: 'Contact name and method required for guest reservations' }, { status: 400 });
|
||||
}
|
||||
if (!['email', 'phone', 'whatsapp'].includes(contact_method)) {
|
||||
return NextResponse.json({ error: 'Invalid contact method' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, social_event_id)
|
||||
DO UPDATE SET guests = EXCLUDED.guests, notes = EXCLUDED.notes, status = 'pending'
|
||||
`INSERT INTO social_event_reservations
|
||||
(user_id, social_event_id, guests, notes, status, contact_name, contact_email, contact_phone, contact_method)
|
||||
VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[session.id, parseInt(social_event_id, 10), parseInt(guests, 10), notes || '']
|
||||
[userId, parseInt(social_event_id, 10), parseInt(guests, 10), notes || null, contact_name || null, contact_email || null, contact_phone || null, contact_method || null]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('POST /api/social_event_reservations error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: error.message || 'Failed to create reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,13 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const body = await request.json();
|
||||
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
|
||||
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
|
||||
const { rows } = await db.query(
|
||||
`UPDATE social_events
|
||||
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8
|
||||
WHERE id=$9
|
||||
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, is_private=$8, max_guests=$9, sort_order=$10
|
||||
WHERE id=$11
|
||||
RETURNING *`,
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0, id]
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0, id]
|
||||
);
|
||||
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
|
||||
@@ -2,15 +2,26 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { rows } = await db.query(`
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includePrivate = searchParams.get('includePrivate') === 'true';
|
||||
|
||||
// Public requests only see active, non-private events
|
||||
// Admin requests with includePrivate=true see all events
|
||||
let query = `
|
||||
SELECT se.*,
|
||||
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
|
||||
FROM social_events se
|
||||
WHERE se.active = TRUE
|
||||
ORDER BY se.sort_order, se.date, se.id
|
||||
`);
|
||||
`;
|
||||
|
||||
if (includePrivate) {
|
||||
query += ' ORDER BY se.is_private, se.sort_order, se.date, se.id';
|
||||
} else {
|
||||
query += ' WHERE se.active = TRUE AND (se.is_private = FALSE OR se.is_private IS NULL) ORDER BY se.sort_order, se.date, se.id';
|
||||
}
|
||||
|
||||
const { rows } = await db.query(query);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/social_events error:', error);
|
||||
@@ -22,12 +33,12 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
|
||||
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0]
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -32,12 +32,34 @@ export async function GET() {
|
||||
);
|
||||
const wifiPassword = configRows[0]?.value || '';
|
||||
|
||||
// Get user's trips (future and current)
|
||||
const { rows: trips } = await db.query(
|
||||
`SELECT t.id, t.room_id, t.check_in, t.check_out, t.notes, t.status, r.name as room_name
|
||||
FROM trips t
|
||||
LEFT JOIN rooms r ON t.room_id = r.id
|
||||
WHERE t.user_id = $1 AND t.check_out >= CURRENT_DATE
|
||||
ORDER BY t.check_in ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Get weather
|
||||
let weather = undefined;
|
||||
try {
|
||||
const weatherRes = await fetch('http://localhost:3000/api/weather');
|
||||
if (weatherRes.ok) {
|
||||
const weatherData = await weatherRes.json();
|
||||
weather = weatherData.data;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return NextResponse.json({
|
||||
reservations,
|
||||
coupons,
|
||||
offers,
|
||||
benefits: benefits.map((b: { text: string }) => b.text),
|
||||
wifiPassword,
|
||||
trips,
|
||||
weather,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,22 @@ export async function GET() {
|
||||
weathercode: daily.weathercode?.[0],
|
||||
summary: codeMap[daily.weathercode?.[0]] || 'Unknown',
|
||||
},
|
||||
tomorrow: {
|
||||
maxC: celsius(daily.temperature_2m_max?.[1]),
|
||||
minC: celsius(daily.temperature_2m_min?.[1]),
|
||||
maxF: fahrenheit(daily.temperature_2m_max?.[1]),
|
||||
minF: fahrenheit(daily.temperature_2m_min?.[1]),
|
||||
weathercode: daily.weathercode?.[1],
|
||||
summary: codeMap[daily.weathercode?.[1]] || 'Unknown',
|
||||
},
|
||||
dayAfter: {
|
||||
maxC: celsius(daily.temperature_2m_max?.[2]),
|
||||
minC: celsius(daily.temperature_2m_min?.[2]),
|
||||
maxF: fahrenheit(daily.temperature_2m_max?.[2]),
|
||||
minF: fahrenheit(daily.temperature_2m_min?.[2]),
|
||||
weathercode: daily.weathercode?.[2],
|
||||
summary: codeMap[daily.weathercode?.[2]] || 'Unknown',
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
+494
-31
@@ -9,6 +9,21 @@ interface MenuItem {
|
||||
description: string;
|
||||
price: string;
|
||||
is_daily_special: boolean;
|
||||
photos?: string[];
|
||||
featured_photo?: string | null;
|
||||
}
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
@@ -18,15 +33,60 @@ const warm = '#a6683c';
|
||||
|
||||
export default function CoffeePage() {
|
||||
const [items, setItems] = useState<MenuItem[]>([]);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [assignedRoom, setAssignedRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Order state
|
||||
const [showOrderModal, setShowOrderModal] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Map<number, number>>(new Map());
|
||||
const [orderType, setOrderType] = useState<'pickup' | 'dine_in' | 'room_delivery'>('pickup');
|
||||
const [selectedRoom, setSelectedRoom] = useState<number | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [orderResult, setOrderResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/menu')
|
||||
.then(async (res) => {
|
||||
Promise.all([
|
||||
fetch('/api/menu').then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to load menu');
|
||||
const json = await res.json();
|
||||
setItems(json.data || []);
|
||||
return res.json();
|
||||
}),
|
||||
fetch('/api/auth/me').then(async (res) => {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.authenticated && data.id) {
|
||||
return { user: { id: data.id, username: data.username, role: data.role, first_name: data.first_name, last_name: data.last_name } };
|
||||
}
|
||||
}
|
||||
return { user: null };
|
||||
}),
|
||||
fetch('/api/rooms').then(async (res) => {
|
||||
if (!res.ok) return { rooms: [] };
|
||||
return res.json();
|
||||
}),
|
||||
])
|
||||
.then(async ([menuData, sessionData, roomsData]) => {
|
||||
setItems(menuData.data || []);
|
||||
setUser(sessionData.user || null);
|
||||
setRooms(roomsData.rooms || []);
|
||||
|
||||
// Fetch user's active room assignment if logged in
|
||||
if (sessionData.user) {
|
||||
try {
|
||||
const assignRes = await fetch('/api/room-assignments');
|
||||
if (assignRes.ok) {
|
||||
const assignData = await assignRes.json();
|
||||
if (assignData.assignments?.length > 0) {
|
||||
const assignment = assignData.assignments[0];
|
||||
setAssignedRoom({ id: assignment.room_id, name: assignment.room_name });
|
||||
setSelectedRoom(assignment.room_id);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -35,12 +95,111 @@ export default function CoffeePage() {
|
||||
const categories = Array.from(new Set(items.map((i) => i.category)));
|
||||
const daily = items.find((i) => i.is_daily_special);
|
||||
|
||||
// Calculate order totals
|
||||
const orderItems = items.filter((item) => selectedItems.has(item.id) && (selectedItems.get(item.id) || 0) > 0);
|
||||
const subtotal = orderItems.reduce((sum, item) => {
|
||||
const qty = selectedItems.get(item.id) || 0;
|
||||
return sum + parseFloat(item.price) * qty;
|
||||
}, 0);
|
||||
const serviceFee = orderType === 'room_delivery' ? subtotal * 0.1 : 0;
|
||||
const total = subtotal + serviceFee;
|
||||
|
||||
const updateQuantity = (itemId: number, delta: number) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const current = newMap.get(itemId) || 0;
|
||||
const newQty = Math.max(0, current + delta);
|
||||
if (newQty === 0) {
|
||||
newMap.delete(itemId);
|
||||
} else {
|
||||
newMap.set(itemId, newQty);
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitOrder = async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
alert('Please select at least one item');
|
||||
return;
|
||||
}
|
||||
|
||||
if (orderType === 'room_delivery' && !selectedRoom) {
|
||||
alert('Please select a room for delivery');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: Array.from(selectedItems.entries()).map(([menu_item_id, quantity]) => ({
|
||||
menu_item_id,
|
||||
quantity,
|
||||
})),
|
||||
order_type: orderType,
|
||||
room_id: orderType === 'room_delivery' ? selectedRoom : null,
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to place order');
|
||||
}
|
||||
|
||||
setOrderResult({ success: true, message: data.message });
|
||||
setSelectedItems(new Map());
|
||||
setNotes('');
|
||||
} catch (err) {
|
||||
setOrderResult({ success: false, message: err instanceof Error ? err.message : 'Failed to place order' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowOrderModal(false);
|
||||
setOrderResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Coffee & Kitchen</h1>
|
||||
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
||||
Locally sourced coffee, fresh pastries, and seasonal plates.
|
||||
</p>
|
||||
<main style={{ padding: 'clamp(1rem, 5vw, 2rem)', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 6vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.25rem' }}>Coffee & Kitchen</h1>
|
||||
<p style={{ color: '#555', marginBottom: 0, maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
|
||||
Locally sourced coffee, fresh pastries, and seasonal plates.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowOrderModal(true)}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'clamp(14px, 3vw, 16px)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(232,168,73,0.3)',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(232,168,73,0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(232,168,73,0.3)';
|
||||
}}
|
||||
>
|
||||
Place Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <p style={{ color: warm }}>Loading menu...</p>}
|
||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||
@@ -50,10 +209,10 @@ export default function CoffeePage() {
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${navy} 0%, #0e2f47 100%)`,
|
||||
color: cream,
|
||||
borderRadius: '20px',
|
||||
padding: '1.5rem 2rem',
|
||||
marginBottom: '2rem',
|
||||
boxShadow: '0 6px 20px rgba(1,13,30,0.2)',
|
||||
borderRadius: '16px',
|
||||
padding: 'clamp(1rem, 4vw, 1.5rem) clamp(1.25rem, 5vw, 2rem)',
|
||||
marginBottom: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(1,13,30,0.2)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
@@ -61,62 +220,366 @@ export default function CoffeePage() {
|
||||
display: 'inline-block',
|
||||
background: gold,
|
||||
color: navy,
|
||||
fontSize: '12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
padding: '0.35rem 0.8rem',
|
||||
letterSpacing: '0.08em',
|
||||
padding: '0.3rem 0.7rem',
|
||||
borderRadius: '999px',
|
||||
marginBottom: '0.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
Menu of the Day
|
||||
</span>
|
||||
<h2 style={{ margin: '0 0 0.5rem', fontSize: '26px' }}>{daily.name}</h2>
|
||||
<p style={{ margin: '0 0 1rem', opacity: 0.85 }}>{daily.description}</p>
|
||||
<strong style={{ color: gold, fontSize: '20px' }}>${daily.price}</strong>
|
||||
<h2 style={{ margin: '0 0 0.35rem', fontSize: 'clamp(20px, 4vw, 26px)' }}>{daily.name}</h2>
|
||||
<p style={{ margin: '0 0 0.75rem', opacity: 0.85, fontSize: 'clamp(14px, 3vw, 16px)' }}>{daily.description}</p>
|
||||
<strong style={{ color: gold, fontSize: 'clamp(18px, 4vw, 20px)' }}>${daily.price}</strong>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && items.length === 0 && <p style={{ color: '#777' }}>No menu items yet.</p>}
|
||||
|
||||
{categories.map((category) => (
|
||||
<section key={category} style={{ marginBottom: '2rem' }}>
|
||||
<section key={category} style={{ marginBottom: '1.5rem' }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontSize: 'clamp(16px, 4vw, 20px)',
|
||||
color: navy,
|
||||
borderBottom: '2px solid ' + warm,
|
||||
display: 'inline-block',
|
||||
paddingBottom: '0.3rem',
|
||||
margin: '0 0 1rem',
|
||||
paddingBottom: '0.25rem',
|
||||
margin: '0 0 0.75rem',
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 280px), 1fr))', gap: 'clamp(0.75rem, 3vw, 1rem)' }}>
|
||||
{items
|
||||
.filter((i) => i.category === category)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
padding: 'clamp(1rem, 4vw, 1.25rem)',
|
||||
background: cream,
|
||||
borderRadius: '16px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 1px 3px rgba(1,13,30,0.08)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.5rem' }}>
|
||||
<strong style={{ color: navy }}>{item.name}</strong>
|
||||
<span style={{ color: warm, fontWeight: 700 }}>${item.price}</span>
|
||||
{(item.photos && item.photos.length > 0) && (
|
||||
<div style={{ marginBottom: '0.6rem', background: '#e8e4dc', borderRadius: '10px', overflow: 'hidden' }}>
|
||||
<img
|
||||
src={item.featured_photo || item.photos[0]}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: 'clamp(100px, 25vw, 140px)', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.4rem', gap: '0.5rem' }}>
|
||||
<strong style={{ color: navy, fontSize: 'clamp(15px, 3.5vw, 17px)' }}>{item.name}</strong>
|
||||
<span style={{ color: warm, fontWeight: 700, fontSize: 'clamp(15px, 3.5vw, 17px)', whiteSpace: 'nowrap' }}>${item.price}</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{item.description}</p>
|
||||
<p style={{ margin: 0, color: '#555', fontSize: 'clamp(13px, 3vw, 14px)', lineHeight: 1.4 }}>{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Order Modal */}
|
||||
{showOrderModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={closeModal}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
maxWidth: 'min(95vw, 600px)',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
padding: 'clamp(1rem, 4vw, 1.5rem)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{orderResult ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '1rem' }}>
|
||||
{orderResult.success ? '✓' : '✕'}
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy }}>{orderResult.success ? 'Order Placed!' : 'Error'}</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1.5rem' }}>{orderResult.message}</p>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
background: navy,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.75rem 2rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Place Your Order</h3>
|
||||
|
||||
{/* Order Type Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Order Type</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'pickup', label: 'Pickup' },
|
||||
{ value: 'dine_in', label: 'Dine In' },
|
||||
{ value: 'room_delivery', label: 'Room Delivery (+10%)' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setOrderType(opt.value as typeof orderType)}
|
||||
disabled={opt.value === 'room_delivery' && !user}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: '120px',
|
||||
padding: '0.75rem 1rem',
|
||||
border: `2px solid ${orderType === opt.value ? gold : '#ddd'}`,
|
||||
borderRadius: '8px',
|
||||
background: orderType === opt.value ? gold : 'white',
|
||||
color: orderType === opt.value ? 'white' : opt.value === 'room_delivery' && !user ? '#aaa' : navy,
|
||||
cursor: opt.value === 'room_delivery' && !user ? 'not-allowed' : 'pointer',
|
||||
opacity: opt.value === 'room_delivery' && !user ? 0.5 : 1,
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === 'room_delivery' && !user && (
|
||||
<div style={{ fontSize: '11px', marginTop: '2px' }}>Login required</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Selection for Delivery */}
|
||||
{orderType === 'room_delivery' && user && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>
|
||||
{assignedRoom ? 'Your Room' : 'Select Room'}
|
||||
</label>
|
||||
{assignedRoom ? (
|
||||
<div style={{
|
||||
padding: '0.75rem',
|
||||
border: '2px solid gold',
|
||||
borderRadius: '8px',
|
||||
background: '#fff9e6',
|
||||
color: navy,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
🏨 {assignedRoom.name}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedRoom || ''}
|
||||
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<option value="">Select a room...</option>
|
||||
{rooms.map((room) => (
|
||||
<option key={room.id} value={room.id}>{room.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Items Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Items</label>
|
||||
<div style={{ maxHeight: '300px', overflow: 'auto', border: '2px solid #ddd', borderRadius: '8px' }}>
|
||||
{categories.map((category) => (
|
||||
<div key={category}>
|
||||
<div style={{ background: '#f5f5f5', padding: '0.5rem 1rem', fontWeight: 600, color: navy, borderBottom: '1px solid #ddd' }}>
|
||||
{category}
|
||||
</div>
|
||||
{items
|
||||
.filter((i) => i.category === category)
|
||||
.map((item) => {
|
||||
const qty = selectedItems.get(item.id) || 0;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid #eee',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, color: navy }}>{item.name}</div>
|
||||
<div style={{ color: warm, fontWeight: 600 }}>${item.price}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, -1)}
|
||||
disabled={qty === 0}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
border: `2px solid ${qty > 0 ? navy : '#ddd'}`,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
color: qty > 0 ? navy : '#aaa',
|
||||
cursor: qty > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span style={{ minWidth: '24px', textAlign: 'center', fontWeight: 600 }}>{qty}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, 1)}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
border: `2px solid ${navy}`,
|
||||
borderRadius: '50%',
|
||||
background: navy,
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Special Instructions (optional)</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any allergies, preferences, or special requests..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
minHeight: '80px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
{selectedItems.size > 0 && (
|
||||
<div style={{ background: cream, borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem' }}>
|
||||
<h4 style={{ margin: '0 0 0.5rem', color: navy }}>Order Summary</h4>
|
||||
{orderItems.map((item) => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px', marginBottom: '0.25rem' }}>
|
||||
<span>{item.name} × {selectedItems.get(item.id)}</span>
|
||||
<span>${(parseFloat(item.price) * (selectedItems.get(item.id) || 0)).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ borderTop: '1px solid #ddd', marginTop: '0.5rem', paddingTop: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{orderType === 'room_delivery' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: warm }}>
|
||||
<span>Service Fee (10%)</span>
|
||||
<span>${serviceFee.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, marginTop: '0.25rem', fontSize: '16px' }}>
|
||||
<span>Total</span>
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
background: 'white',
|
||||
color: navy,
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitOrder}
|
||||
disabled={submitting || selectedItems.size === 0}
|
||||
style={{
|
||||
flex: 2,
|
||||
padding: '0.75rem',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: submitting || selectedItems.size === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: submitting || selectedItems.size === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Placing Order...' : `Place Order • $${total.toFixed(2)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,24 @@ a {
|
||||
|
||||
.navbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-icon-link {
|
||||
color: var(--gold);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navbar-icon-link:hover {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.navbar-btn-ghost {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import './globals.css';
|
||||
|
||||
export const metadata = {
|
||||
title: 'La Huasca',
|
||||
description: 'Hotel, restaurant, and vineyard in the Honduran highlands',
|
||||
description: 'Hotel and restaurant in the Ecuadorian highlands',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+354
-12
@@ -1,15 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type TermsData = {
|
||||
id?: number;
|
||||
language: string;
|
||||
title: string;
|
||||
content: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [country, setCountry] = useState('');
|
||||
const [preferredLanguage, setPreferredLanguage] = useState('es');
|
||||
const [terms, setTerms] = useState<TermsData | null>(null);
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
const [showTerms, setShowTerms] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
// Check if already logged in
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.authenticated) {
|
||||
router.push(data.role === 'admin' ? '/admin' : '/user');
|
||||
} else {
|
||||
setChecking(false);
|
||||
}
|
||||
})
|
||||
.catch(() => setChecking(false));
|
||||
}, [router]);
|
||||
|
||||
// Fetch terms when language changes
|
||||
useEffect(() => {
|
||||
fetch(`/api/terms?language=${preferredLanguage}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.data) setTerms(data.data);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [preferredLanguage]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
@@ -27,9 +71,82 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Check if user needs to accept terms
|
||||
if (!data.terms_accepted_at) {
|
||||
// Store user data and redirect to terms acceptance
|
||||
localStorage.setItem('pendingUser', JSON.stringify(data));
|
||||
router.push('/accept-terms');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(data.role === 'admin' ? '/admin' : '/user');
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!acceptTerms) {
|
||||
setError('You must accept the terms of service');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
preferred_language: preferredLanguage,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
country,
|
||||
accept_terms: true,
|
||||
}),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-login after registration
|
||||
const loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
router.push('/user');
|
||||
} else {
|
||||
setMode('login');
|
||||
setError('Registration successful! Please log in.');
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#FAF7F0' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '16px' }}>⏳</div>
|
||||
<div style={{ fontSize: '16px', color: '#555' }}>Checking authentication...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const accent = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
@@ -46,34 +163,74 @@ export default function LoginPage() {
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={mode === 'login' ? handleLogin : handleRegister}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '26rem',
|
||||
maxWidth: '28rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
gap: '1rem',
|
||||
padding: '2.5rem',
|
||||
background: cream,
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 6px 18px rgba(14,47,71,0.12)',
|
||||
}}
|
||||
>
|
||||
{/* Language toggle */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreferredLanguage('es')}
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: preferredLanguage === 'es' ? accent : 'transparent',
|
||||
border: `1px solid ${preferredLanguage === 'es' ? accent : '#ccc'}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: preferredLanguage === 'es' ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
🇪🇨 Español
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreferredLanguage('en')}
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: preferredLanguage === 'en' ? accent : 'transparent',
|
||||
border: `1px solid ${preferredLanguage === 'en' ? accent : '#ccc'}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: preferredLanguage === 'en' ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
🇬🇧 English
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
margin: '0 0 0.25rem',
|
||||
margin: '0',
|
||||
fontSize: 'clamp(28px, 4vw, 40px)',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
color: navy,
|
||||
}}
|
||||
>
|
||||
Welcome back
|
||||
{mode === 'login'
|
||||
? (preferredLanguage === 'es' ? 'Bienvenido de nuevo' : 'Welcome back')
|
||||
: (preferredLanguage === 'es' ? 'Crear cuenta' : 'Create account')}
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 1rem', color: '#555' }}>Log in to your portal.</p>
|
||||
<p style={{ margin: '0 0 0.5rem', color: '#555' }}>
|
||||
{mode === 'login'
|
||||
? (preferredLanguage === 'es' ? 'Inicia sesión en tu portal.' : 'Log in to your portal.')
|
||||
: (preferredLanguage === 'es' ? 'Regístrate como huésped.' : 'Register as a guest.')}
|
||||
</p>
|
||||
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
Username
|
||||
{preferredLanguage === 'es' ? 'Usuario' : 'Username'}
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
@@ -91,7 +248,7 @@ export default function LoginPage() {
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
Password
|
||||
{preferredLanguage === 'es' ? 'Contraseña' : 'Password'}
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -108,7 +265,161 @@ export default function LoginPage() {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
{preferredLanguage === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
style={{
|
||||
padding: '0.75rem 0.9rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
background: '#fff',
|
||||
fontSize: '15px',
|
||||
color: navy,
|
||||
outline: 'none',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
{preferredLanguage === 'es' ? 'Nombre' : 'First name'}
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
style={{
|
||||
padding: '0.75rem 0.9rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
background: '#fff',
|
||||
fontSize: '15px',
|
||||
color: navy,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
{preferredLanguage === 'es' ? 'Apellido' : 'Last name'}
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
style={{
|
||||
padding: '0.75rem 0.9rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
background: '#fff',
|
||||
fontSize: '15px',
|
||||
color: navy,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
{preferredLanguage === 'es' ? 'Correo electrónico' : 'Email'}
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
padding: '0.75rem 0.9rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
background: '#fff',
|
||||
fontSize: '15px',
|
||||
color: navy,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
{preferredLanguage === 'es' ? 'Teléfono' : 'Phone'}
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
style={{
|
||||
padding: '0.75rem 0.9rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
background: '#fff',
|
||||
fontSize: '15px',
|
||||
color: navy,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
|
||||
{preferredLanguage === 'es' ? 'País' : 'Country'}
|
||||
<input
|
||||
type="text"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
style={{
|
||||
padding: '0.75rem 0.9rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
background: '#fff',
|
||||
fontSize: '15px',
|
||||
color: navy,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Terms of Service */}
|
||||
<div style={{ background: '#fff', borderRadius: '12px', padding: '1rem', border: '1px solid rgba(1,13,30,0.18)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="accept-terms"
|
||||
checked={acceptTerms}
|
||||
onChange={(e) => setAcceptTerms(e.target.checked)}
|
||||
style={{ marginTop: '0.25rem' }}
|
||||
/>
|
||||
<label htmlFor="accept-terms" style={{ fontSize: '14px', color: '#555', cursor: 'pointer' }}>
|
||||
{preferredLanguage === 'es'
|
||||
? 'Acepto los términos de servicio y la política de privacidad'
|
||||
: 'I accept the terms of service and privacy policy'}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTerms(!showTerms)}
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: accent,
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{preferredLanguage === 'es' ? 'Leer términos completos' : 'Read full terms'}
|
||||
</button>
|
||||
{showTerms && terms && (
|
||||
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f5f5f5', borderRadius: '8px', maxHeight: '200px', overflow: 'auto', fontSize: '13px' }}>
|
||||
<h3 style={{ margin: '0 0 0.5rem', fontSize: '16px' }}>{terms.title}</h3>
|
||||
<p style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{terms.content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p style={{ color: '#c23b22', margin: 0, fontSize: '14px' }}>{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
@@ -131,11 +442,42 @@ export default function LoginPage() {
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
Log in
|
||||
{mode === 'login'
|
||||
? (preferredLanguage === 'es' ? 'Iniciar sesión' : 'Log in')
|
||||
: (preferredLanguage === 'es' ? 'Registrarse' : 'Register')}
|
||||
</button>
|
||||
|
||||
<p style={{ margin: '0.5rem 0 0', fontSize: '13px', color: '#777', textAlign: 'center' }}>
|
||||
Demo accounts: <strong style={{ color: navy }}>admin</strong> / admin and <strong style={{ color: navy }}>user</strong> / user.
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
{preferredLanguage === 'es' ? '¿No tienes cuenta? ' : "Don't have an account? "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('register')}
|
||||
style={{ background: 'none', border: 'none', color: accent, cursor: 'pointer', textDecoration: 'underline', fontSize: '13px' }}
|
||||
>
|
||||
{preferredLanguage === 'es' ? 'Regístrate' : 'Register'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{preferredLanguage === 'es' ? '¿Ya tienes cuenta? ' : 'Already have an account? '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('login')}
|
||||
style={{ background: 'none', border: 'none', color: accent, cursor: 'pointer', textDecoration: 'underline', fontSize: '13px' }}
|
||||
>
|
||||
{preferredLanguage === 'es' ? 'Iniciar sesión' : 'Log in'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{mode === 'login' && (
|
||||
<p style={{ margin: '0.25rem 0 0', fontSize: '12px', color: '#999', textAlign: 'center' }}>
|
||||
{preferredLanguage === 'es' ? 'Contacta a recepción para obtener credenciales.' : 'Contact reception for login credentials.'}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { formatDisplayDateTime } from '@/lib/date';
|
||||
|
||||
type User = { id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Conversation = {
|
||||
id: number;
|
||||
subject: string | null;
|
||||
last_message: string | null;
|
||||
last_message_at: string | null;
|
||||
unread_count: number;
|
||||
participants: Participant[];
|
||||
};
|
||||
|
||||
export default function MessagesPage() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConv, setSelectedConv] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [showNewConv, setShowNewConv] = useState(false);
|
||||
const [convSubject, setConvSubject] = useState('');
|
||||
const [convMessage, setConvMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchConversations();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConv) {
|
||||
fetchMessages(selectedConv);
|
||||
}
|
||||
}, [selectedConv]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.authenticated) {
|
||||
setUser({
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
role: data.role,
|
||||
});
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/conversations');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConversations(data.conversations || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch conversations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async (convId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${convId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(data.messages || []);
|
||||
setParticipants(data.participants || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.trim() || !selectedConv) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newMessage.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setNewMessage('');
|
||||
fetchConversations(); // Update last message in list
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const createConversation = async () => {
|
||||
if (!convMessage.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/conversations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: convSubject || null,
|
||||
initial_message: convMessage.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConversations(prev => [data.conversation, ...prev]);
|
||||
setSelectedConv(data.conversation.id);
|
||||
setShowNewConv(false);
|
||||
setConvSubject('');
|
||||
setConvMessage('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getOtherParticipants = (conv: Conversation) => {
|
||||
return conv.participants.filter(p => p.id !== user?.id);
|
||||
};
|
||||
|
||||
const getDisplayName = (p: Participant) => {
|
||||
if (p.role === 'admin') {
|
||||
return p.first_name || p.username;
|
||||
}
|
||||
return p.first_name || p.username;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-6xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">Messages</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="flex h-[600px]">
|
||||
{/* Conversations List */}
|
||||
<div className={`w-full md:w-1/3 border-r ${selectedConv ? 'hidden md:block' : ''}`}>
|
||||
<div className="p-3 border-b bg-gray-50 flex justify-between items-center">
|
||||
<span className="font-medium text-gray-700">Conversations</span>
|
||||
<button
|
||||
onClick={() => setShowNewConv(true)}
|
||||
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto h-[calc(100%-50px)]">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No conversations yet. Start a new one!
|
||||
</div>
|
||||
) : (
|
||||
conversations.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
|
||||
selectedConv === conv.id ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="font-medium text-gray-800">
|
||||
{conv.subject || getOtherParticipants(conv).map(getDisplayName).join(', ') || 'Conversation'}
|
||||
</div>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<div className="text-sm text-gray-500 truncate mt-1">
|
||||
{conv.last_message}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages View */}
|
||||
<div className={`flex-1 flex flex-col ${selectedConv ? '' : 'hidden md:flex'}`}>
|
||||
{selectedConv ? (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b bg-gray-50">
|
||||
<button
|
||||
onClick={() => setSelectedConv(null)}
|
||||
className="md:hidden text-blue-600 mr-3"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="font-medium text-gray-700">
|
||||
{participants.filter(p => p.id !== user?.id).map(getDisplayName).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.sender_id === user?.id ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[70%] ${msg.sender_id === user?.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
|
||||
{msg.sender_id !== user?.id && (
|
||||
<div className="text-xs font-medium mb-1 text-gray-600">
|
||||
{msg.first_name || msg.username}
|
||||
</div>
|
||||
)}
|
||||
<div>{msg.content}</div>
|
||||
<div className={`text-xs mt-1 ${msg.sender_id === user?.id ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
{formatDisplayDateTime(msg.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t bg-gray-50">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!newMessage.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
Select a conversation or start a new one
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Conversation Modal */}
|
||||
{showNewConv && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-bold mb-4">New Conversation</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={convSubject}
|
||||
onChange={e => setConvSubject(e.target.value)}
|
||||
placeholder="What is this about?"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
|
||||
<textarea
|
||||
value={convMessage}
|
||||
onChange={e => setConvMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
rows={4}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowNewConv(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createConversation}
|
||||
disabled={!convMessage.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+101
-54
@@ -5,6 +5,7 @@ import Slideshow from '@/components/Slideshow';
|
||||
import CalendarStrip from '@/components/CalendarStrip';
|
||||
import WeatherWidget from '@/components/WeatherWidget';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import Link from 'next/link';
|
||||
import { t, useLanguage } from '@/lib/i18n';
|
||||
|
||||
const gold = '#E8A849';
|
||||
@@ -13,94 +14,140 @@ const cream = '#f5f0e8';
|
||||
|
||||
const homeCopy = {
|
||||
en: {
|
||||
title: 'La Huasca',
|
||||
subtitle: 'A mountain retreat with warm hospitality and local flavor.',
|
||||
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
|
||||
tagline: 'Welcome to the Heart of Imbabura',
|
||||
cta_rooms: 'Book a Room',
|
||||
cta_menu: 'View Our Menu',
|
||||
cta_contact: 'Contact Us',
|
||||
},
|
||||
es: {
|
||||
title: 'La Huasca',
|
||||
subtitle: 'Un refugio de montaña con hospitalidad cálida y sabor local.',
|
||||
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
|
||||
tagline: 'Bienvenidos al Corazón de Imbabura',
|
||||
cta_rooms: 'Reservar Habitación',
|
||||
cta_menu: 'Ver Menú',
|
||||
cta_contact: 'Contáctenos',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const fallbackTaglineKey = 'common.home_tagline';
|
||||
|
||||
export default function HomePage() {
|
||||
const language = useLanguage();
|
||||
const [tagline, setTagline] = useState(t(fallbackTaglineKey, language));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fallbackTagline = t(fallbackTaglineKey, language);
|
||||
setTagline(fallbackTagline);
|
||||
fetch('/api/site-assets?key=tagline')
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (!cancelled && j.data) setTagline(j.data);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [language]);
|
||||
|
||||
const copy = homeCopy[language];
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
|
||||
<LanguageSwitcher />
|
||||
<div style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}>
|
||||
<div style={{ position: 'absolute', top: '4rem', right: '1rem', zIndex: 10 }}>
|
||||
<WeatherWidget />
|
||||
</div>
|
||||
|
||||
<Slideshow />
|
||||
|
||||
{/* Compact CTA section */}
|
||||
<section
|
||||
style={{
|
||||
padding: 'clamp(3rem, 9vw, 7rem) 2rem',
|
||||
padding: 'clamp(1.5rem, 4vw, 2.5rem) 2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
color: gold,
|
||||
margin: '0 0 1rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{tagline}
|
||||
</p>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 'clamp(44px, 7vw, 84px)',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.08,
|
||||
margin: '0 0 1.25rem',
|
||||
color: cream,
|
||||
}}
|
||||
>
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p style={{ fontSize: '18px', lineHeight: 1.55, color: 'rgba(245,240,232,0.78)', maxWidth: '52ch', margin: '0 0 2rem' }}>
|
||||
{copy.highlights.map((key, index) => (
|
||||
<span key={key}>
|
||||
{t(key, language)}
|
||||
{index < copy.highlights.length - 1 ? ' · ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<p style={{ fontSize: '16px', lineHeight: 1.65, color: 'rgba(245,240,232,0.7)', maxWidth: '58ch', margin: 0 }}>
|
||||
{copy.subtitle}
|
||||
{copy.tagline}
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<Link
|
||||
href="/rooms"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.875rem 1.75rem',
|
||||
background: gold,
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '15px',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
transition: 'transform 200ms, box-shadow 200ms',
|
||||
boxShadow: '0 4px 12px rgba(232,168,73,0.25)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(232,168,73,0.35)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(232,168,73,0.25)';
|
||||
}}
|
||||
>
|
||||
🛏️ {copy.cta_rooms}
|
||||
</Link>
|
||||
<Link
|
||||
href="/coffee"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.875rem 1.75rem',
|
||||
background: 'transparent',
|
||||
color: cream,
|
||||
fontWeight: 600,
|
||||
fontSize: '15px',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
border: `2px solid ${gold}`,
|
||||
transition: 'background 200ms, color 200ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = gold;
|
||||
e.currentTarget.style.color = navy;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = cream;
|
||||
}}
|
||||
>
|
||||
🍽️ {copy.cta_menu}
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.875rem 1.75rem',
|
||||
background: 'transparent',
|
||||
color: 'rgba(245,240,232,0.8)',
|
||||
fontWeight: 600,
|
||||
fontSize: '15px',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
border: '2px solid rgba(245,240,232,0.3)',
|
||||
transition: 'border-color 200ms, color 200ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(245,240,232,0.6)';
|
||||
e.currentTarget.style.color = cream;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(245,240,232,0.3)';
|
||||
e.currentTarget.style.color = 'rgba(245,240,232,0.8)';
|
||||
}}
|
||||
>
|
||||
✉️ {copy.cta_contact}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CalendarStrip />
|
||||
|
||||
+567
-46
@@ -1,6 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { formatDisplayDate } from '@/lib/date';
|
||||
import RoomCalendar from '@/components/RoomCalendar';
|
||||
import OptimizedImage from '@/components/OptimizedImage';
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
@@ -10,6 +13,7 @@ interface Room {
|
||||
photos: string[];
|
||||
featured_photo: string | null;
|
||||
active?: boolean;
|
||||
amenities: Array<{ id: number; name: string; icon_svg: string }>;
|
||||
}
|
||||
|
||||
interface RoomComment {
|
||||
@@ -29,6 +33,7 @@ interface User {
|
||||
role: 'admin' | 'user';
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email?: string;
|
||||
comments_disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -57,6 +62,15 @@ export default function RoomsPage() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({});
|
||||
|
||||
// Action modal states
|
||||
const [activeModal, setActiveModal] = useState<'reserve' | 'message' | 'calendar' | 'review' | null>(null);
|
||||
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
|
||||
const [reservationDates, setReservationDates] = useState({ checkIn: '', checkOut: '' });
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [reviewText, setReviewText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [guestForm, setGuestForm] = useState({ name: '', contactMethod: 'email', contact: '' });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/rooms')
|
||||
.then(async (res) => {
|
||||
@@ -73,7 +87,21 @@ export default function RoomsPage() {
|
||||
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then((j) => setUser(j.user || null))
|
||||
.then((j) => {
|
||||
if (j.authenticated) {
|
||||
setUser({
|
||||
id: j.id,
|
||||
username: j.username,
|
||||
role: j.role,
|
||||
first_name: j.first_name,
|
||||
last_name: j.last_name,
|
||||
email: j.email,
|
||||
comments_disabled: j.comments_disabled,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -138,23 +166,185 @@ export default function RoomsPage() {
|
||||
|
||||
const getPhoto = (room: Room, index: number): string | null => {
|
||||
if (!room.photos || room.photos.length === 0) return null;
|
||||
return room.photos[Math.min(index, room.photos.length - 1)] || null;
|
||||
const photo = room.photos[Math.min(index, room.photos.length - 1)];
|
||||
if (!photo) return null;
|
||||
// If it's a URL with id= param, extract id; if it's base64, return as-is
|
||||
if (photo.includes('id=')) {
|
||||
return photo;
|
||||
}
|
||||
return photo;
|
||||
};
|
||||
|
||||
const getPhotoId = (photo: string): number | null => {
|
||||
if (!photo) return null;
|
||||
if (photo.includes('id=')) {
|
||||
const match = photo.match(/id=(\d+)/);
|
||||
return match ? parseInt(match[1]) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getActiveIndex = (room: Room): number => {
|
||||
return activePhotoByRoom[room.id] ?? 0;
|
||||
};
|
||||
|
||||
const openModal = (type: 'reserve' | 'message' | 'calendar' | 'review', room: Room) => {
|
||||
setSelectedRoom(room);
|
||||
setActiveModal(type);
|
||||
setReservationDates({ checkIn: '', checkOut: '' });
|
||||
setMessageText('');
|
||||
setReviewText('');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setActiveModal(null);
|
||||
setSelectedRoom(null);
|
||||
};
|
||||
|
||||
const handleReserve = async () => {
|
||||
if (!selectedRoom || !reservationDates.checkIn || !reservationDates.checkOut) {
|
||||
alert('Please select dates');
|
||||
return;
|
||||
}
|
||||
|
||||
// For guests, require name and contact
|
||||
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
|
||||
alert('Please provide your name and contact info');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/reservations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
room_id: selectedRoom.id,
|
||||
check_in: reservationDates.checkIn,
|
||||
check_out: reservationDates.checkOut,
|
||||
...(user ? {} : {
|
||||
guest_name: guestForm.name.trim(),
|
||||
guest_contact_method: guestForm.contactMethod,
|
||||
guest_contact: guestForm.contact.trim(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Reservation request sent! You will receive a confirmation shortly.');
|
||||
closeModal();
|
||||
setReservationDates({ checkIn: '', checkOut: '' });
|
||||
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to create reservation');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to create reservation');
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!selectedRoom || !messageText.trim()) {
|
||||
alert('Please enter a message');
|
||||
return;
|
||||
}
|
||||
|
||||
// For guests, require name and contact
|
||||
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
|
||||
alert('Please provide your name and contact info');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
room_id: selectedRoom.id,
|
||||
message: messageText.trim(),
|
||||
...(user ? {} : {
|
||||
guest_name: guestForm.name.trim(),
|
||||
guest_contact_method: guestForm.contactMethod,
|
||||
guest_contact: guestForm.contact.trim(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Message sent to our team!');
|
||||
closeModal();
|
||||
setMessageText('');
|
||||
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to send message');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to send message');
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!selectedRoom || !reviewText.trim() || !user) {
|
||||
alert('Please log in and enter a review');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${selectedRoom.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ text: reviewText.trim(), status: 'pending' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Review submitted for approval');
|
||||
closeModal();
|
||||
loadComments(selectedRoom.id);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to submit review');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to submit review');
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const displayedRooms = rooms.filter((r) => r.active !== false);
|
||||
|
||||
return (
|
||||
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms & Suites</h1>
|
||||
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
||||
Comfortable stays with views of the vineyard and cloud forest.
|
||||
Comfortable stays with views of the cloud forest.
|
||||
</p>
|
||||
|
||||
{loading && <p style={{ color: warm }}>Loading rooms...</p>}
|
||||
{loading && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '2rem' }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: cream, borderRadius: '20px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
|
||||
<div style={{ width: '100%', height: '300px', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite' }} />
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<div style={{ height: '24px', width: '60%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.75rem' }} />
|
||||
<div style={{ height: '16px', width: '90%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.5rem' }} />
|
||||
<div style={{ height: '16px', width: '75%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px', marginBottom: '0.5rem' }} />
|
||||
<div style={{ height: '16px', width: '50%', background: 'linear-gradient(90deg, #e8e4dc 25%, #f5f0e8 50%, #e8e4dc 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite', borderRadius: '4px' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<style jsx>{`
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`}</style>
|
||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
|
||||
|
||||
@@ -184,65 +374,56 @@ export default function RoomsPage() {
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
||||
>
|
||||
{/* ─── Main Image ─── */}
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<div style={{ position: 'relative', width: '100%', height: '300px' }}>
|
||||
{activeSrc ? (
|
||||
<img
|
||||
src={activeSrc}
|
||||
alt={room.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
activeSrc.startsWith('data:') ? (
|
||||
<img
|
||||
src={activeSrc}
|
||||
alt={room.name}
|
||||
style={{ width: '100%', height: '300px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<OptimizedImage
|
||||
id={parseInt(activeSrc.split('id=')[1])}
|
||||
alt={room.name}
|
||||
size="medium"
|
||||
lazy={true}
|
||||
style={{ width: '100%', height: '300px' }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
|
||||
No photo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Like button overlay */}
|
||||
{/* Like button with count */}
|
||||
<button
|
||||
onClick={() => toggleLike(room.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.4)',
|
||||
color: '#fff',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.15)'; }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
aria-label="Like this room"
|
||||
>
|
||||
{hasLiked ? '\u2764\uFE0F' : '\u2661'}
|
||||
<span style={{ fontSize: '16px' }}>{hasLiked ? '❤️' : '♡'}</span>
|
||||
<span>{totalLikes}</span>
|
||||
</button>
|
||||
|
||||
{/* Like count */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 12,
|
||||
background: hasLiked ? 'rgba(231,76,60,0.85)' : 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '999px',
|
||||
}}>
|
||||
{'\u2764\uFE0F'} {totalLikes}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Thumbnail Strip ─── */}
|
||||
@@ -278,7 +459,21 @@ export default function RoomsPage() {
|
||||
}}
|
||||
>
|
||||
{p ? (
|
||||
<img src={p} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
p.startsWith('data:') ? (
|
||||
<img
|
||||
src={p}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<OptimizedImage
|
||||
id={parseInt(p.split('id=')[1])}
|
||||
alt=""
|
||||
size="thumbnail"
|
||||
lazy={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', background: '#ddd' }} />
|
||||
)}
|
||||
@@ -290,13 +485,105 @@ export default function RoomsPage() {
|
||||
|
||||
{/* ─── Room Info ─── */}
|
||||
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{room.name}</h2>
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
|
||||
<h2 style={{ margin: 0, color: navy, fontSize: '24px' }}>{room.name}</h2>
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1, marginTop: '0.5rem' }}>{room.description}</p>
|
||||
|
||||
{/* ─── Amenity Icons ─── */}
|
||||
{room.amenities && room.amenities.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.75rem' }}>
|
||||
{room.amenities.slice(0, 6).map((amenity) => (
|
||||
<div
|
||||
key={amenity.id}
|
||||
title={amenity.name}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.35rem 0.6rem',
|
||||
background: 'rgba(1,13,30,0.06)',
|
||||
borderRadius: '999px',
|
||||
fontSize: '12px',
|
||||
color: navy,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{amenity.icon_svg ? (
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '16px', height: '16px' }}
|
||||
dangerouslySetInnerHTML={{ __html: amenity.icon_svg
|
||||
.replace(/width="[^"]*"/g, '')
|
||||
.replace(/height="[^"]*"/g, '')
|
||||
.replace(/stroke="currentColor"/g, `stroke="${navy}"`)
|
||||
.replace(/<svg/, '<svg style="width:100%;height:100%"')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span>{amenity.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{room.amenities.length > 6 && (
|
||||
<span style={{ fontSize: '10px', color: '#777', alignSelf: 'center', padding: '0 0.5rem' }}>+{room.amenities.length - 6}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
|
||||
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
|
||||
</div>
|
||||
|
||||
{/* ─── Action Buttons ─── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
onClick={() => openModal('message', room)}
|
||||
style={{
|
||||
padding: '0.6rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
border: `2px solid ${navy}`,
|
||||
background: 'transparent',
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Message
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('calendar', room)}
|
||||
style={{
|
||||
padding: '0.6rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
border: `2px solid ${navy}`,
|
||||
background: 'transparent',
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Reserve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('review', room)}
|
||||
style={{
|
||||
padding: '0.6rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
border: `2px solid ${navy}`,
|
||||
background: 'transparent',
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comments button — cream */}
|
||||
<button
|
||||
onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
|
||||
@@ -378,7 +665,7 @@ export default function RoomsPage() {
|
||||
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
|
||||
</strong>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>
|
||||
{new Date(c.created_at).toLocaleDateString()}
|
||||
{formatDisplayDate(c.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
|
||||
@@ -401,6 +688,240 @@ export default function RoomsPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ─── Modals ─── */}
|
||||
{activeModal && selectedRoom && (
|
||||
<div
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '20px',
|
||||
padding: '2rem',
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Reserve Modal */}
|
||||
{activeModal === 'reserve' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
|
||||
{!user ? (
|
||||
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to make a reservation.</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkIn}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkOut}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
|
||||
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Message Modal */}
|
||||
{activeModal === 'message' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Send a Message</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>Send a message about {selectedRoom.name} to our team.</p>
|
||||
{!user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={guestForm.name}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
|
||||
placeholder="Enter your name"
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
|
||||
<select
|
||||
value={guestForm.contactMethod}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="phone">Phone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
|
||||
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
|
||||
</label>
|
||||
<input
|
||||
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
|
||||
value={guestForm.contact}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
|
||||
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
|
||||
<strong>From:</strong> {user.first_name} {user.last_name} ({user.email})
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
placeholder="Your message..."
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleSendMessage} disabled={submitting || !messageText.trim() || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Sending...' : 'Send Message'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reserve/Calendar Modal */}
|
||||
{activeModal === 'calendar' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>Select your dates and we'll contact you to confirm.</p>
|
||||
{!user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={guestForm.name}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
|
||||
placeholder="Enter your name"
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
|
||||
<select
|
||||
value={guestForm.contactMethod}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="phone">Phone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
|
||||
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
|
||||
</label>
|
||||
<input
|
||||
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
|
||||
value={guestForm.contact}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
|
||||
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
|
||||
<strong>Reserving as:</strong> {user.first_name} {user.last_name} ({user.email})
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkIn}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkOut}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
|
||||
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Availability Calendar</label>
|
||||
<RoomCalendar roomId={selectedRoom.id} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Review Modal */}
|
||||
{activeModal === 'review' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Leave a Review</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>Share your experience with {selectedRoom.name}.</p>
|
||||
{!user ? (
|
||||
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to leave a review.</p>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={reviewText}
|
||||
onChange={(e) => setReviewText(e.target.value)}
|
||||
placeholder="Your review or suggestion..."
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: '#777', marginTop: '0.5rem' }}>Reviews are submitted for admin approval before being published.</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleReview} disabled={submitting || !reviewText.trim()} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Submitting...' : 'Submit Review'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatDisplayDateFull } from '@/lib/date';
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
const warm = '#a6683c';
|
||||
|
||||
interface SocialEvent {
|
||||
id: number;
|
||||
@@ -23,11 +29,6 @@ interface User {
|
||||
comments_disabled?: boolean;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
const warm = '#a6683c';
|
||||
|
||||
export default function SocialEventsPage() {
|
||||
const [events, setEvents] = useState<SocialEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -167,7 +168,7 @@ export default function SocialEventsPage() {
|
||||
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{event.name}</h2>
|
||||
{event.date && (
|
||||
<p style={{ margin: '0 0 0.5rem', color: warm, fontSize: '15px', fontWeight: 600 }}>
|
||||
{new Date(event.date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
{formatDisplayDateFull(event.date)}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{event.description}</p>
|
||||
|
||||
+310
-138
@@ -1,34 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatDisplayDate, formatDisplayDateTime } from '@/lib/date';
|
||||
|
||||
interface Reservation {
|
||||
interface Trip {
|
||||
id: number;
|
||||
date: string;
|
||||
time: string;
|
||||
guests: number;
|
||||
table_name: string;
|
||||
room_id: number | null;
|
||||
room_name: string | null;
|
||||
check_in: string;
|
||||
check_out: string;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Coupon {
|
||||
interface Message {
|
||||
id: number;
|
||||
code: string;
|
||||
discount: string;
|
||||
sender_role: 'user' | 'admin';
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
interface Weather {
|
||||
current: { celsius: number; fahrenheit: number; summary: string; windspeed: number; weathercode: number };
|
||||
today: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
|
||||
}
|
||||
|
||||
interface PortalData {
|
||||
reservations: Reservation[];
|
||||
coupons: Coupon[];
|
||||
offers: Offer[];
|
||||
benefits: string[];
|
||||
trips: Trip[];
|
||||
wifiPassword: string;
|
||||
messages: Message[];
|
||||
weather?: Weather;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
@@ -40,24 +42,76 @@ export default function UserPage() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<PortalData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/user/portal', { credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load portal data');
|
||||
}
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/user/messages', { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(prev => prev ? { ...prev, messages: json.messages } : null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/user/portal', { credentials: 'same-origin' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load portal data');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => setData(json))
|
||||
.catch((err) => console.error(err))
|
||||
.finally(() => setLoading(false));
|
||||
fetchData();
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchMessages, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [data?.messages]);
|
||||
|
||||
const sendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch('/api/user/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ message: newMessage.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewMessage('');
|
||||
fetchMessages();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main style={{ padding: '2rem', color: warm, fontSize: '18px' }}>
|
||||
@@ -67,135 +121,253 @@ export default function UserPage() {
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <main style={{ padding: '2rem' }}><p>Please log in as user to access this page.</p></main>;
|
||||
return <main style={{ padding: '2rem' }}><p>Please log in to access this page.</p></main>;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const activeTrip = data.trips.find(t => t.check_in <= today && t.check_out >= today);
|
||||
const upcomingTrip = data.trips.find(t => t.check_in > today);
|
||||
|
||||
return (
|
||||
<main style={{ padding: '2rem', maxWidth: '56rem', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 2rem' }}>
|
||||
Member Portal
|
||||
<main style={{ padding: '2rem', maxWidth: '64rem', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 1.5rem' }}>
|
||||
Your Portal
|
||||
</h1>
|
||||
|
||||
<Section title="Future Reservations">
|
||||
{data.reservations.length === 0 ? (
|
||||
<p style={{ color: '#777' }}>No upcoming reservations.</p>
|
||||
) : (
|
||||
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{data.reservations.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
style={{
|
||||
padding: '1rem 1.25rem',
|
||||
background: cream,
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 1px 2px rgba(1,13,30,0.08)',
|
||||
color: navy,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: warm }}>{r.date}</strong> at {r.time} · {r.guests} guests · {r.table_name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Discount Coupons">
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
{data.coupons.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
background: cream,
|
||||
border: '2px dashed ' + warm,
|
||||
borderRadius: '16px',
|
||||
minWidth: '13rem',
|
||||
color: navy,
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '18px', color: warm }}>{c.code}</strong>
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#555', fontSize: '14px' }}>{c.discount}</p>
|
||||
{/* Weather Widget - Always visible below navbar */}
|
||||
{data.weather && (
|
||||
<div style={{ background: 'linear-gradient(135deg, #010D1E 0%, #1a2a3a 100%)', borderRadius: '12px', padding: '0.75rem 1.25rem', marginTop: '5rem', marginBottom: '2rem', display: 'flex', alignItems: 'center', gap: '1.5rem', flexWrap: 'wrap', boxShadow: '0 2px 8px rgba(1,13,30,0.15)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ fontSize: '32px' }}>
|
||||
{data.weather.current.weathercode <= 1 ? '☀️' : data.weather.current.weathercode === 2 ? '⛅' : data.weather.current.weathercode === 3 ? '☁️' : data.weather.current.weathercode >= 51 && data.weather.current.weathercode <= 55 ? '🌧️' : data.weather.current.weathercode >= 61 ? '🌧️' : '☀️'}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div style={{ fontSize: '22px', fontWeight: 600, color: '#fff' }}>{data.weather.current.celsius}°C</div>
|
||||
<div style={{ fontSize: '11px', color: 'rgba(255,255,255,0.7)' }}>{data.weather.current.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1.5rem', flex: 1, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>High</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 500, color: '#E8A849' }}>{data.weather.today.maxC}°</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Low</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 500, color: '#88b4e8' }}>{data.weather.today.minC}°</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Wind</div>
|
||||
<div style={{ fontSize: '14px', color: '#fff' }}>{data.weather.current.windspeed} km/h</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Offers">
|
||||
<ul style={{ padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{data.offers.map((o) => (
|
||||
<li
|
||||
key={o.id}
|
||||
style={{
|
||||
padding: '1rem 1.25rem',
|
||||
background: cream,
|
||||
borderRadius: '16px',
|
||||
color: navy,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: warm }}>{o.title}</strong> — {o.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
{/* Welcome Message - Show on reservation day */}
|
||||
{activeTrip && (
|
||||
<Section title="Welcome!" highlight>
|
||||
<div style={{ background: 'linear-gradient(135deg, #E8A849 0%, #d4963f 100%)', padding: '1.5rem', borderRadius: '16px', color: '#fff' }}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontSize: '20px', fontWeight: 600 }}>
|
||||
Welcome to Hosteria La Huasca!
|
||||
</p>
|
||||
<p style={{ margin: '0', opacity: 0.9 }}>
|
||||
Your room{activeTrip.room_name ? ` (${activeTrip.room_name})` : ''} is ready. Check-out: {formatDisplayDate(activeTrip.check_out, { weekday: 'long', month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Future Trip Info */}
|
||||
{upcomingTrip && (
|
||||
<Section title="Your Upcoming Trip">
|
||||
<div style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
|
||||
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-in</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{formatDisplayDate(upcomingTrip.check_in, { weekday: 'short', month: 'short', day: 'numeric' })}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Check-out</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{formatDisplayDate(upcomingTrip.check_out, { weekday: 'short', month: 'short', day: 'numeric' })}</div>
|
||||
</div>
|
||||
{upcomingTrip.room_name && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: warm, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Room</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, color: navy }}>{upcomingTrip.room_name}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{upcomingTrip.notes && (
|
||||
<div style={{ fontSize: '14px', color: '#555', marginTop: '0.5rem' }}>
|
||||
<strong>Notes:</strong> {upcomingTrip.notes}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '1rem', fontSize: '13px', color: '#777' }}>
|
||||
Status: <span style={{ color: warm, fontWeight: 500, textTransform: 'capitalize' }}>{upcomingTrip.status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* WiFi Password */}
|
||||
<Section title="WiFi Password">
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.9rem 2rem',
|
||||
background: navy,
|
||||
color: cream,
|
||||
borderRadius: '12px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '1.4rem',
|
||||
letterSpacing: '0.04em',
|
||||
boxShadow: '0 4px 12px rgba(1,13,30,0.2)',
|
||||
}}
|
||||
>
|
||||
{data.wifiPassword}
|
||||
<div style={{ display: 'inline-block', padding: '0.9rem 2rem', background: navy, color: cream, borderRadius: '12px', fontFamily: 'monospace', fontSize: '1.4rem', letterSpacing: '0.04em', boxShadow: '0 4px 12px rgba(1,13,30,0.2)' }}>
|
||||
{data.wifiPassword || 'Not available'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Member Benefits">
|
||||
<ul style={{ padding: 0, listStyle: 'none', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
|
||||
{data.benefits.map((b) => (
|
||||
<li
|
||||
key={b}
|
||||
style={{
|
||||
padding: '0.9rem 1.1rem',
|
||||
background: '#e8eff3',
|
||||
borderLeft: '4px solid ' + gold,
|
||||
borderRadius: '0 12px 12px 0',
|
||||
color: navy,
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Change Password */}
|
||||
<Section title="Change Password">
|
||||
<ChangePassword />
|
||||
</Section>
|
||||
|
||||
{/* Chat with Admins */}
|
||||
<Section title="Message Staff">
|
||||
<div style={{ background: cream, borderRadius: '16px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto', padding: '1rem' }}>
|
||||
{data.messages.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', color: '#777', padding: '2rem' }}>
|
||||
No messages yet. Send a message to contact our staff.
|
||||
</div>
|
||||
) : (
|
||||
data.messages.map((m) => (
|
||||
<div key={m.id} style={{ display: 'flex', justifyContent: m.sender_role === 'user' ? 'flex-end' : 'flex-start', marginBottom: '0.75rem' }}>
|
||||
<div style={{ maxWidth: '70%', padding: '0.75rem 1rem', borderRadius: '12px', background: m.sender_role === 'user' ? gold : '#fff', color: m.sender_role === 'user' ? '#fff' : navy, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '0.25rem' }}>
|
||||
{m.sender_role === 'admin' ? 'Staff' : 'You'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>{m.message}</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7, marginTop: '0.25rem' }}>
|
||||
{formatDisplayDateTime(m.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={sendMessage} style={{ display: 'flex', gap: '0.5rem', padding: '1rem', borderTop: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
disabled={sending}
|
||||
style={{ flex: 1, padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
|
||||
/>
|
||||
<button type="submit" disabled={sending || !newMessage.trim()} style={{ padding: '0.75rem 1.5rem', background: gold, color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 600, cursor: sending ? 'wait' : 'pointer', opacity: sending ? 0.7 : 1 }}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({ title, children, highlight }: { title: string; children: React.ReactNode; highlight?: boolean }) {
|
||||
return (
|
||||
<section style={{ marginBottom: '2.5rem' }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em',
|
||||
color: navy,
|
||||
margin: '0 0 1rem',
|
||||
paddingBottom: '0.4rem',
|
||||
borderBottom: '1.5px solid ' + warm,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, letterSpacing: '-0.01em', color: highlight ? '#fff' : navy, margin: '0 0 1rem', paddingBottom: '0.4rem', borderBottom: highlight ? 'none' : '1.5px solid ' + warm, display: 'inline-block' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePassword() {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
|
||||
if (newPassword.length < 4) {
|
||||
setMessage({ text: 'Password must be at least 4 characters', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMessage({ text: 'Passwords do not match', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/user/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ text: 'Password changed successfully!', type: 'success' });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
setMessage({ text: err.error || 'Failed to change password', type: 'error' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ text: 'Error changing password', type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)', maxWidth: '400px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
{message && (
|
||||
<div style={{ padding: '0.75rem', borderRadius: '8px', background: message.type === 'success' ? '#d4edda' : '#f8d7da', color: message.type === 'success' ? '#155724' : '#721c24', fontSize: '14px' }}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !currentPassword || !newPassword || !confirmPassword}
|
||||
style={{ padding: '0.75rem 1.5rem', background: gold, color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 600, cursor: saving ? 'wait' : 'pointer', opacity: saving ? 0.7 : 1 }}
|
||||
>
|
||||
{saving ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { getEcuadorToday, getEcuadorDateFromToday, daysUntilEcuador, getMonthDayEcuador } from '@/lib/date';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: number;
|
||||
@@ -12,8 +13,32 @@ interface CalendarEvent {
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
current?: { celsius: number; fahrenheit: number; summary: string };
|
||||
today?: { maxC: number; minC: number; maxF: number; minF: number; summary: string };
|
||||
current?: { celsius: number; fahrenheit: number; summary: string; weathercode: number };
|
||||
today?: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
|
||||
tomorrow?: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
|
||||
dayAfter?: { maxC: number; minC: number; maxF: number; minF: number; summary: string; weathercode: number };
|
||||
}
|
||||
|
||||
const weatherIcons: Record<number, string> = {
|
||||
0: '☀️', 1: '🌤️', 2: '⛅', 3: '☁️',
|
||||
45: '🌫️', 48: '🌫️', 51: '🌦️', 53: '🌦️', 55: '🌧️',
|
||||
61: '🌧️', 63: '🌧️', 65: '🌧️', 71: '🌨️', 73: '🌨️',
|
||||
75: '🌨️', 80: '🌦️', 81: '🌧️', 82: '🌧️',
|
||||
95: '⛈️', 96: '⛈️', 99: '⛈️'
|
||||
};
|
||||
|
||||
function getWeatherIcon(code: number | undefined): string {
|
||||
return code !== undefined ? (weatherIcons[code] || '🌤️') : '🌤️';
|
||||
}
|
||||
|
||||
function getDateLabel(daysFromToday: number): { month: string; day: number; label: string } {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysFromToday);
|
||||
return {
|
||||
month: d.toLocaleString('en-US', { month: 'short' }),
|
||||
day: d.getDate(),
|
||||
label: daysFromToday === 0 ? 'Today' : daysFromToday === 1 ? 'Tomorrow' : 'Day After'
|
||||
};
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
@@ -26,22 +51,6 @@ const typeEmoji: Record<CalendarEvent['type'], string> = {
|
||||
event: '📌',
|
||||
};
|
||||
|
||||
function formatMonthDay(dateStr: string) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return { month: d.toLocaleString('en-US', { month: 'short' }), day: d.getDate() };
|
||||
}
|
||||
|
||||
function daysUntil(dateStr: string) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const diff = Math.ceil((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diff === 0) return 'Today';
|
||||
if (diff === 1) return 'Tomorrow';
|
||||
if (diff < 0) return 'Past';
|
||||
return `in ${diff} days`;
|
||||
}
|
||||
|
||||
export default function CalendarStrip() {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||
@@ -60,21 +69,40 @@ export default function CalendarStrip() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const cards = events.map((e) => ({ ...e, isWeather: false })).concat(
|
||||
weather
|
||||
? [
|
||||
{
|
||||
id: 9999,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
title: `${weather.current?.celsius ?? '--'}°C / ${weather.current?.fahrenheit ?? '--'}°F ${weather.current?.summary ?? ''}`,
|
||||
type: 'weather' as const,
|
||||
description: `Today ${weather.today?.maxC ?? '--'}°C / ${weather.today?.maxF ?? '--'}°F · ${weather.today?.minC ?? '--'}°C / ${weather.today?.minF ?? '--'}°F`,
|
||||
color: '#4FC3F7',
|
||||
isWeather: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
// Build weather cards: today, tomorrow, day after
|
||||
const weatherCards = weather
|
||||
? [
|
||||
{
|
||||
id: 9997,
|
||||
date: getEcuadorToday(),
|
||||
title: `${getWeatherIcon(weather.today?.weathercode)} ${weather.current?.celsius ?? '--'}°C`,
|
||||
type: 'weather' as const,
|
||||
description: `Today: ${weather.today?.maxC ?? '--'}° / ${weather.today?.minC ?? '--'}°C · ${weather.today?.summary ?? ''}`,
|
||||
color: '#4FC3F7',
|
||||
isWeather: true,
|
||||
},
|
||||
{
|
||||
id: 9998,
|
||||
date: getEcuadorDateFromToday(1),
|
||||
title: `${getWeatherIcon(weather.tomorrow?.weathercode)} ${weather.tomorrow?.maxC ?? '--'}° / ${weather.tomorrow?.minC ?? '--'}°C`,
|
||||
type: 'weather' as const,
|
||||
description: `Tomorrow: ${weather.tomorrow?.summary ?? ''}`,
|
||||
color: '#81D4FA',
|
||||
isWeather: true,
|
||||
},
|
||||
{
|
||||
id: 9999,
|
||||
date: getEcuadorDateFromToday(2),
|
||||
title: `${getWeatherIcon(weather.dayAfter?.weathercode)} ${weather.dayAfter?.maxC ?? '--'}° / ${weather.dayAfter?.minC ?? '--'}°C`,
|
||||
type: 'weather' as const,
|
||||
description: `Day After: ${weather.dayAfter?.summary ?? ''}`,
|
||||
color: '#4DD0E1',
|
||||
isWeather: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const cards = [...events.map((e) => ({ ...e, isWeather: false })), ...weatherCards];
|
||||
|
||||
if (cards.length === 0) return null;
|
||||
|
||||
@@ -86,22 +114,50 @@ export default function CalendarStrip() {
|
||||
overflow: 'hidden',
|
||||
borderTop: `1px solid rgba(232,168,73,0.25)`,
|
||||
borderBottom: `1px solid rgba(232,168,73,0.25)`,
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
{/* Left fade mask */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '80px',
|
||||
background: `linear-gradient(to right, ${navy} 0%, transparent 100%)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
{/* Right fade mask */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '80px',
|
||||
background: `linear-gradient(to left, ${navy} 0%, transparent 100%)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
padding: '0 1rem',
|
||||
width: 'max-content',
|
||||
animation: paused ? 'none' : 'marquee 38s linear infinite',
|
||||
animation: 'marquee 60s linear infinite',
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
ref={trackRef}
|
||||
>
|
||||
{[...cards, ...cards].map((e, i) => {
|
||||
const { month, day } = formatMonthDay(e.date);
|
||||
const { month, day } = getMonthDayEcuador(e.date);
|
||||
const accent = e.color || gold;
|
||||
return (
|
||||
<div
|
||||
@@ -156,7 +212,7 @@ export default function CalendarStrip() {
|
||||
{e.description || ''}
|
||||
</p>
|
||||
<div style={{ marginTop: '0.8rem', fontSize: '12px', color: 'rgba(245,240,232,0.5)' }}>
|
||||
{e.isWeather ? 'Otavalo now' : daysUntil(e.date)}
|
||||
{e.isWeather ? 'Otavalo now' : daysUntilEcuador(e.date)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { FacebookIcon, InstagramIcon, WhatsAppIcon } from './icons';
|
||||
|
||||
interface Config {
|
||||
tagline?: string;
|
||||
facebook_url?: string;
|
||||
instagram_url?: string;
|
||||
whatsapp_url?: string;
|
||||
@@ -47,7 +48,7 @@ export default function Footer() {
|
||||
<div>
|
||||
<h3 style={{ color: gold, fontSize: '18px', margin: '0 0 1rem', fontStyle: 'italic' }}>La Huasca</h3>
|
||||
<p style={{ color: 'rgba(245,240,232,0.75)', lineHeight: 1.55, margin: 0 }}>
|
||||
A quiet corner of the Ecuadorian Andes.
|
||||
{config.tagline || 'A quiet corner of the Ecuadorian Andes.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,11 +4,22 @@ import { Language, setLanguage, useLanguage } from '@/lib/i18n';
|
||||
|
||||
function Flag({ language }: { language: Language }) {
|
||||
if (language === 'en') {
|
||||
// Union Jack (UK flag) - simplified but recognizable
|
||||
return (
|
||||
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" aria-hidden="true">
|
||||
<rect width="24" height="16" fill="white" />
|
||||
<rect x="0" y="6" width="24" height="4" fill="#CE1124" />
|
||||
<rect x="10" y="0" width="4" height="16" fill="#CE1124" />
|
||||
<svg width="24" height="16" viewBox="0 0 60 30" fill="none" aria-hidden="true">
|
||||
{/* Blue background */}
|
||||
<rect width="60" height="30" fill="#012169"/>
|
||||
{/* White diagonal saltire (St Andrew) */}
|
||||
<path d="M0 0L60 30M60 0L0 30" stroke="white" strokeWidth="6"/>
|
||||
{/* Red diagonal saltire (St Patrick) - offset */}
|
||||
<path d="M0 0L30 15M60 0L30 15" stroke="#C8102E" strokeWidth="2"/>
|
||||
<path d="M60 30L30 15M0 30L30 15" stroke="#C8102E" strokeWidth="2"/>
|
||||
{/* White cross (St George + St Andrew) */}
|
||||
<rect x="25" y="0" width="10" height="30" fill="white"/>
|
||||
<rect x="0" y="10" width="60" height="10" fill="white"/>
|
||||
{/* Red cross (St George) */}
|
||||
<rect x="27" y="0" width="6" height="30" fill="#C8102E"/>
|
||||
<rect x="0" y="12" width="60" height="6" fill="#C8102E"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -37,8 +48,8 @@ export default function LanguageSwitcher() {
|
||||
onClick={toggleLanguage}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
top: '0.5rem',
|
||||
right: '7rem',
|
||||
zIndex: 1000,
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid #ddd',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default function Navbar() {
|
||||
link.href = data.data;
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
@@ -85,6 +85,17 @@ export default function Navbar() {
|
||||
})}
|
||||
</ul>
|
||||
<div className="navbar-actions">
|
||||
{auth?.authenticated && (
|
||||
<Link
|
||||
href={auth?.role === 'admin' ? '/admin/messages' : '/messages'}
|
||||
className="navbar-icon-link"
|
||||
title="Messages"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
{auth?.authenticated ? (
|
||||
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}>←</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' }}>→</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>
|
||||
);
|
||||
}
|
||||
@@ -2,72 +2,46 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function celsiusToFahrenheit(c: number): number {
|
||||
return Math.round((c * 9/5) + 32);
|
||||
}
|
||||
|
||||
export default function WeatherWidget() {
|
||||
const [weather, setWeather] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
|
||||
// Load weather configuration
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/site-assets?key=weather_config');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.data) {
|
||||
const parsedConfig = JSON.parse(data.data);
|
||||
setConfig(parsedConfig);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load weather config:', err);
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
// Fetch weather data when config is loaded and enabled
|
||||
useEffect(() => {
|
||||
if (!config || !config.enabled || config.cities.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Use the first city in the list for now
|
||||
const city = config.cities[0];
|
||||
const res = await fetch(`/api/weather?lat=${city.lat}&lon=${city.lon}`);
|
||||
const res = await fetch('/api/weather');
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setWeather(data);
|
||||
setWeather(data.data);
|
||||
} else {
|
||||
throw new Error('Failed to fetch weather data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Weather API error:', err);
|
||||
setError('Failed to load weather data');
|
||||
setError('Failed to load weather');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeather();
|
||||
}, [config]);
|
||||
|
||||
if (!config?.enabled) return null;
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
padding: '40px 16px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.2)'
|
||||
@@ -78,49 +52,57 @@ export default function WeatherWidget() {
|
||||
}
|
||||
|
||||
if (error || !weather) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.2)'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Weather unavailable</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = weather.current;
|
||||
const temp = current.celsius;
|
||||
const icon = '🌤️';
|
||||
const today = weather.today;
|
||||
|
||||
const weatherIcons: Record<number, string> = {
|
||||
0: '☀️', 1: '🌤️', 2: '⛅', 3: '☁️',
|
||||
45: '🌫️', 48: '🌫️', 51: '🌦️', 53: '🌦️', 55: '🌦️',
|
||||
61: '🌧️', 63: '🌧️', 65: '🌧️', 71: '🌨️', 73: '🌨️',
|
||||
75: '🌨️', 80: '🌦️', 81: '🌦️', 82: '🌧️',
|
||||
95: '⛈️', 96: '⛈️', 99: '⛈️'
|
||||
};
|
||||
|
||||
const icon = weatherIcons[current.weathercode] || '🌤️';
|
||||
const tempF = celsiusToFahrenheit(current.celsius);
|
||||
const maxF = celsiusToFahrenheit(today.maxC);
|
||||
const minF = celsiusToFahrenheit(today.minC);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
minWidth: '180px'
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
minWidth: '160px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '20px' }}>{icon}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '28px' }}>{icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{temp}°C
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#1a1a2e' }}>
|
||||
{current.celsius}°C / {tempF}°F
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666', marginTop: '2px' }}>
|
||||
{current.summary}
|
||||
</div>
|
||||
{config.cities[0] && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
|
||||
{config.cities[0].name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}>
|
||||
Updated: {new Date(current.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||
fontSize: '11px',
|
||||
color: '#555'
|
||||
}}>
|
||||
<span>📍 Otavalo</span>
|
||||
<span>↑{today.maxC}°C ({maxF}°F) ↓{today.minC}°C ({minF}°F)</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-10
@@ -18,17 +18,8 @@ export function useAuth() {
|
||||
}, []);
|
||||
|
||||
const login = useCallback((username: string, password: string): boolean => {
|
||||
// TODO: Implement server-side authentication via API call
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (username === 'admin' && password === 'admin') {
|
||||
localStorage.setItem(ROLE_KEY, 'admin');
|
||||
setRole('admin');
|
||||
return true;
|
||||
}
|
||||
if (username === 'user' && password === 'user') {
|
||||
localStorage.setItem(ROLE_KEY, 'user');
|
||||
setRole('user');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
@@ -17,30 +17,46 @@ export async function verifyPassword(password: string, hash: string) {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password: string, role: 'admin' | 'user') {
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password: string,
|
||||
role: 'admin' | 'user',
|
||||
profile?: { first_name?: string; last_name?: string; email?: string; phone?: string; country?: string; preferred_language?: string }
|
||||
) {
|
||||
const hash = await hashPassword(password);
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = EXCLUDED.role RETURNING id, username, role',
|
||||
[username, hash, role]
|
||||
`INSERT INTO users (username, password_hash, role, first_name, last_name, email, phone, country, preferred_language)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = EXCLUDED.role
|
||||
RETURNING id, username, role, first_name, last_name, email, phone, country, preferred_language`,
|
||||
[username, hash, role, profile?.first_name || null, profile?.last_name || null, profile?.email || null, profile?.phone || null, profile?.country || null, profile?.preferred_language || 'es']
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function authenticateUser(username: string, password: string) {
|
||||
const { rows } = await db.query('SELECT id, username, password_hash, role FROM users WHERE username = $1', [username]);
|
||||
const { rows } = await db.query('SELECT id, username, password_hash, role, preferred_language, terms_accepted_at, first_name, last_name FROM users WHERE username = $1', [username]);
|
||||
if (rows.length === 0) return null;
|
||||
const user = rows[0];
|
||||
const valid = await verifyPassword(password, user.password_hash);
|
||||
if (!valid) return null;
|
||||
return { id: user.id, username: user.username, role: user.role };
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
preferred_language: user.preferred_language || 'es',
|
||||
terms_accepted_at: user.terms_accepted_at,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSession(user: { id: number; username: string; role: string }) {
|
||||
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setExpirationTime('7d')
|
||||
.setExpirationTime('4h') // 4 hours for security (reduced from 7 days)
|
||||
.sign(getSecret());
|
||||
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 7 });
|
||||
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 4 }); // 4 hours
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
+125
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Rate limiting for public API endpoints
|
||||
// Uses in-memory store with sliding window - suitable for single-instance deployments
|
||||
// For multi-instance deployments, consider using Redis or database-backed rate limiting
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
interface RateLimitConfig {
|
||||
windowMs: number; // Time window in milliseconds
|
||||
maxRequests: number; // Max requests per window
|
||||
}
|
||||
|
||||
// Default configs for different endpoints
|
||||
export const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||
'private-event-reservation': {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
maxRequests: 5, // 5 submissions per hour per IP
|
||||
},
|
||||
'contact-form': {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
maxRequests: 10,
|
||||
},
|
||||
'login': {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
maxRequests: 10,
|
||||
},
|
||||
// Admin endpoints - higher limits for legitimate admin use
|
||||
'admin-private-events': {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
maxRequests: 100, // 100 requests per minute
|
||||
},
|
||||
'admin-social-events': {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
maxRequests: 100, // 100 requests per minute
|
||||
},
|
||||
};
|
||||
|
||||
// Clean up expired entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
rateLimitStore.forEach((entry, key) => {
|
||||
if (entry.resetAt < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000); // Clean every minute
|
||||
|
||||
/**
|
||||
* Check if a request is rate limited
|
||||
* @param endpointKey - The endpoint identifier (e.g., 'private-event-reservation')
|
||||
* @param identifier - The client identifier (e.g., IP address)
|
||||
* @returns { limited: boolean, remaining: number, resetAt: number }
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
endpointKey: string,
|
||||
identifier: string
|
||||
): { limited: boolean; remaining: number; resetAt: number; retryAfter?: number } {
|
||||
const config = RATE_LIMITS[endpointKey];
|
||||
if (!config) {
|
||||
// No rate limit configured for this endpoint
|
||||
return { limited: false, remaining: Infinity, resetAt: Date.now() + 60000 };
|
||||
}
|
||||
|
||||
const key = `${endpointKey}:${identifier}`;
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(key);
|
||||
|
||||
// If no entry or window expired, create new entry
|
||||
if (!entry || entry.resetAt < now) {
|
||||
const resetAt = now + config.windowMs;
|
||||
rateLimitStore.set(key, { count: 1, resetAt });
|
||||
return { limited: false, remaining: config.maxRequests - 1, resetAt };
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (entry.count >= config.maxRequests) {
|
||||
return {
|
||||
limited: true,
|
||||
remaining: 0,
|
||||
resetAt: entry.resetAt,
|
||||
retryAfter: Math.ceil((entry.resetAt - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// Increment count
|
||||
entry.count++;
|
||||
return { limited: false, remaining: config.maxRequests - entry.count, resetAt: entry.resetAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP from request headers
|
||||
* Handles X-Forwarded-For and X-Real-IP headers for reverse proxy setups
|
||||
*/
|
||||
export function getClientIp(request: Request): string {
|
||||
// Check X-Forwarded-For header (common for reverse proxies)
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
// Take the first IP (original client) from the chain
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Check X-Real-IP header (used by some proxies)
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
// Fallback - this may be undefined in some environments
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware-style rate limit checker for API routes
|
||||
* Returns null if allowed, or a Response object if rate limited
|
||||
*/
|
||||
export function withRateLimit(
|
||||
request: Request,
|
||||
endpointKey: string
|
||||
): { allowed: true } | { allowed: false; response: Response } {
|
||||
const clientIp = getClientIp(request);
|
||||
const result = checkRateLimit(endpointKey, clientIp);
|
||||
|
||||
if (result.limited) {
|
||||
return {
|
||||
allowed: false,
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: result.retryAfter,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(result.retryAfter || 60),
|
||||
'X-RateLimit-Limit': String(RATE_LIMITS[endpointKey]?.maxRequests || 0),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
+386
-10
@@ -20,6 +20,8 @@ export async function initSchema() {
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
thumbnail TEXT,
|
||||
medium TEXT,
|
||||
category VARCHAR(100) DEFAULT 'other',
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
location_target VARCHAR(100) DEFAULT 'gallery',
|
||||
@@ -27,6 +29,10 @@ export async function initSchema() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Add columns if they don't exist (for existing databases)
|
||||
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS medium TEXT`);
|
||||
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS thumbnail TEXT`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -100,10 +106,16 @@ export async function initSchema() {
|
||||
is_daily_special BOOLEAN DEFAULT FALSE,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Add columns if they don't exist (for existing databases)
|
||||
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
|
||||
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -120,6 +132,10 @@ export async function initSchema() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Add columns if they don't exist (for existing databases)
|
||||
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
|
||||
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -168,11 +184,17 @@ export async function initSchema() {
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
is_private BOOLEAN DEFAULT FALSE,
|
||||
max_guests INTEGER,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Add is_private and max_guests columns if they don't exist
|
||||
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE`);
|
||||
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS max_guests INTEGER`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS social_event_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -186,16 +208,16 @@ export async function initSchema() {
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL UNIQUE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(20) NOT NULL
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL UNIQUE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(20) NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
@@ -222,6 +244,38 @@ export async function initSchema() {
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT 'es';
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMP;
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS country VARCHAR(100);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS terms_content (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
version VARCHAR(20) NOT NULL DEFAULT '1.0',
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(language, version)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS availability (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -233,6 +287,260 @@ export async function initSchema() {
|
||||
UNIQUE(room_id, date)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
check_in DATE NOT NULL,
|
||||
check_out DATE NOT NULL,
|
||||
notes TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'confirmed' CHECK (status IN ('pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
sender_role VARCHAR(20) NOT NULL CHECK (sender_role IN ('user', 'admin')),
|
||||
message TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_amenities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
icon_svg TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_amenity_links (
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
amenity_id INTEGER REFERENCES room_amenities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (room_id, amenity_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
check_in DATE NOT NULL,
|
||||
check_out DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
total_price DECIMAL(10,2),
|
||||
guest_name VARCHAR(100),
|
||||
guest_contact_method VARCHAR(20),
|
||||
guest_contact VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_availability (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
||||
date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'available',
|
||||
reason TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(room_id, date)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER REFERENCES rooms(id),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
message TEXT NOT NULL,
|
||||
guest_name VARCHAR(100),
|
||||
guest_contact_method VARCHAR(20),
|
||||
guest_contact VARCHAR(255),
|
||||
read_by_admins BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`ALTER TABLE room_comments ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'approved'`);
|
||||
|
||||
// Add guest columns to room_reservations
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE room_reservations ALTER COLUMN user_id DROP NOT NULL`);
|
||||
|
||||
// Add guest columns to messages
|
||||
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
|
||||
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
|
||||
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE messages ALTER COLUMN user_id DROP NOT NULL`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_reservations_room_dates ON reservations(room_id, check_in, check_out)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_availability_room_date ON room_availability(room_id, date)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC)`);
|
||||
|
||||
// ─── Conversations & Direct Messages ───
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subject VARCHAR(255),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
joined_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(conversation_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_conversation_participants_user ON conversation_participants(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation ON direct_messages(conversation_id, created_at DESC)`);
|
||||
|
||||
// ─── Bulk Messages ───
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS bulk_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id),
|
||||
subject VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
recipient_type VARCHAR(20) NOT NULL DEFAULT 'all_customers',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS bulk_message_recipients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
bulk_message_id INTEGER NOT NULL REFERENCES bulk_messages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
read_at TIMESTAMP,
|
||||
UNIQUE(bulk_message_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Orders tables for coffee/kitchen
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
order_type VARCHAR(20) NOT NULL,
|
||||
room_id INTEGER REFERENCES rooms(id),
|
||||
subtotal DECIMAL(10,2) NOT NULL,
|
||||
service_fee DECIMAL(10,2) DEFAULT 0,
|
||||
total DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
menu_item_id INTEGER NOT NULL REFERENCES menu_items(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price DECIMAL(10,2) NOT NULL,
|
||||
total_price DECIMAL(10,2) NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS private_event_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_name VARCHAR(255) NOT NULL,
|
||||
contact_name VARCHAR(255) NOT NULL,
|
||||
contact_email VARCHAR(255),
|
||||
contact_phone VARCHAR(50),
|
||||
contact_method VARCHAR(20) NOT NULL CHECK (contact_method IN ('email', 'phone', 'whatsapp')),
|
||||
event_date DATE,
|
||||
event_time TIME,
|
||||
guests INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Add status column to social_event_reservations if it doesn't exist
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled'))`);
|
||||
|
||||
// Add contact columns to social_event_reservations
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50)`);
|
||||
await db.query(`ALTER TABLE social_event_reservations ALTER COLUMN user_id DROP NOT NULL`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
|
||||
|
||||
// Room assignments - admins assign registered users to rooms
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_assignments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
check_in DATE NOT NULL,
|
||||
check_out DATE NOT NULL,
|
||||
notes TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(room_id, check_in, check_out)
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_room ON room_assignments(room_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_user ON room_assignments(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_dates ON room_assignments(check_in, check_out)`);
|
||||
|
||||
// Audit log for admin actions
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER,
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`);
|
||||
}
|
||||
|
||||
export async function migrateFromLegacyPlaces() {
|
||||
@@ -250,6 +558,20 @@ export async function migrateFromLegacyPlaces() {
|
||||
await db.query(`
|
||||
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||
`);
|
||||
|
||||
// Add room status columns for housekeeping management
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS clean_status VARCHAR(20) DEFAULT 'clean'`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS notes TEXT`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS last_cleaned TIMESTAMP`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assigned_staff_id INTEGER REFERENCES users(id)`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS priority VARCHAR(10) DEFAULT 'normal'`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS issues_count INTEGER DEFAULT 0`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS is_vip BOOLEAN DEFAULT FALSE`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_time TIME`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_date DATE`);
|
||||
|
||||
// Add payment_status to room_reservations
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) DEFAULT 'pending'`);
|
||||
}
|
||||
|
||||
export async function seed() {
|
||||
@@ -314,6 +636,60 @@ export async function seed() {
|
||||
if (configTaglineRows.length === 0) {
|
||||
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', tagline]);
|
||||
}
|
||||
|
||||
const { rows: calendarRows } = await db.query(`SELECT id FROM calendar_events`);
|
||||
if (calendarRows.length === 0) {
|
||||
await db.query(
|
||||
`INSERT INTO calendar_events (date, title, type, description, color, active) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
['2026-06-30', 'Welcome Dinner', 'event', 'Andean tasting menu', '#b45309', true]
|
||||
);
|
||||
}
|
||||
|
||||
const { rows: socialRows } = await db.query(`SELECT id FROM social_events`);
|
||||
if (socialRows.length === 0) {
|
||||
await db.query(
|
||||
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order) VALUES ($1, $2, $3, $4, '{}', null, $5, $6)`,
|
||||
['Sunset Tasting', 'Local wines on the terrace.', 'USD 35', '2026-07-04', true, 1]
|
||||
);
|
||||
}
|
||||
|
||||
const { rows: socialReservationRows } = await db.query(`SELECT id FROM social_event_reservations`);
|
||||
if (socialReservationRows.length === 0) {
|
||||
const { rows: adminIdRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
|
||||
if (adminIdRows.length > 0) {
|
||||
await db.query(
|
||||
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes, status) SELECT $1, id, $2, $3, $4 FROM social_events LIMIT 1`,
|
||||
[adminIdRows[0].id, 2, 'Anniversary', 'confirmed']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: amenityRows } = await db.query(`SELECT id FROM room_amenities`);
|
||||
if (amenityRows.length === 0) {
|
||||
const defaultAmenities = [
|
||||
['Wi-Fi', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>', 1],
|
||||
['TV', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg>', 2],
|
||||
['Shower', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V4"/><path d="M4 9h4"/><path d="M6 9v12"/><path d="M10 4h10a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H10"/><circle cx="6" cy="14" r="1"/><circle cx="6" cy="18" r="1"/><circle cx="10" cy="14" r="1"/><circle cx="10" cy="18" r="1"/></svg>', 3],
|
||||
['Bathtub', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16a1 1 0 0 1 1 1v3a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4v-3a1 1 0 0 1 1-1z"/><path d="M6 12V5a2 2 0 0 1 2-2h3v2.25"/><circle cx="12" cy="5" r=".5"/></svg>', 4],
|
||||
['Fireplace', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.14-4.052 1.5-5 .5 2 2 4 2 6a2.5 2.5 0 0 0 5 0c0-1.38-.5-2-1-3-.5-1-1-2-1-3.5 1.5 1 3 2.5 3 5a4 4 0 1 1-8 0"/><path d="M4 19h16a2 2 0 0 0 2-2v-2H2v2a2 2 0 0 0 2 2z"/></svg>', 5],
|
||||
['Minibar', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3z"/><path d="M5 9v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>', 6],
|
||||
['Coffee Maker', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>', 7],
|
||||
['Safe', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>', 8],
|
||||
['Heating', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/></svg>', 9],
|
||||
['Balcony', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><line x1="3" y1="17" x2="21" y2="17"/><line x1="7" y1="11" x2="7" y2="21"/><line x1="12" y1="11" x2="12" y2="21"/><line x1="17" y1="11" x2="17" y2="21"/></svg>', 10],
|
||||
['King Bed', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4"/><path d="M2 14v-2a2 2 0 0 1 2-2h1"/><path d="M22 14v-2a2 2 0 0 0-2-2h-1"/><path d="M5 4h14a2 2 0 0 1 2 2v2H3V6a2 2 0 0 1 2-2z"/><rect x="3" y="10" width="18" height="4" rx="1"/></svg>', 11],
|
||||
['Queen Bed', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3"/><path d="M2 13v-2a2 2 0 0 1 2-2h1"/><path d="M18 13v-2a2 2 0 0 0-2-2h-1"/><path d="M4 5h10a2 2 0 0 1 2 2v1H2V7a2 2 0 0 1 2-2z"/><rect x="2" y="9" width="14" height="3" rx="1"/></svg>', 13],
|
||||
['2 Queen Beds', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 15v-2a1.5 1.5 0 0 1 1.5-1.5H10a1.5 1.5 0 0 1 1.5 1.5v2"/><path d="M12.5 15v-2a1.5 1.5 0 0 1 1.5-1.5H21.5a1.5 1.5 0 0 1 1.5 1.5v2"/><path d="M1 11V9.5A1.5 1.5 0 0 1 2.5 8h8A1.5 1.5 0 0 1 12 9.5V11"/><path d="M12 11V9.5A1.5 1.5 0 0 1 13.5 8h8A1.5 1.5 0 0 1 23 9.5V11"/><rect x="1" y="8" width="11" height="2.5" rx="0.75"/><rect x="12" y="8" width="11" height="2.5" rx="0.75"/></svg>', 14],
|
||||
['Mountain View', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>', 12],
|
||||
];
|
||||
|
||||
for (const [name, icon_svg, sort_order] of defaultAmenities) {
|
||||
await db.query(
|
||||
'INSERT INTO room_amenities (name, icon_svg, sort_order) VALUES ($1, $2, $3)',
|
||||
[name, icon_svg, sort_order]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetDatabase() {
|
||||
|
||||
+45
-23
@@ -1,40 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
// Simple middleware for Next.js Edge Runtime
|
||||
// This is a basic implementation that doesn't verify tokens
|
||||
// In a production environment, you would want to verify the token
|
||||
export function middleware(request: NextRequest) {
|
||||
// Paths that require authentication
|
||||
const PROTECTED_PATHS = ['/admin'];
|
||||
// Paths that require no authentication (login page should not redirect logged-in users)
|
||||
const AUTH_PATHS = ['/login'];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Protect admin routes
|
||||
if (pathname.startsWith('/admin')) {
|
||||
// Get session token from cookies
|
||||
const token = request.cookies.get('session')?.value;
|
||||
// Check if this is a protected path
|
||||
const isProtectedPath = PROTECTED_PATHS.some(path => pathname.startsWith(path));
|
||||
const isAuthPath = AUTH_PATHS.some(path => pathname.startsWith(path));
|
||||
|
||||
// If no token, redirect to login
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
if (isProtectedPath) {
|
||||
// Get session token from cookie
|
||||
const sessionToken = request.cookies.get('session')?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
// No session, redirect to login
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Note: In a real implementation, you would verify the token here
|
||||
// For now, we're just checking that a token exists
|
||||
// Session exists - actual auth validation happens in API routes
|
||||
// We can't verify JWT in Edge runtime without external libraries
|
||||
// The login page and API routes handle full auth checks
|
||||
}
|
||||
|
||||
// Redirect authenticated users from login page to home
|
||||
if (pathname === '/login') {
|
||||
const token = request.cookies.get('session')?.value;
|
||||
if (token) {
|
||||
// In a real implementation, you would verify the token and check role
|
||||
// For now, we'll just redirect to home
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
// Add security headers to all responses
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-XSS-Protection', '1; mode=block');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Content Security Policy - allow inline styles for Next.js
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob: https:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-ancestors 'none';"
|
||||
);
|
||||
|
||||
// HSTS for production (assuming TLS termination at proxy)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
return response;
|
||||
}
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*', '/login'],
|
||||
matcher: ['/admin/:path*', '/login', '/api/:path*'],
|
||||
};
|
||||
+5
-1
@@ -31,13 +31,17 @@ describe('auth helpers', () => {
|
||||
it('authenticates a user with the stored password hash', async () => {
|
||||
const passwordHash = await hashPassword('secret-password');
|
||||
query.mockResolvedValueOnce({
|
||||
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin' }],
|
||||
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin', first_name: null, last_name: null, preferred_language: 'es', terms_accepted_at: null }],
|
||||
});
|
||||
|
||||
await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({
|
||||
id: 2,
|
||||
username: 'bob',
|
||||
role: 'admin',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
preferred_language: 'es',
|
||||
terms_accepted_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,13 +20,18 @@ describe('/api/rooms', () => {
|
||||
});
|
||||
|
||||
it('returns active rooms', async () => {
|
||||
// Mock rooms query
|
||||
query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] });
|
||||
// Mock amenities query
|
||||
query.mockResolvedValueOnce({ rows: [] });
|
||||
// Mock images query
|
||||
query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true }] });
|
||||
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true, photos: [], amenities: [] }] });
|
||||
});
|
||||
|
||||
it('creates a room for admins', async () => {
|
||||
@@ -52,6 +57,6 @@ describe('/api/rooms', () => {
|
||||
|
||||
expect(requireRole).toHaveBeenCalledWith('admin');
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00' } });
|
||||
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00', photos: [], amenity_ids: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user