Compare commits
104 Commits
ba920406c8
...
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 | |||
| f771ad751b | |||
| d8dcd7eadb | |||
| ebfa8dcdb0 | |||
| 991e73fcff | |||
| 01ea9e391d | |||
| 295c024ced | |||
| e1b091b25d | |||
| 4b6e7f4362 | |||
| f6b76eb396 | |||
| fdca926468 | |||
| 80c562b439 | |||
| bd3f3e2d3b | |||
| e97e880695 | |||
| b4b8cbcce1 | |||
| c46dc826fb | |||
| 55fe8ce866 | |||
| 610dd16438 | |||
| e78e8f4d5a | |||
| 50affc13a2 | |||
| 1b696a1cc3 | |||
| c4ee3ef5f0 | |||
| 16aff31870 | |||
| 0ca57b8aee | |||
| c15b19b071 | |||
| 0e57b61266 | |||
| f8baeedba3 | |||
| a47f54d22b | |||
| 6b13a35fbe | |||
| 52f6668f02 | |||
| 69cb389ecf |
@@ -0,0 +1,6 @@
|
||||
# Example local env. Copy to .env.local and fill values for local development.
|
||||
# Values in Kubernetes are injected via secrets/environment; do not hardcode them here.
|
||||
# DATABASE_URL should be provided in .env.local only.
|
||||
# JWT_SECRET should be provided in .env.local only.
|
||||
|
||||
NEXT_PUBLIC_SITE_NAME=Hotel La Huasca
|
||||
@@ -1,3 +1,4 @@
|
||||
.env
|
||||
.env.local
|
||||
tmp/
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Migration script to fix missing columns in existing tables
|
||||
|
||||
We've identified that the `init-db.sh` script in the repository already includes the correct schema with all the necessary columns, but when it was run on the database, it skipped adding new columns to existing tables because they already existed.
|
||||
|
||||
To fix this issue, we've created a migration script that adds the missing columns to existing tables:
|
||||
|
||||
1. Added `photos` and `featured_photo` columns to the `rooms` table
|
||||
2. Added `photos` and `featured_photo` columns to the `places` table
|
||||
3. Added `photos` and `featured_photo` columns to the `menu_items` table
|
||||
4. Added `first_name` and `last_name` columns to the `users` table
|
||||
5. Added `comments_disabled` column to the `users` table
|
||||
6. Added `featured_photo` column to the `reservations` table
|
||||
7. Added `featured_photo` column to the `social_events` table
|
||||
8. Added `featured_photo` column to the `reviews` table
|
||||
9. Added `featured_photo` column to the `social_event_reservations` table
|
||||
10. Added `featured_photo` column to the `calendar_events` table
|
||||
11. Added `featured_photo` column to the `room_comments` table
|
||||
12. Added `featured_photo` column to the `offers` table
|
||||
13. Added `featured_photo` column to the `coupons` table
|
||||
14. Added `featured_photo` column to the `benefits` table
|
||||
15. Added `featured_photo` column to the `slides` table
|
||||
16. Added `featured_photo` column to the `config` table
|
||||
|
||||
This migration script should be run on any existing database to ensure all tables have the correct schema.
|
||||
@@ -47,14 +47,52 @@ Kubernetes runtime uses:
|
||||
|
||||
### Initialize the database schema and seed data
|
||||
|
||||
Once the database is reachable and `DATABASE_URL` is set, start the dev server and call:
|
||||
There are several ways to initialize the database depending on your environment:
|
||||
|
||||
#### Option 1: Using the init-db.sh script (for direct server access)
|
||||
|
||||
If you have direct access to the Kubernetes cluster, you can use the `init-db.sh` script:
|
||||
|
||||
```bash
|
||||
./init-db.sh
|
||||
```
|
||||
|
||||
This script will automatically detect the postgres pod and initialize the database with all required tables and seed data.
|
||||
|
||||
#### Option 2: Using the Kubernetes job (recommended for production)
|
||||
|
||||
To initialize the database using a Kubernetes job:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/init-db-job.yaml
|
||||
```
|
||||
|
||||
You can monitor the job status with:
|
||||
```bash
|
||||
kubectl get jobs -n lahuasca
|
||||
kubectl get pods -n lahuasca | grep init-database
|
||||
```
|
||||
|
||||
To view the job logs:
|
||||
```bash
|
||||
kubectl logs -n lahuasca -l job-name=init-database
|
||||
```
|
||||
|
||||
Once the job completes successfully, you can remove it:
|
||||
```bash
|
||||
kubectl delete job init-database -n lahuasca
|
||||
```
|
||||
|
||||
#### Option 3: Using the API endpoint (development only)
|
||||
|
||||
Note: This method only works in development mode and is disabled in production.
|
||||
|
||||
Start the dev server and call:
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/db/init
|
||||
```
|
||||
|
||||
To reset everything (drops and recreates tables with seed data):
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/db/init?reset=true"
|
||||
```
|
||||
@@ -76,4 +114,4 @@ Visit `/admin` after logging in as `admin` to upload images. Images are previewe
|
||||
|
||||
## User portal
|
||||
|
||||
Visit `/user` after logging in as `user` to see a member portal with reservations, discount coupons, offers, the WiFi password, and a list of benefits.
|
||||
Visit `/user` after logging in as `user` to see a member portal with reservations, discount coupons, offers, the WiFi password, and a list of benefits.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Database Migration Script
|
||||
|
||||
This script addresses the issue where `init-db.sh` skipped adding new columns to existing tables.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Make sure you have access to the PostgreSQL database
|
||||
2. Run the migration script:
|
||||
```bash
|
||||
./migration-fix-columns.sh
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
The script adds missing columns to existing tables in the database. It uses `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements to ensure that:
|
||||
1. If a column already exists, it won't cause an error
|
||||
2. If a column doesn't exist, it will be added
|
||||
|
||||
## Affected Tables
|
||||
|
||||
- rooms
|
||||
- places
|
||||
- menu_items
|
||||
- users
|
||||
- reservations
|
||||
- social_events
|
||||
- reviews
|
||||
- social_event_reservations
|
||||
- calendar_events
|
||||
- room_comments
|
||||
- offers
|
||||
- coupons
|
||||
- benefits
|
||||
- slides
|
||||
- config
|
||||
|
||||
## Columns Added
|
||||
|
||||
- photos (TEXT[])
|
||||
- featured_photo (VARCHAR(500))
|
||||
- first_name (VARCHAR(255))
|
||||
- last_name (VARCHAR(255))
|
||||
- comments_disabled (BOOLEAN DEFAULT false)
|
||||
+2
-3
@@ -3,15 +3,14 @@ set -euo pipefail
|
||||
|
||||
# Deploy Postgres for la-huasca in its own namespace
|
||||
|
||||
NAMESPACE="lahuasca"
|
||||
NAMESPACE="${NAMESPACE:-lahuasca}"
|
||||
SECRET_NAME="la-huasca-db-secrets"
|
||||
PVC_NAME="postgres-data"
|
||||
|
||||
# Generate a secure random password if secret doesn't exist yet
|
||||
if ! kubectl get secret "$SECRET_NAME" -n "$NAMESPACE" >/dev/null 2>&1; then
|
||||
echo "==> Generating database password ..."
|
||||
PASSWORD=*** 75d7 | base64 | head -c 32 | tr -d '
|
||||
'
|
||||
PASSWORD=$(openssl rand -base64 32 | tr -d '\n')
|
||||
DATABASE_URL="postgres://lahuasca:$PASSWORD@postgres.$NAMESPACE.svc.cluster.local:5432/lahuasca"
|
||||
|
||||
kubectl create secret generic "$SECRET_NAME" \
|
||||
|
||||
@@ -12,7 +12,7 @@ set -euo pipefail
|
||||
# - docker or buildah/push capability (we build locally and push)
|
||||
# - registry secret exists or REGISTRY_USER/REGISTRY_PASS are exported
|
||||
|
||||
NAMESPACE="lahuasca"
|
||||
NAMESPACE="${NAMESPACE:-lahuasca}"
|
||||
IMAGE="gitea.ajavasoft.com/muken/la-huasca-ts"
|
||||
TAG="${TAG:-$(git rev-parse --short HEAD)}"
|
||||
HOSTS=("lahuasca.com" "www.lahuasca.com")
|
||||
@@ -64,7 +64,7 @@ envsubst < "./k8s/app.yaml" | kubectl apply -f -
|
||||
|
||||
# Wait for rollout
|
||||
echo "==> Waiting for rollout ..."
|
||||
kubectl rollout status deployment/la-huasca-nextjs -n "$NAMESPACE" --timeout=180s
|
||||
kubectl rollout status deployment/la-huasca-ts -n "$NAMESPACE" --timeout=180s
|
||||
|
||||
# Print summary
|
||||
IP="$(kubectl get service traefik -n kube-system -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo 'node IP')"
|
||||
@@ -76,7 +76,7 @@ echo " Hosts: ${HOSTS[*]}"
|
||||
echo " LB IP: $IP"
|
||||
echo ""
|
||||
echo "To seed the database, run:"
|
||||
echo " kubectl exec -n $NAMESPACE deploy/la-huasca-nextjs -- curl -s -X POST http://localhost:3000/api/db/init"
|
||||
echo " kubectl exec -n $NAMESPACE deploy/la-huasca-ts -- curl -s -X POST http://localhost:3000/api/db/init"
|
||||
echo ""
|
||||
echo "To bootstrap mmendoza admin user:"
|
||||
echo " kubectl exec -n $NAMESPACE deploy/la-huasca-nextjs -- curl -s -X POST http://localhost:3000/api/bootstrap-user"
|
||||
echo " kubectl exec -n $NAMESPACE deploy/la-huasca-ts -- curl -s -X POST http://localhost:3000/api/bootstrap-user"
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/* 1. Add spacing vars
|
||||
Added for consistent responsive spacing
|
||||
*/
|
||||
:root {
|
||||
--sp-base: 0.5rem;
|
||||
--sp-sm: 1rem;
|
||||
--sp-md: 1.5rem;
|
||||
--sp-lg: 3rem;
|
||||
}
|
||||
|
||||
/* Existing color vars remain */
|
||||
:root {
|
||||
--gold: #E8A849;
|
||||
--cream: #f5f0e8;
|
||||
--navy: #001321;
|
||||
--ink: #1a1a1a;
|
||||
--warm: #a6683c;
|
||||
--bg: #FAF7F0;
|
||||
--surface: #e8eff3;
|
||||
}
|
||||
|
||||
/* 2. Responsive adjustments */
|
||||
@media (min-width: 640px) {
|
||||
.navbar {
|
||||
padding: var(--sp-sm) var(--sp-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 3. Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111;
|
||||
--ink: #faf7f0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 4. Focus visible */
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 5. Navbar link reuse */
|
||||
.navbar-link {
|
||||
display: inline-block;
|
||||
color: rgba(245, 240, 232, 0.85);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1.5px solid transparent;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar-link.active {
|
||||
color: var(--gold);
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
.navbar-link:hover {
|
||||
color: var(--cream);
|
||||
}
|
||||
|
||||
/* 6. Typography helpers */
|
||||
h1 { font-size: 2.5rem; font-weight: 700; }
|
||||
h2 { font-size: 2rem; font-weight: 600; }
|
||||
Executable
+244
@@ -0,0 +1,244 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Initializing La Huasca database (local version)..."
|
||||
|
||||
sudo -u postgres psql -d lahuasca << 'EOF'
|
||||
-- Create all tables
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
||||
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()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
category VARCHAR(100) DEFAULT 'other',
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
location_target VARCHAR(100) DEFAULT 'gallery',
|
||||
uploaded_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
time TIME NOT NULL,
|
||||
guests INTEGER NOT NULL,
|
||||
table_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
discount VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS benefits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS menu_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
is_daily_special BOOLEAN DEFAULT FALSE,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
distance_km NUMERIC(8,2),
|
||||
visit_time VARCHAR(100),
|
||||
suggestion TEXT,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
author_name VARCHAR(255) NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
text TEXT NOT NULL,
|
||||
approved BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS slides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255),
|
||||
subtitle TEXT,
|
||||
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
|
||||
image_url TEXT,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
photo_url VARCHAR(500),
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_initial VARCHAR(10),
|
||||
text TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
deleted_by_admin BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS social_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price VARCHAR(50),
|
||||
date DATE,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS social_event_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
social_event_id INTEGER REFERENCES social_events(id) ON DELETE CASCADE,
|
||||
guests INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, social_event_id)
|
||||
);
|
||||
|
||||
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()
|
||||
);
|
||||
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
-- 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;
|
||||
|
||||
INSERT INTO config (key, value) VALUES
|
||||
('wifi_password', 'LaHuascaGuest2026'),
|
||||
('tagline', 'Hotel · Restaurant · Andean Highlands')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
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;
|
||||
|
||||
INSERT INTO benefits (text) VALUES
|
||||
('Priority booking for peak weekends'),
|
||||
('Late checkout on availability'),
|
||||
('Free welcome cocktail'),
|
||||
('Member-only events and tastings')
|
||||
ON CONFLICT DO NOTHING;
|
||||
EOF
|
||||
|
||||
echo "Verifying database tables..."
|
||||
sudo -u postgres psql -d lahuasca -c "\dt"
|
||||
echo "Database initialization completed successfully!"
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Database initialization script for La Huasca
|
||||
# This script creates the necessary tables and seeds initial data
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "Initializing La Huasca database..."
|
||||
|
||||
# Get the postgres pod name
|
||||
POSTGRES_POD=$(kubectl get pods -n "${NAMESPACE:-lahuasca}" | grep postgres | awk '{print $1}')
|
||||
|
||||
if [ -z "$POSTGRES_POD" ]; then
|
||||
echo "Error: Could not find postgres pod"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found postgres pod: $POSTGRES_POD"
|
||||
|
||||
# Create the SQL file with schema definitions
|
||||
cat > /tmp/schema.sql << 'EOF'
|
||||
-- Create all tables
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
||||
comments_disabled BOOLEAN DEFAULT FALSE,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
category VARCHAR(100) DEFAULT 'other',
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
location_target VARCHAR(100) DEFAULT 'gallery',
|
||||
uploaded_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
time TIME NOT NULL,
|
||||
guests INTEGER NOT NULL,
|
||||
table_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
discount VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS benefits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS menu_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
is_daily_special BOOLEAN DEFAULT FALSE,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
distance_km NUMERIC(8,2),
|
||||
visit_time VARCHAR(100),
|
||||
suggestion TEXT,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
author_name VARCHAR(255) NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
text TEXT NOT NULL,
|
||||
approved BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS slides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255),
|
||||
subtitle TEXT,
|
||||
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
|
||||
image_url VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
photo_url VARCHAR(500),
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_initial VARCHAR(10),
|
||||
text TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
deleted_by_admin BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS social_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price VARCHAR(50),
|
||||
date DATE,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS social_event_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
social_event_id INTEGER REFERENCES social_events(id) ON DELETE CASCADE,
|
||||
guests INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, social_event_id)
|
||||
);
|
||||
|
||||
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()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add missing columns if they don't exist
|
||||
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);
|
||||
|
||||
-- Insert initial data
|
||||
-- Default admin user (password: admin)
|
||||
INSERT INTO users (username, password_hash, role) VALUES
|
||||
('admin', '${BOOTSTRAP_ADMIN_PASSWORD_HASH}', 'admin')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Default config values
|
||||
INSERT INTO config (key, value) VALUES
|
||||
('wifi_password', '${WIFI_PASSWORD}'),
|
||||
('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)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Sample benefits
|
||||
INSERT INTO benefits (text) VALUES
|
||||
('Priority booking for peak weekends'),
|
||||
('Late checkout on availability'),
|
||||
('Free welcome cocktail'),
|
||||
('Member-only events and tastings')
|
||||
ON CONFLICT DO NOTHING;
|
||||
EOF
|
||||
|
||||
# Copy the schema file to the postgres pod
|
||||
echo "Copying schema to postgres pod..."
|
||||
kubectl cp /tmp/schema.sql "${NAMESPACE:-lahuasca}/$POSTGRES_POD:/tmp/schema.sql"
|
||||
|
||||
# Execute the schema
|
||||
echo "Executing database schema..."
|
||||
kubectl exec -n "${NAMESPACE:-lahuasca}" "$POSTGRES_POD" -- psql -U "${POSTGRES_USER:-lahuasca}" -d "${POSTGRES_DB:-lahuasca}" -f /tmp/schema.sql
|
||||
|
||||
# Verify that the tables were created
|
||||
echo "Verifying database tables..."
|
||||
kubectl exec -n "${NAMESPACE:-lahuasca}" deploy/postgres -- psql -U "${POSTGRES_USER:-lahuasca}" -d "${POSTGRES_DB:-lahuasca}" -c "\dt"
|
||||
|
||||
echo "Database initialization completed successfully!"
|
||||
+38
-4
@@ -27,8 +27,44 @@ spec:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: calendar-db-secrets
|
||||
key: url
|
||||
name: la-huasca-db-secrets
|
||||
key: DATABASE_URL
|
||||
- name: BOOTSTRAP_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_ADMIN_PASSWORD
|
||||
- name: BOOTSTRAP_USER_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_USER_PASSWORD
|
||||
- name: BOOTSTRAP_WIFI_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_WIFI_PASSWORD
|
||||
- name: BOOTSTRAP_TAGLINE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: la-huasca-bootstrap-config
|
||||
key: BOOTSTRAP_TAGLINE
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
@@ -56,8 +92,6 @@ kind: Ingress
|
||||
metadata:
|
||||
name: la-huasca-ts
|
||||
namespace: lahuasca
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: la-huasca-bootstrap-config
|
||||
namespace: lahuasca
|
||||
data:
|
||||
BOOTSTRAP_TAGLINE: "Hotel · Restaurant · Andean Highlands"
|
||||
@@ -0,0 +1,11 @@
|
||||
# WARNING: Replace CHANGE_ME_* values before applying
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
namespace: lahuasca
|
||||
type: Opaque
|
||||
stringData:
|
||||
BOOTSTRAP_ADMIN_PASSWORD: "CHANGE_ME_ADMIN_PASSWORD"
|
||||
BOOTSTRAP_USER_PASSWORD: "CHANGE_ME_USER_PASSWORD"
|
||||
BOOTSTRAP_WIFI_PASSWORD: "CHANGE_ME_WIFI_PASSWORD"
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
"kind": "Deployment",
|
||||
"metadata": {
|
||||
"name": "la-huasca-ts",
|
||||
"namespace": "la-huasca-ts"
|
||||
"namespace": "lahuasca"
|
||||
},
|
||||
"spec": {
|
||||
"replicas": 1,
|
||||
@@ -43,8 +43,8 @@
|
||||
"name": "DATABASE_URL",
|
||||
"valueFrom": {
|
||||
"secretKeyRef": {
|
||||
"name": "calendar-db-secrets",
|
||||
"key": "url"
|
||||
"name": "la-huasca-db-secrets",
|
||||
"key": "DATABASE_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"kind": "Ingress",
|
||||
"metadata": {
|
||||
"name": "la-huasca-ts",
|
||||
"namespace": "la-huasca-ts",
|
||||
"namespace": "lahuasca",
|
||||
"annotations": {
|
||||
"traefik.ingress.kubernetes.io/router.entrypoints": "web,websecure"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: la-huasca-bootstrap-config
|
||||
namespace: lahuasca
|
||||
data:
|
||||
BOOTSTRAP_TAGLINE: Hotel · Restaurant · Andean Highlands
|
||||
schema.sql: |
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
||||
comments_disabled BOOLEAN DEFAULT FALSE,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
category VARCHAR(100) DEFAULT 'other',
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
location_target VARCHAR(100) DEFAULT 'gallery',
|
||||
uploaded_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
time TIME NOT NULL,
|
||||
guests INTEGER NOT NULL,
|
||||
table_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
discount VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS benefits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS menu_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
is_daily_special BOOLEAN DEFAULT FALSE,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
distance_km NUMERIC(8,2),
|
||||
visit_time VARCHAR(100),
|
||||
suggestion TEXT,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
author_name VARCHAR(255) NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
text TEXT NOT NULL,
|
||||
approved BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS slides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255),
|
||||
subtitle TEXT,
|
||||
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
|
||||
image_url VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
photo_url VARCHAR(500),
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_initial VARCHAR(10),
|
||||
text TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
deleted_by_admin BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS social_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price VARCHAR(50),
|
||||
date DATE,
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS social_event_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
social_event_id INTEGER REFERENCES social_events(id) ON DELETE CASCADE,
|
||||
guests INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, social_event_id)
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
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);
|
||||
seed.sh: |
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${BOOTSTRAP_ADMIN_USERNAME:?missing BOOTSTRAP_ADMIN_USERNAME}"
|
||||
: "${BOOTSTRAP_ADMIN_PASSWORD:?missing BOOTSTRAP_ADMIN_PASSWORD}"
|
||||
: "${BOOTSTRAP_USER_USERNAME:?missing BOOTSTRAP_USER_USERNAME}"
|
||||
: "${BOOTSTRAP_USER_PASSWORD:?missing BOOTSTRAP_USER_PASSWORD}"
|
||||
: "${BOOTSTRAP_WIFI_PASSWORD:?missing BOOTSTRAP_WIFI_PASSWORD}"
|
||||
: "${BOOTSTRAP_TAGLINE:?missing BOOTSTRAP_TAGLINE}"
|
||||
|
||||
psql -h "${DB_HOST}" -p "${DB_PORT}" -U "$POSTGRES_USER" -d "$POSTGRES_DB" <<'SQL'
|
||||
INSERT INTO users (username, password_hash, role)
|
||||
VALUES
|
||||
('${BOOTSTRAP_ADMIN_USERNAME}', '${BOOTSTRAP_ADMIN_PASSWORD}', 'admin'),
|
||||
('${BOOTSTRAP_USER_USERNAME}', '${BOOTSTRAP_USER_PASSWORD}', 'user')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
INSERT INTO config (key, value)
|
||||
VALUES
|
||||
('wifi_password', '${BOOTSTRAP_WIFI_PASSWORD}'),
|
||||
('tagline', '${BOOTSTRAP_TAGLINE}')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
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)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO benefits (text)
|
||||
VALUES
|
||||
('Priority booking for peak weekends'),
|
||||
('Late checkout on availability'),
|
||||
('Free welcome cocktail'),
|
||||
('Member-only events and tastings')
|
||||
ON CONFLICT DO NOTHING;
|
||||
SQL
|
||||
@@ -0,0 +1,96 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: init-database
|
||||
namespace: lahuasca
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: init-db
|
||||
image: postgres:15
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
- |
|
||||
until pg_isready -h "${DB_HOST}" -p "${DB_PORT}" -U "$POSTGRES_USER"; do
|
||||
echo "Waiting for postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
psql \
|
||||
-h "${DB_HOST}" \
|
||||
-p "${DB_PORT}" \
|
||||
-U "$POSTGRES_USER" \
|
||||
-d "$POSTGRES_DB" \
|
||||
-f /bootstrap/schema.sql
|
||||
|
||||
sh /bootstrap/seed.sh
|
||||
|
||||
echo "Database initialization completed!"
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: postgres
|
||||
- name: DB_PORT
|
||||
value: "5432"
|
||||
- name: PGPASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-db-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-db-secrets
|
||||
key: POSTGRES_USER
|
||||
- name: POSTGRES_DB
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-db-secrets
|
||||
key: POSTGRES_DB
|
||||
- name: BOOTSTRAP_ADMIN_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_ADMIN_USERNAME
|
||||
- name: BOOTSTRAP_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_ADMIN_PASSWORD
|
||||
- name: BOOTSTRAP_USER_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_USER_USERNAME
|
||||
- name: BOOTSTRAP_USER_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_USER_PASSWORD
|
||||
- name: BOOTSTRAP_WIFI_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: la-huasca-bootstrap-secrets
|
||||
key: BOOTSTRAP_WIFI_PASSWORD
|
||||
- name: BOOTSTRAP_TAGLINE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: la-huasca-bootstrap-config
|
||||
key: BOOTSTRAP_TAGLINE
|
||||
volumeMounts:
|
||||
- name: bootstrap
|
||||
mountPath: /bootstrap
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: bootstrap
|
||||
configMap:
|
||||
name: la-huasca-bootstrap-config
|
||||
defaultMode: 0755
|
||||
items:
|
||||
- key: schema.sql
|
||||
path: schema.sql
|
||||
- key: seed.sh
|
||||
path: seed.sh
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: la-huasca-registry
|
||||
namespace: ${NAMESPACE}
|
||||
namespace: lahuasca
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
stringData:
|
||||
.dockerconfigjson: |
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"kind": "Service",
|
||||
"metadata": {
|
||||
"name": "la-huasca-ts",
|
||||
"namespace": "la-huasca-ts"
|
||||
"namespace": "lahuasca"
|
||||
},
|
||||
"spec": {
|
||||
"ports": [
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Migration script to add missing columns to existing tables
|
||||
# This script addresses the issue where init-db.sh skipped adding new columns to existing tables
|
||||
|
||||
echo "Adding missing columns to existing tables..."
|
||||
|
||||
# Add photos and featured_photo columns to rooms table if they don't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE rooms ADD COLUMN IF NOT EXISTS photos TEXT[];"
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add photos and featured_photo columns to places table if they don't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE places ADD COLUMN IF NOT EXISTS photos TEXT[];"
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add photos and featured_photo columns to menu_items table if they don't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS photos TEXT[];"
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add first_name and last_name columns to users table if they don't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(255);"
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(255);"
|
||||
|
||||
# Add comments_disabled column to users table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT false;"
|
||||
|
||||
# Add featured_photo column to reservations table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE reservations ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to social_events table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE social_events ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to reviews table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE reviews ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to social_event_reservations table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to calendar_events table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to room_comments table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE room_comments ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to offers table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE offers ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to coupons table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE coupons ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to benefits table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE benefits ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to slides table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE slides ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
# Add featured_photo column to config table if it doesn't exist
|
||||
psql -U lahuasca -d lahuasca -c "ALTER TABLE config ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);"
|
||||
|
||||
echo "Migration completed successfully!"
|
||||
@@ -2,6 +2,16 @@
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
Generated
+3221
-47
File diff suppressed because it is too large
Load Diff
+13
-5
@@ -5,24 +5,32 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -H 0.0.0.0 -p 3000",
|
||||
"lint": "next lint"
|
||||
"start": "node .next/standalone/server.js",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.17.30",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/react": "^18.3.20",
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"typescript": "^5.4.5"
|
||||
"@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>
|
||||
);
|
||||
}
|
||||
+3632
-1164
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,12 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export async function GET() {
|
||||
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 {
|
||||
await requireRole('admin');
|
||||
const { rows } = await db.query('SELECT id, filename, uploaded_at FROM images ORDER BY uploaded_at DESC');
|
||||
return NextResponse.json({ images: rows, count: rows.length });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get('category');
|
||||
const roomId = searchParams.get('room_id');
|
||||
|
||||
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;
|
||||
|
||||
if (category) {
|
||||
conditions.push(`category = $${paramIndex++}`);
|
||||
params.push(category);
|
||||
}
|
||||
if (roomId) {
|
||||
conditions.push(`room_id = $${paramIndex++}`);
|
||||
params.push(parseInt(roomId));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ data: rows, count: rows.length });
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -20,18 +85,21 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { filename, data } = body;
|
||||
const { filename, data, category, room_id, location_target } = body;
|
||||
|
||||
if (!filename || !data) {
|
||||
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) VALUES ($1, $2) RETURNING id, filename, uploaded_at',
|
||||
[filename, data]
|
||||
'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,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||
|
||||
// GET /api/admin/menu — returns ALL menu items (including inactive)
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const { rows } = await db.query(
|
||||
'SELECT * FROM menu_items ORDER BY sort_order, id'
|
||||
);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/admin/menu error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to fetch menu' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
@@ -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,11 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createUser } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const user = await createUser('mmendoza', 'W3canbeheroes*-*', 'admin');
|
||||
const username = process.env.BOOTSTRAP_ADMIN_USERNAME || 'mmendoza';
|
||||
const password = process.env.BOOTSTRAP_ADMIN_PASSWORD;
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: 'Bootstrap password is not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
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 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,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'cache-control': 'no-store, max-age=0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const id = parseInt(params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid image ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT filename, data, category FROM images WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Image not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const image = rows[0];
|
||||
const buffer = Buffer.from(image.data);
|
||||
const contentType = getContentType(image.category, image.filename);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to fetch image' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getContentType(category: string, filename: string): string {
|
||||
if (category === 'photo') return 'image/jpeg';
|
||||
if (category === 'slide') return 'image/webp';
|
||||
if (filename.endsWith('.png')) return 'image/png';
|
||||
if (filename.endsWith('.webp')) return 'image/webp';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { order } = body;
|
||||
|
||||
if (!Array.isArray(order) || order.length === 0) {
|
||||
return NextResponse.json({ error: 'Missing or invalid order array' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Each entry: { id: number, sort_order: number }
|
||||
const updates = order.map((item: { id: number; sort_order: number }) =>
|
||||
db.query(
|
||||
'UPDATE menu_items SET sort_order = $1 WHERE id = $2',
|
||||
[item.sort_order, item.id]
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('POST /api/menu/reorder error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to reorder menu items' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -2,38 +2,53 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const { rows } = await db.query('SELECT * FROM places WHERE id = $1', [id]);
|
||||
if (!rows.length) return NextResponse.json({ error: 'Place not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/places/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, active, sort_order } = body;
|
||||
const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, sort_order, active } = body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE places SET name = $1, description = $2, photos = $3, featured_photo = $4, distance_km = $5, visit_time = $6, suggestion = $7, active = $8, sort_order = $9
|
||||
WHERE id = $10 RETURNING *`,
|
||||
[name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, active, sort_order || 0, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Place not found' }, { status: 404 });
|
||||
if (!name || !description) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE places SET name = $1, description = $2, photos = $3, featured_photo = $4, distance_km = $5, visit_time = $6, suggestion = $7, sort_order = $8, active = $9 WHERE id = $10 RETURNING *`,
|
||||
[name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, sort_order || 0, active !== false, id]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('PUT /api/places/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update place' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('PUT /api/places/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
await db.query('DELETE FROM places WHERE id = $1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('DELETE /api/places/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to delete place' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('DELETE /api/places/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { 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' },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// 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);
|
||||
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;
|
||||
}
|
||||
|
||||
return NextResponse.json({ totalLikes, likedByUser });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to get likes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - toggle like (add if not liked, remove if already liked)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const roomId = parseInt(params.id, 10);
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
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 {
|
||||
// Like - add the like
|
||||
await db.query(
|
||||
'INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)',
|
||||
[roomId, session.id]
|
||||
);
|
||||
liked = true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
@@ -2,38 +2,77 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
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 });
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
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, active, sort_order, featured_photo } = body;
|
||||
const { name, description, price, sort_order, featured_photo, active, amenity_ids } = body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, featured_photo = $7, updated_at = NOW()
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[name, description, price, photos || [], active, sort_order || 0, featured_photo || null, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
if (!name || !description || price === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
// Photos are stored in images table, not rooms.photos
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, sort_order = $4, featured_photo = $5, active = $6 WHERE id = $7 RETURNING *`,
|
||||
[name, description, price, sort_order || 0, featured_photo || null, active !== false, id]
|
||||
);
|
||||
|
||||
// Update amenities
|
||||
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) {
|
||||
console.error('PUT /api/rooms/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update room' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('PUT /api/rooms/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
await db.query('DELETE FROM rooms WHERE id = $1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('DELETE /api/rooms/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to delete room' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('DELETE /api/rooms/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
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] });
|
||||
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] });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { rows } = await db.query(`
|
||||
|
||||
@@ -2,19 +2,30 @@ 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 {
|
||||
await requireRole('admin');
|
||||
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
|
||||
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);
|
||||
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: error.message || 'Failed to fetch social events' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +2,8 @@ import { 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');
|
||||
@@ -30,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) {
|
||||
|
||||
+495
-32
@@ -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({
|
||||
|
||||
+355
-13
@@ -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,12 +442,43 @@ 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>
|
||||
);
|
||||
}
|
||||
+117
-30
@@ -3,64 +3,151 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Slideshow from '@/components/Slideshow';
|
||||
import CalendarStrip from '@/components/CalendarStrip';
|
||||
import MeasuredText from '@/components/MeasuredText';
|
||||
import WeatherWidget from '@/components/WeatherWidget';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import Link from 'next/link';
|
||||
import { t, useLanguage } from '@/lib/i18n';
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
|
||||
export default function HomePage() {
|
||||
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
|
||||
const homeCopy = {
|
||||
en: {
|
||||
tagline: 'Welcome to the Heart of Imbabura',
|
||||
cta_rooms: 'Book a Room',
|
||||
cta_menu: 'View Our Menu',
|
||||
cta_contact: 'Contact Us',
|
||||
},
|
||||
es: {
|
||||
tagline: 'Bienvenidos al Corazón de Imbabura',
|
||||
cta_rooms: 'Reservar Habitación',
|
||||
cta_menu: 'Ver Menú',
|
||||
cta_contact: 'Contáctenos',
|
||||
},
|
||||
} as const;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/site-assets?key=tagline')
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.data) setTagline(j.data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
export default function HomePage() {
|
||||
const language = useLanguage();
|
||||
const copy = homeCopy[language];
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
|
||||
<LanguageSwitcher />
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
La Huasca
|
||||
</h1>
|
||||
<p style={{ fontSize: '18px', lineHeight: 1.55, color: 'rgba(245,240,232,0.78)', maxWidth: '52ch', margin: '0 0 2rem' }}>
|
||||
Hotel · Restaurant · Andean Highlands
|
||||
{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 />
|
||||
|
||||
+733
-69
@@ -1,6 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { formatDisplayDate } from '@/lib/date';
|
||||
import RoomCalendar from '@/components/RoomCalendar';
|
||||
import OptimizedImage from '@/components/OptimizedImage';
|
||||
|
||||
interface Room {
|
||||
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,9 +33,16 @@ interface User {
|
||||
role: 'admin' | 'user';
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email?: string;
|
||||
comments_disabled: boolean;
|
||||
}
|
||||
|
||||
interface RoomLike {
|
||||
room_id: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
@@ -43,10 +54,22 @@ export default function RoomsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [commentsByRoom, setCommentsByRoom] = useState<Record<number, RoomComment[]>>({});
|
||||
const [likesByRoom, setLikesByRoom] = useState<Record<number, number>>({});
|
||||
const [userLikesByRoom, setUserLikesByRoom] = useState<Record<number, boolean>>({});
|
||||
const [expandedRoom, setExpandedRoom] = useState<number | null>(null);
|
||||
const [activePhoto, setActivePhoto] = useState<Record<number, string>>({});
|
||||
const [activePhotoByRoom, setActivePhotoByRoom] = useState<Record<number, number>>({});
|
||||
const [draft, setDraft] = useState('');
|
||||
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')
|
||||
@@ -54,33 +77,65 @@ export default function RoomsPage() {
|
||||
if (!res.ok) throw new Error('Failed to load rooms');
|
||||
const json = await res.json();
|
||||
setRooms(json.data || []);
|
||||
json.data?.forEach((r: Room) => loadComments(r.id));
|
||||
json.data?.forEach((r: Room) => {
|
||||
loadComments(r.id);
|
||||
loadLikes(r.id);
|
||||
});
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then((j) => setUser(j.user || null))
|
||||
.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(() => {});
|
||||
}, []);
|
||||
|
||||
const loadComments = async (roomId: number) => {
|
||||
const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' });
|
||||
const json = await res.json().catch(() => ({ data: [] }));
|
||||
setCommentsByRoom((prev) => ({ ...prev, [roomId]: json.data || [] }));
|
||||
try {
|
||||
setLoadingComments((prev) => ({ ...prev, [roomId]: true }));
|
||||
const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' });
|
||||
const json = await res.json().catch(() => ({ data: [] }));
|
||||
setCommentsByRoom((prev) => ({ ...prev, [roomId]: json.data || [] }));
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingComments((prev) => ({ ...prev, [roomId]: false })); }
|
||||
};
|
||||
|
||||
const loadLikes = async (roomId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${roomId}/likes`, { credentials: 'same-origin' });
|
||||
const json = await res.json().catch(() => ({ count: 0 }));
|
||||
setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || 0 }));
|
||||
setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !!json.user_liked }));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const postComment = async (roomId: number) => {
|
||||
if (!draft.trim()) return;
|
||||
if (!draft.trim() || sending) return;
|
||||
setSending(true);
|
||||
const photo = activePhoto[roomId] || null;
|
||||
const room = rooms.find(r => r.id === roomId);
|
||||
if (!room) return;
|
||||
const photo = getPhoto(room, activePhotoByRoom[roomId] || 0);
|
||||
await fetch(`/api/rooms/${roomId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ text: draft.trim(), photo_url: photo }),
|
||||
});
|
||||
}).catch(() => {});
|
||||
setDraft('');
|
||||
await loadComments(roomId);
|
||||
setSending(false);
|
||||
@@ -91,26 +146,217 @@ export default function RoomsPage() {
|
||||
await loadComments(roomId);
|
||||
};
|
||||
|
||||
const toggleLike = async (roomId: number) => {
|
||||
if (!user) {
|
||||
alert('Log in to like rooms');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${roomId}/likes`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || (likesByRoom[roomId] || 0) + (userLikesByRoom[roomId] ? -1 : 1) }));
|
||||
setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !userLikesByRoom[roomId] }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const getPhoto = (room: Room, index: number): string | null => {
|
||||
if (!room.photos || room.photos.length === 0) return 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>}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '2rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '2rem' }}>
|
||||
{displayedRooms.map((room) => {
|
||||
const photos = room.photos?.length ? room.photos : [];
|
||||
const featured = room.featured_photo || photos[0];
|
||||
const currentPhoto = activePhoto[room.id] || featured || photos[0];
|
||||
const photos = room.photos || [];
|
||||
const activeIdx = getActiveIndex(room);
|
||||
const activeSrc = getPhoto(room, activeIdx);
|
||||
const comments = commentsByRoom[room.id] || [];
|
||||
const isExpanded = expandedRoom === room.id;
|
||||
const totalLikes = likesByRoom[room.id] || 0;
|
||||
const hasLiked = userLikesByRoom[room.id] || false;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -127,77 +373,243 @@ export default function RoomsPage() {
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '260px',
|
||||
background: '#ddd',
|
||||
backgroundImage: currentPhoto ? `url(${currentPhoto})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
cursor: photos.length > 1 ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (photos.length > 1) {
|
||||
const idx = photos.indexOf(currentPhoto);
|
||||
setActivePhoto((prev) => ({ ...prev, [room.id]: photos[(idx + 1) % photos.length] }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div style={{ position: 'absolute', bottom: 10, right: 10, display: 'flex', gap: '0.4rem' }}>
|
||||
{photos.map((p, i) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
|
||||
style={{
|
||||
width: 10, height: 10, borderRadius: '50%', border: 'none',
|
||||
background: p === currentPhoto ? gold : 'rgba(255,255,255,0.7)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
aria-label={`Photo ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
{/* ─── Main Image ─── */}
|
||||
<div style={{ position: 'relative', width: '100%', height: '300px' }}>
|
||||
{activeSrc ? (
|
||||
activeSrc.startsWith('data:') ? (
|
||||
<img
|
||||
src={activeSrc}
|
||||
alt={room.name}
|
||||
style={{ width: '100%', height: '300px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<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 with count */}
|
||||
<button
|
||||
onClick={() => toggleLike(room.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
display: 'flex',
|
||||
alignItems: '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.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
aria-label="Like this room"
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{hasLiked ? '❤️' : '♡'}</span>
|
||||
<span>{totalLikes}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ─── Thumbnail Strip ─── */}
|
||||
{photos.length > 1 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
padding: '10px 14px 14px',
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{photos.map((p, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
return (
|
||||
<button
|
||||
key={`${room.id}-${i}`}
|
||||
onClick={() => setActivePhotoByRoom((prev) => ({ ...prev, [room.id]: i }))}
|
||||
style={{
|
||||
width: 60,
|
||||
height: 45,
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
border: isActive ? `2px solid ${gold}` : '2px solid transparent',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.2s, transform 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.transform = 'scale(1.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{p ? (
|
||||
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' }} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 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>
|
||||
|
||||
{photos.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', overflowX: 'auto' }}>
|
||||
{photos.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
|
||||
style={{
|
||||
width: 64, height: 48, flexShrink: 0, borderRadius: 8, border: `2px solid ${p === currentPhoto ? gold : 'transparent'}`,
|
||||
backgroundImage: `url(${p})`, backgroundSize: 'cover', backgroundPosition: 'center', cursor: 'pointer',
|
||||
}}
|
||||
aria-label="View photo"
|
||||
/>
|
||||
))}
|
||||
</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)}
|
||||
style={{
|
||||
marginTop: '1rem', padding: '0.6rem 1rem', borderRadius: '999px', border: 'none',
|
||||
background: navy, color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: '14px',
|
||||
marginTop: '1rem',
|
||||
padding: '0.6rem 1rem',
|
||||
borderRadius: '999px',
|
||||
border: `2px solid ${cream}`,
|
||||
background: cream,
|
||||
color: navy,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.borderColor = gold; e.currentTarget.style.color = navy; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = cream; e.currentTarget.style.borderColor = cream; e.currentTarget.style.color = navy; }}
|
||||
>
|
||||
{isExpanded ? 'Hide comments' : `Comments (${comments.length})`}
|
||||
</button>
|
||||
|
||||
{/* Comments section */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{loadingComments[room.id] && <p style={{ color: '#777', fontSize: '13px' }}>Loading comments...</p>}
|
||||
|
||||
{user ? (
|
||||
user.comments_disabled ? (
|
||||
<p style={{ color: '#c23b22', fontSize: '14px' }}>Comments are disabled for your account.</p>
|
||||
@@ -208,14 +620,30 @@ export default function RoomsPage() {
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Share your thoughts about this room..."
|
||||
rows={3}
|
||||
style={{ padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontFamily: 'inherit', fontSize: '14px' }}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>Posting as {user.first_name || user.username}</span>
|
||||
<button
|
||||
onClick={() => postComment(room.id)}
|
||||
disabled={sending || !draft.trim()}
|
||||
style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: 'none', background: gold, color: navy, fontWeight: 700, cursor: 'pointer' }}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: gold,
|
||||
color: navy,
|
||||
fontWeight: 700,
|
||||
cursor: draft.trim() ? 'pointer' : 'default',
|
||||
opacity: draft.trim() ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{sending ? 'Sending...' : 'Post'}
|
||||
</button>
|
||||
@@ -236,7 +664,9 @@ export default function RoomsPage() {
|
||||
<strong style={{ color: navy, fontSize: '14px' }}>
|
||||
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
|
||||
</strong>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>{new Date(c.created_at).toLocaleDateString()}</span>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>
|
||||
{formatDisplayDate(c.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
|
||||
{user?.role === 'admin' && (
|
||||
@@ -258,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>
|
||||
@@ -242,4 +243,4 @@ export default function SocialEventsPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
+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>
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
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 60 30" fill="none" aria-hidden="true">
|
||||
{/* Blue background */}
|
||||
<rect width="60" height="30" fill="#012169"/>
|
||||
{/* White diagonal saltire (St Andrew) */}
|
||||
<path d="M0 0L60 30M60 0L0 30" stroke="white" strokeWidth="6"/>
|
||||
{/* Red diagonal saltire (St Patrick) - offset */}
|
||||
<path d="M0 0L30 15M60 0L30 15" stroke="#C8102E" strokeWidth="2"/>
|
||||
<path d="M60 30L30 15M0 30L30 15" stroke="#C8102E" strokeWidth="2"/>
|
||||
{/* White cross (St George + St Andrew) */}
|
||||
<rect x="25" y="0" width="10" height="30" fill="white"/>
|
||||
<rect x="0" y="10" width="60" height="10" fill="white"/>
|
||||
{/* Red cross (St George) */}
|
||||
<rect x="27" y="0" width="6" height="30" fill="#C8102E"/>
|
||||
<rect x="0" y="12" width="60" height="6" fill="#C8102E"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" aria-hidden="true">
|
||||
<rect width="24" height="8" fill="#FFD100" />
|
||||
<rect y="8" width="24" height="4" fill="#073685" />
|
||||
<rect y="12" width="24" height="4" fill="#EF3340" />
|
||||
<circle cx="12" cy="8.2" r="2.2" fill="#FFFFFF" opacity="0.95" />
|
||||
<circle cx="12" cy="8.2" r="1.1" fill="#EF3340" opacity="0.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const currentLang = useLanguage();
|
||||
const nextLanguage = currentLang === 'en' ? 'es' : 'en';
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setLanguage(nextLanguage);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '0.5rem',
|
||||
right: '7rem',
|
||||
zIndex: 1000,
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
aria-label={`Switch to ${currentLang === 'en' ? 'Spanish' : 'English'}`}
|
||||
>
|
||||
<Flag language={nextLanguage} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user