feat: harden bootstrap and add tests
This commit is contained in:
@@ -1,5 +0,0 @@
|
|||||||
# Do not hardcode secret values here in production.
|
|
||||||
# In Kubernetes, DATABASE_URL and JWT_SECRET are injected from secrets.
|
|
||||||
# For local development, use an untracked .env.local.
|
|
||||||
|
|
||||||
NEXT_PUBLIC_SITE_NAME=Hotel La Huasca
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
tmp/
|
tmp/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
# Deploy Postgres for la-huasca in its own namespace
|
# Deploy Postgres for la-huasca in its own namespace
|
||||||
|
|
||||||
NAMESPACE="lahuasca"
|
NAMESPACE="${NAMESPACE:-lahuasca}"
|
||||||
SECRET_NAME="la-huasca-db-secrets"
|
SECRET_NAME="la-huasca-db-secrets"
|
||||||
PVC_NAME="postgres-data"
|
PVC_NAME="postgres-data"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ set -euo pipefail
|
|||||||
# - docker or buildah/push capability (we build locally and push)
|
# - docker or buildah/push capability (we build locally and push)
|
||||||
# - registry secret exists or REGISTRY_USER/REGISTRY_PASS are exported
|
# - registry secret exists or REGISTRY_USER/REGISTRY_PASS are exported
|
||||||
|
|
||||||
NAMESPACE="lahuasca"
|
NAMESPACE="${NAMESPACE:-lahuasca}"
|
||||||
IMAGE="gitea.ajavasoft.com/muken/la-huasca-ts"
|
IMAGE="gitea.ajavasoft.com/muken/la-huasca-ts"
|
||||||
TAG="${TAG:-$(git rev-parse --short HEAD)}"
|
TAG="${TAG:-$(git rev-parse --short HEAD)}"
|
||||||
HOSTS=("lahuasca.com" "www.lahuasca.com")
|
HOSTS=("lahuasca.com" "www.lahuasca.com")
|
||||||
|
|||||||
Executable
+221
@@ -0,0 +1,221 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Database initialization script for La Huasca (local version)
|
||||||
|
# This script creates the necessary tables and seeds initial data
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "Initializing La Huasca database (local version)..."
|
||||||
|
|
||||||
|
# Execute the schema directly
|
||||||
|
echo "Executing database schema..."
|
||||||
|
sudo -u postgres psql -d lahuasca << 'EOF'
|
||||||
|
-- Create all tables
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 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', '$2a$10$8K1p/a0dhrxiowP.dnkgNORTWgdEDHn5L2/xjpEWuC.QQv4rKO9jO', 'admin')
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
-- Default config values
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('wifi_password', 'LaHuascaGuest2026'),
|
||||||
|
('tagline', 'Hotel · Restaurant · Andean Highlands')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Sample offers
|
||||||
|
INSERT INTO offers (title, description, active, sort_order) VALUES
|
||||||
|
('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.', true, 1),
|
||||||
|
('Sunset Dinner', 'Three-course menu on the terrace every Friday.', true, 2)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Verify that the tables were created
|
||||||
|
echo "Verifying database tables..."
|
||||||
|
sudo -u postgres psql -d lahuasca -c "\dt"
|
||||||
|
|
||||||
|
echo "Database initialization completed successfully!"
|
||||||
+6
-6
@@ -8,7 +8,7 @@ set -e # Exit on any error
|
|||||||
echo "Initializing La Huasca database..."
|
echo "Initializing La Huasca database..."
|
||||||
|
|
||||||
# Get the postgres pod name
|
# Get the postgres pod name
|
||||||
POSTGRES_POD=$(kubectl get pods -n lahuasca | grep postgres | awk '{print $1}')
|
POSTGRES_POD=$(kubectl get pods -n "${NAMESPACE:-lahuasca}" | grep postgres | awk '{print $1}')
|
||||||
|
|
||||||
if [ -z "$POSTGRES_POD" ]; then
|
if [ -z "$POSTGRES_POD" ]; then
|
||||||
echo "Error: Could not find postgres pod"
|
echo "Error: Could not find postgres pod"
|
||||||
@@ -199,12 +199,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
|||||||
-- Insert initial data
|
-- Insert initial data
|
||||||
-- Default admin user (password: admin)
|
-- Default admin user (password: admin)
|
||||||
INSERT INTO users (username, password_hash, role) VALUES
|
INSERT INTO users (username, password_hash, role) VALUES
|
||||||
('admin', '$2a$10$8K1p/a0dhrxiowP.dnkgNORTWgdEDHn5L2/xjpEWuC.QQv4rKO9jO', 'admin')
|
('admin', '${BOOTSTRAP_ADMIN_PASSWORD_HASH}', 'admin')
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
-- Default config values
|
-- Default config values
|
||||||
INSERT INTO config (key, value) VALUES
|
INSERT INTO config (key, value) VALUES
|
||||||
('wifi_password', 'LaHuascaGuest2026'),
|
('wifi_password', '${WIFI_PASSWORD}'),
|
||||||
('tagline', 'Hotel · Restaurant · Andean Highlands')
|
('tagline', 'Hotel · Restaurant · Andean Highlands')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
@@ -225,14 +225,14 @@ EOF
|
|||||||
|
|
||||||
# Copy the schema file to the postgres pod
|
# Copy the schema file to the postgres pod
|
||||||
echo "Copying schema to postgres pod..."
|
echo "Copying schema to postgres pod..."
|
||||||
kubectl cp /tmp/schema.sql lahuasca/$POSTGRES_POD:/tmp/schema.sql
|
kubectl cp /tmp/schema.sql "${NAMESPACE:-lahuasca}/$POSTGRES_POD:/tmp/schema.sql"
|
||||||
|
|
||||||
# Execute the schema
|
# Execute the schema
|
||||||
echo "Executing database schema..."
|
echo "Executing database schema..."
|
||||||
kubectl exec -n lahuasca $POSTGRES_POD -- psql -U lahuasca -d lahuasca -f /tmp/schema.sql
|
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
|
# Verify that the tables were created
|
||||||
echo "Verifying database tables..."
|
echo "Verifying database tables..."
|
||||||
kubectl exec -n lahuasca deploy/postgres -- psql -U lahuasca -d lahuasca -c "\dt"
|
kubectl exec -n "${NAMESPACE:-lahuasca}" deploy/postgres -- psql -U "${POSTGRES_USER:-lahuasca}" -d "${POSTGRES_DB:-lahuasca}" -c "\dt"
|
||||||
|
|
||||||
echo "Database initialization completed successfully!"
|
echo "Database initialization completed successfully!"
|
||||||
+36
-2
@@ -29,6 +29,42 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: la-huasca-db-secrets
|
name: la-huasca-db-secrets
|
||||||
key: DATABASE_URL
|
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:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 200m
|
cpu: 200m
|
||||||
@@ -56,8 +92,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: la-huasca-ts
|
name: la-huasca-ts
|
||||||
namespace: lahuasca
|
namespace: lahuasca
|
||||||
annotations:
|
|
||||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
tls:
|
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,10 @@
|
|||||||
|
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"
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"kind": "Deployment",
|
"kind": "Deployment",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "la-huasca-ts",
|
"name": "la-huasca-ts",
|
||||||
"namespace": "la-huasca"
|
"namespace": "lahuasca"
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"replicas": 1,
|
"replicas": 1,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"kind": "Ingress",
|
"kind": "Ingress",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "la-huasca-ts",
|
"name": "la-huasca-ts",
|
||||||
"namespace": "la-huasca",
|
"namespace": "lahuasca",
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"traefik.ingress.kubernetes.io/router.entrypoints": "web,websecure"
|
"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
|
||||||
+64
-210
@@ -4,230 +4,37 @@ metadata:
|
|||||||
name: init-database
|
name: init-database
|
||||||
namespace: lahuasca
|
namespace: lahuasca
|
||||||
spec:
|
spec:
|
||||||
|
backoffLimit: 4
|
||||||
template:
|
template:
|
||||||
spec:
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
containers:
|
containers:
|
||||||
- name: init-db
|
- name: init-db
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -ec
|
||||||
- |
|
- |
|
||||||
apk add --no-cache curl
|
until pg_isready -h "${DB_HOST}" -p "${DB_PORT}" -U "$POSTGRES_USER"; do
|
||||||
# Wait for postgres to be ready
|
|
||||||
until pg_isready -h postgres.lahuasca.svc.cluster.local -U lahuasca; do
|
|
||||||
echo "Waiting for postgres..."
|
echo "Waiting for postgres..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
# Create tables and seed data
|
psql \
|
||||||
psql -h postgres.lahuasca.svc.cluster.local -U lahuasca -d lahuasca << 'EOF'
|
-h "${DB_HOST}" \
|
||||||
-- Create all tables
|
-p "${DB_PORT}" \
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
-U "$POSTGRES_USER" \
|
||||||
id SERIAL PRIMARY KEY,
|
-d "$POSTGRES_DB" \
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
-f /bootstrap/schema.sql
|
||||||
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 (
|
sh /bootstrap/seed.sh
|
||||||
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()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 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', '$2a$10$8K1p/a0dhrxiowP.dnkgNORTWgdEDHn5L2/xjpEWuC.QQv4rKO9jO', 'admin')
|
|
||||||
ON CONFLICT (username) DO NOTHING;
|
|
||||||
|
|
||||||
-- Default config values
|
|
||||||
INSERT INTO config (key, value) VALUES
|
|
||||||
('wifi_password', 'LaHuascaGuest2026'),
|
|
||||||
('tagline', 'Hotel · Restaurant · Andean Highlands')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
|
|
||||||
-- Sample offers
|
|
||||||
INSERT INTO offers (title, description, active, sort_order) VALUES
|
|
||||||
('Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.', true, 1),
|
|
||||||
('Sunset Dinner', 'Three-course menu on the terrace every Friday.', true, 2)
|
|
||||||
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
|
|
||||||
|
|
||||||
echo "Database initialization completed!"
|
echo "Database initialization completed!"
|
||||||
env:
|
env:
|
||||||
|
- name: DB_HOST
|
||||||
|
value: postgres
|
||||||
|
- name: DB_PORT
|
||||||
|
value: "5432"
|
||||||
- name: PGPASSWORD
|
- name: PGPASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -238,5 +45,52 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: la-huasca-db-secrets
|
name: la-huasca-db-secrets
|
||||||
key: POSTGRES_USER
|
key: POSTGRES_USER
|
||||||
restartPolicy: Never
|
- name: POSTGRES_DB
|
||||||
backoffLimit: 4
|
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
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: la-huasca-registry
|
name: la-huasca-registry
|
||||||
namespace: ${NAMESPACE}
|
namespace: lahuasca
|
||||||
type: kubernetes.io/dockerconfigjson
|
type: kubernetes.io/dockerconfigjson
|
||||||
stringData:
|
stringData:
|
||||||
.dockerconfigjson: |
|
.dockerconfigjson: |
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"kind": "Service",
|
"kind": "Service",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "la-huasca-ts",
|
"name": "la-huasca-ts",
|
||||||
"namespace": "la-huasca"
|
"namespace": "lahuasca"
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"ports": [
|
"ports": [
|
||||||
|
|||||||
Generated
+2069
-1
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -5,8 +5,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -H 0.0.0.0 -p 3000",
|
"start": "node .next/standalone/server.js",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenglou/pretext": "^0.0.8",
|
"@chenglou/pretext": "^0.0.8",
|
||||||
@@ -18,11 +19,16 @@
|
|||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20.17.30",
|
"@types/node": "^20.17.30",
|
||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
"@types/react": "^18.3.20",
|
"@types/react": "^18.3.20",
|
||||||
"@types/react-dom": "^18.3.6",
|
"@types/react-dom": "^18.3.6",
|
||||||
"typescript": "^5.4.5"
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||||
|
|
||||||
// GET /api/admin/menu — returns ALL menu items (including inactive)
|
// GET /api/admin/menu — returns ALL menu items (including inactive)
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createUser } from '@/lib/auth';
|
import { createUser } from '@/lib/auth';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
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 } });
|
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: 500 });
|
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const { rows } = await db.query(`
|
const { rows } = await db.query(`
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
|
|||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'; // Prevents static generation
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await requireRole('user');
|
const session = await requireRole('user');
|
||||||
|
|||||||
+39
-7
@@ -3,26 +3,50 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Slideshow from '@/components/Slideshow';
|
import Slideshow from '@/components/Slideshow';
|
||||||
import CalendarStrip from '@/components/CalendarStrip';
|
import CalendarStrip from '@/components/CalendarStrip';
|
||||||
import MeasuredText from '@/components/MeasuredText';
|
|
||||||
import WeatherWidget from '@/components/WeatherWidget';
|
import WeatherWidget from '@/components/WeatherWidget';
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
import { t, getLanguage } from '@/lib/i18n';
|
import { t, useLanguage } from '@/lib/i18n';
|
||||||
|
|
||||||
const gold = '#E8A849';
|
const gold = '#E8A849';
|
||||||
const navy = '#010D1E';
|
const navy = '#010D1E';
|
||||||
const cream = '#f5f0e8';
|
const cream = '#f5f0e8';
|
||||||
|
|
||||||
|
const homeCopy = {
|
||||||
|
en: {
|
||||||
|
title: 'La Huasca',
|
||||||
|
subtitle: 'A mountain retreat with warm hospitality and local flavor.',
|
||||||
|
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
title: 'La Huasca',
|
||||||
|
subtitle: 'Un refugio de montaña con hospitalidad cálida y sabor local.',
|
||||||
|
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const fallbackTaglineKey = 'common.home_tagline';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
|
const language = useLanguage();
|
||||||
|
const [tagline, setTagline] = useState(t(fallbackTaglineKey, language));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const fallbackTagline = t(fallbackTaglineKey, language);
|
||||||
|
setTagline(fallbackTagline);
|
||||||
fetch('/api/site-assets?key=tagline')
|
fetch('/api/site-assets?key=tagline')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((j) => {
|
.then((j) => {
|
||||||
if (j.data) setTagline(j.data);
|
if (!cancelled && j.data) setTagline(j.data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const copy = homeCopy[language];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
|
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
|
||||||
@@ -64,10 +88,18 @@ export default function HomePage() {
|
|||||||
color: cream,
|
color: cream,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
La Huasca
|
{copy.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: '18px', lineHeight: 1.55, color: 'rgba(245,240,232,0.78)', maxWidth: '52ch', margin: '0 0 2rem' }}>
|
<p style={{ fontSize: '18px', lineHeight: 1.55, color: 'rgba(245,240,232,0.78)', maxWidth: '52ch', margin: '0 0 2rem' }}>
|
||||||
{t('common.hotel', getLanguage())} · {t('common.restaurant', getLanguage())} · {t('common.andean_highlands', getLanguage())}
|
{copy.highlights.map((key, index) => (
|
||||||
|
<span key={key}>
|
||||||
|
{t(key, language)}
|
||||||
|
{index < copy.highlights.length - 1 ? ' · ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '16px', lineHeight: 1.65, color: 'rgba(245,240,232,0.7)', maxWidth: '58ch', margin: 0 }}>
|
||||||
|
{copy.subtitle}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { Language, setLanguage, useLanguage } from '@/lib/i18n';
|
||||||
import { getLanguage, setLanguage } from '@/lib/i18n';
|
|
||||||
|
function Flag({ language }: { language: Language }) {
|
||||||
|
if (language === 'en') {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="16" viewBox="0 0 24 16" fill="none">
|
||||||
|
<rect width="24" height="16" fill="white" />
|
||||||
|
<rect x="0" y="6" width="24" height="4" fill="#CE1124" />
|
||||||
|
<rect x="10" y="0" width="4" height="16" fill="#CE1124" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width="24" height="16" viewBox="0 0 24 16" fill="none">
|
||||||
|
<rect width="24" height="16" fill="white" />
|
||||||
|
<rect y="0" width="24" height="5.33" fill="#EF3340" />
|
||||||
|
<rect y="10.67" width="24" height="5.33" fill="#EF3340" />
|
||||||
|
<rect y="5.33" width="24" height="5.33" fill="#073685" />
|
||||||
|
<path d="M12 8L10.39 9.18L11.02 7.24L9.39 6.82L11.2 6.82L12 5L12.81 6.82L14.62 6.82L12.98 7.24L13.61 9.18L12 8Z" fill="#FFD100" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LanguageSwitcher() {
|
export default function LanguageSwitcher() {
|
||||||
const [currentLang, setCurrentLang] = useState<'en' | 'es'>('en');
|
const currentLang = useLanguage();
|
||||||
|
const nextLanguage = currentLang === 'en' ? 'es' : 'en';
|
||||||
useEffect(() => {
|
|
||||||
setCurrentLang(getLanguage());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
const toggleLanguage = () => {
|
||||||
const newLang = currentLang === 'en' ? 'es' : 'en';
|
setLanguage(nextLanguage);
|
||||||
setLanguage(newLang);
|
|
||||||
setCurrentLang(newLang);
|
|
||||||
// Reload the page to apply the language change
|
|
||||||
window.location.reload();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,13 +43,15 @@ export default function LanguageSwitcher() {
|
|||||||
background: 'rgba(255, 255, 255, 0.9)',
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '0.9rem',
|
display: 'flex',
|
||||||
fontWeight: 'bold',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
|
aria-label={`Switch to ${currentLang === 'en' ? 'Spanish' : 'English'}`}
|
||||||
>
|
>
|
||||||
{currentLang === 'en' ? 'ES' : 'EN'}
|
<Flag language={nextLanguage} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export type AdminNavItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminSidebarProps = {
|
||||||
|
items: AdminNavItem[];
|
||||||
|
activeSection: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
gold: '#D4A853',
|
||||||
|
goldLight: 'rgba(212,168,83,0.12)',
|
||||||
|
navy: '#010D1E',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminSidebar({ items, activeSection, onSelect }: AdminSidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: 260,
|
||||||
|
background: C.navy,
|
||||||
|
height: '100vh',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'transform 300ms',
|
||||||
|
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '24px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '18px', fontWeight: 700, color: C.gold }}>🏨 Hotel La Huasca</h1>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'rgba(255,255,255,0.4)' }}>Admin Portal</p>
|
||||||
|
</div>
|
||||||
|
<nav style={{ padding: '16px 12px', flex: 1 }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '10px 14px',
|
||||||
|
marginBottom: '4px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
background: activeSection === item.id ? C.goldLight : 'transparent',
|
||||||
|
color: activeSection === item.id ? C.gold : 'rgba(255,255,255,0.6)',
|
||||||
|
transition: 'all 200ms',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '18px' }}>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '11px', color: 'rgba(255,255,255,0.3)' }}>
|
||||||
|
v1.0.0
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
gold: '#D4A853',
|
||||||
|
navy: '#010D1E',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
shadow: '0 1px 4px rgba(1,13,30,0.06)',
|
||||||
|
textLight: '#888888',
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({ icon, value, label }: { icon: string; value: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: C.card,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: '16px 20px',
|
||||||
|
boxShadow: C.shadow,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
minWidth: '160px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '28px' }}>{icon}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '22px', fontWeight: 700, color: C.navy }}>{value}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: C.textLight }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardProps = {
|
||||||
|
rooms: Array<unknown>;
|
||||||
|
slides: Array<unknown>;
|
||||||
|
events: Array<unknown>;
|
||||||
|
reviews: Array<{ approved?: boolean }>;
|
||||||
|
places: Array<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dashboard({ rooms, slides, events, reviews, places }: DashboardProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: '0 0 20px', fontSize: '22px', fontWeight: 700, color: C.navy }}>Dashboard</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '14px', flexWrap: 'wrap', marginBottom: '24px' }}>
|
||||||
|
<StatCard icon="🏨" value={rooms?.length || 0} label="Rooms" />
|
||||||
|
<StatCard icon="🎞️" value={slides?.length || 0} label="Slides" />
|
||||||
|
<StatCard icon="📅" value={events?.length || 0} label="Events" />
|
||||||
|
<StatCard icon="🗺️" value={places?.length || 0} label="Places" />
|
||||||
|
<StatCard icon="⭐" value={reviews?.filter((r) => r.approved)?.length || 0} label="Approved Reviews" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,154 +1,171 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { t } from '@/lib/i18n';
|
|
||||||
|
const C = {
|
||||||
|
gold: '#D4A853',
|
||||||
|
navy: '#010D1E',
|
||||||
|
bg: '#F7F4EF',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
border: 'rgba(1,13,30,0.08)',
|
||||||
|
text: '#444444',
|
||||||
|
textLight: '#888888',
|
||||||
|
danger: '#D32F2F',
|
||||||
|
};
|
||||||
|
|
||||||
|
function I({ label, value, onChange, type = 'text', style }: any) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${C.border}`,
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
background: '#FAFAFA',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecHead({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: C.navy }}>{title}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function WeatherSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
export default function WeatherSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [cities, setCities] = useState<Array<{name: string, lat: number, lon: number}>>([
|
const [cities, setCities] = useState<{ name: string; lat: string; lon: string }[]>([]);
|
||||||
{ name: 'La Huasca', lat: -31.525, lon: -65.125 }
|
const [newCity, setNewCity] = useState({ name: '', lat: '', lon: '' });
|
||||||
]);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Load weather config from API
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/site-assets?key=weather_config')
|
const loadConfig = async () => {
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.data) {
|
|
||||||
try {
|
try {
|
||||||
|
const res = await fetch('/api/site-assets?key=weather_config');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.data) {
|
||||||
const config = JSON.parse(data.data);
|
const config = JSON.parse(data.data);
|
||||||
setEnabled(config.enabled ?? true);
|
setEnabled(config.enabled ?? true);
|
||||||
setCities(config.cities ?? [{ name: 'La Huasca', lat: -31.525, lon: -65.125 }]);
|
setCities(config.cities ?? []);
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse weather config:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
} catch (err) {
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to load weather config:', err);
|
console.error('Failed to load weather config:', err);
|
||||||
});
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addCity = () => {
|
const saveConfig = async () => {
|
||||||
setCities([...cities, { name: '', lat: 0, lon: 0 }]);
|
try {
|
||||||
|
const config = { enabled, cities };
|
||||||
|
const res = await fetch('/api/site-assets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: 'weather_config', value: JSON.stringify(config) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
onToast('Weather settings saved', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to save');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onToast('Error saving weather settings', 'error');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCity = (index: number, field: string, value: string | number) => {
|
const addCity = () => {
|
||||||
const updated = [...cities];
|
if (newCity.name && newCity.lat && newCity.lon) {
|
||||||
if (field === 'name') {
|
setCities([...cities, newCity]);
|
||||||
updated[index].name = value as string;
|
setNewCity({ name: '', lat: '', lon: '' });
|
||||||
} else if (field === 'lat') {
|
|
||||||
updated[index].lat = value as number;
|
|
||||||
} else if (field === 'lon') {
|
|
||||||
updated[index].lon = value as number;
|
|
||||||
}
|
}
|
||||||
setCities(updated);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeCity = (index: number) => {
|
const removeCity = (index: number) => {
|
||||||
setCities(cities.filter((_, i) => i !== index));
|
setCities(cities.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveConfig = async () => {
|
if (loading) {
|
||||||
try {
|
return (
|
||||||
const config = { enabled, cities };
|
<div>
|
||||||
const response = await fetch('/api/site-assets', {
|
<SecHead title="🌤️ Weather Widget" />
|
||||||
method: 'POST',
|
<div style={{ textAlign: 'center', padding: '40px', color: C.textLight }}>Loading...</div>
|
||||||
headers: { 'Content-Type': 'application/json' },
|
</div>
|
||||||
body: JSON.stringify({
|
);
|
||||||
key: 'weather_config',
|
|
||||||
data: JSON.stringify(config)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
onToast(t('admin.weather.save_success', 'en'), 'success');
|
|
||||||
} else {
|
|
||||||
onToast(t('admin.weather.save_error', 'en'), 'error');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save weather config:', err);
|
|
||||||
onToast(t('admin.weather.save_error', 'en'), 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div>
|
||||||
<h2 style={{ marginBottom: '1.5rem' }}>{t('admin.weather.title', 'en')}</h2>
|
<SecHead title="🌤️ Weather Widget" />
|
||||||
<p style={{ marginBottom: '2rem', color: '#666' }}>{t('admin.weather.description', 'en')}</p>
|
<div style={{ background: C.card, borderRadius: 12, padding: '24px', marginBottom: '24px', border: `1px solid ${C.border}` }}>
|
||||||
|
<p style={{ color: C.text, marginBottom: '24px' }}>Configure the weather widget displayed on the homepage</p>
|
||||||
|
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={(e) => setEnabled(e.target.checked)}
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
style={{ width: '18px', height: '18px' }}
|
||||||
/>
|
/>
|
||||||
{t('admin.weather.enabled', 'en')}
|
<span style={{ fontWeight: 500, color: C.text }}>Enable Weather Widget</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<h3 style={{ margin: '0 0 16px', color: C.text, fontSize: '16px' }}>Cities</h3>
|
||||||
<h3 style={{ marginBottom: '1rem' }}>{t('admin.weather.cities', 'en')}</h3>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
{cities.map((city, index) => (
|
{cities.map((city, index) => (
|
||||||
<div key={index} style={{ display: 'flex', gap: '1rem', marginBottom: '1rem', alignItems: 'end' }}>
|
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px', padding: '8px', background: C.bg, borderRadius: '8px' }}>
|
||||||
<div>
|
<span style={{ flex: 1, color: C.text }}>{city.name} ({city.lat}, {city.lon})</span>
|
||||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
|
||||||
{t('admin.weather.city_name', 'en')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={city.name}
|
|
||||||
onChange={(e) => updateCity(index, 'name', e.target.value)}
|
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
|
||||||
{t('admin.weather.latitude', 'en')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.001"
|
|
||||||
value={city.lat}
|
|
||||||
onChange={(e) => updateCity(index, 'lat', parseFloat(e.target.value) || 0)}
|
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', width: '100px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
|
||||||
{t('admin.weather.longitude', 'en')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.001"
|
|
||||||
value={city.lon}
|
|
||||||
onChange={(e) => updateCity(index, 'lon', parseFloat(e.target.value) || 0)}
|
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', width: '100px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => removeCity(index)}
|
onClick={() => removeCity(index)}
|
||||||
style={{ padding: '0.5rem 1rem', background: '#ff4444', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
|
style={{ background: C.danger, color: 'white', border: 'none', borderRadius: '4px', padding: '4px 8px', cursor: 'pointer', fontSize: '12px' }}
|
||||||
>
|
>
|
||||||
×
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '16px', background: C.bg, borderRadius: '8px' }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', color: C.text, fontSize: '14px' }}>Add City</h4>
|
||||||
|
<I label="City Name" value={newCity.name} onChange={(v: string) => setNewCity({ ...newCity, name: v })} />
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<I label="Latitude" value={newCity.lat} onChange={(v: string) => setNewCity({ ...newCity, lat: v })} />
|
||||||
|
<I label="Longitude" value={newCity.lon} onChange={(v: string) => setNewCity({ ...newCity, lon: v })} />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={addCity}
|
onClick={addCity}
|
||||||
style={{ padding: '0.5rem 1rem', background: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
|
style={{ alignSelf: 'flex-start', background: C.gold, color: 'white', border: 'none', borderRadius: '6px', padding: '8px 16px', cursor: 'pointer', fontWeight: 500 }}
|
||||||
>
|
>
|
||||||
{t('admin.weather.add_city', 'en')}
|
Add City
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={saveConfig}
|
onClick={saveConfig}
|
||||||
style={{ padding: '0.75rem 1.5rem', background: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '1rem' }}
|
style={{ background: C.gold, color: 'white', border: 'none', borderRadius: '6px', padding: '12px 24px', cursor: 'pointer', fontWeight: 600, fontSize: '14px' }}
|
||||||
>
|
>
|
||||||
{t('admin.weather.save', 'en')}
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+5
-1
@@ -2,9 +2,12 @@ import { Pool } from 'pg';
|
|||||||
|
|
||||||
function createPool() {
|
function createPool() {
|
||||||
const connectionString = process.env.DATABASE_URL;
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
const isBuildPhase = typeof process.env.NEXT_PHASE === 'string' && process.env.NEXT_PHASE.includes('phase-production-build');
|
||||||
|
|
||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
if (typeof process.env.NEXT_PHASE === 'string' && process.env.NEXT_PHASE.includes('phase-production-build')) {
|
if (isBuildPhase) {
|
||||||
|
// Build-time fallback only: keeps static rendering and type generation from crashing.
|
||||||
|
// Runtime imports should still fail fast when DATABASE_URL is missing.
|
||||||
return {
|
return {
|
||||||
query: async () => {
|
query: async () => {
|
||||||
throw new Error('DATABASE_URL environment variable is not set');
|
throw new Error('DATABASE_URL environment variable is not set');
|
||||||
@@ -17,6 +20,7 @@ function createPool() {
|
|||||||
removeListener: () => {},
|
removeListener: () => {},
|
||||||
} as unknown as Pool;
|
} as unknown as Pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('DATABASE_URL environment variable is not set');
|
throw new Error('DATABASE_URL environment variable is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+64
-41
@@ -1,12 +1,17 @@
|
|||||||
// Simple translation utility for La Huasca website
|
// Translation utility for La Huasca.
|
||||||
|
// Keep it simple, but structured enough to scale without scattering key lookups.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
// Define languages
|
|
||||||
export type Language = 'en' | 'es';
|
export type Language = 'en' | 'es';
|
||||||
|
export const DEFAULT_LANGUAGE: Language = 'en';
|
||||||
|
export const supportedLanguages: Language[] = ['en', 'es'];
|
||||||
|
|
||||||
// Translation data
|
const LANGUAGE_STORAGE_KEY = 'language';
|
||||||
export const translations = {
|
const LANGUAGE_CHANGE_EVENT = 'languagechange';
|
||||||
|
|
||||||
|
export const translations: Record<Language, Record<string, string>> = {
|
||||||
en: {
|
en: {
|
||||||
// Common
|
|
||||||
'common.hotel': 'Hotel',
|
'common.hotel': 'Hotel',
|
||||||
'common.restaurant': 'Restaurant',
|
'common.restaurant': 'Restaurant',
|
||||||
'common.andean_highlands': 'Andean Highlands',
|
'common.andean_highlands': 'Andean Highlands',
|
||||||
@@ -18,8 +23,9 @@ export const translations = {
|
|||||||
'common.user_portal': 'User Portal',
|
'common.user_portal': 'User Portal',
|
||||||
'common.login': 'Login',
|
'common.login': 'Login',
|
||||||
'common.logout': 'Logout',
|
'common.logout': 'Logout',
|
||||||
|
'common.language': 'Language',
|
||||||
// Navigation
|
'common.home_tagline': 'Hotel · Restaurant · Andean Highlands',
|
||||||
|
'common.home_subtitle': 'A mountain retreat with warm hospitality and local flavor.',
|
||||||
'nav.brand': 'Brand',
|
'nav.brand': 'Brand',
|
||||||
'nav.gallery': 'Images',
|
'nav.gallery': 'Images',
|
||||||
'nav.slideshow': 'Slideshow',
|
'nav.slideshow': 'Slideshow',
|
||||||
@@ -31,8 +37,6 @@ export const translations = {
|
|||||||
'nav.reviews': 'Reviews',
|
'nav.reviews': 'Reviews',
|
||||||
'nav.users': 'Users',
|
'nav.users': 'Users',
|
||||||
'nav.weather': 'Weather',
|
'nav.weather': 'Weather',
|
||||||
|
|
||||||
// Weather widget
|
|
||||||
'weather.currently': 'Currently',
|
'weather.currently': 'Currently',
|
||||||
'weather.today': 'Today',
|
'weather.today': 'Today',
|
||||||
'weather.max': 'Max',
|
'weather.max': 'Max',
|
||||||
@@ -40,8 +44,6 @@ export const translations = {
|
|||||||
'weather.wind': 'Wind',
|
'weather.wind': 'Wind',
|
||||||
'weather.kmh': 'km/h',
|
'weather.kmh': 'km/h',
|
||||||
'weather_updated': 'Updated',
|
'weather_updated': 'Updated',
|
||||||
|
|
||||||
// Admin panel
|
|
||||||
'admin.brand': 'Brand',
|
'admin.brand': 'Brand',
|
||||||
'admin.gallery': 'Images',
|
'admin.gallery': 'Images',
|
||||||
'admin.slideshow': 'Slideshow',
|
'admin.slideshow': 'Slideshow',
|
||||||
@@ -53,8 +55,6 @@ export const translations = {
|
|||||||
'admin.reviews': 'Reviews',
|
'admin.reviews': 'Reviews',
|
||||||
'admin.users': 'Users',
|
'admin.users': 'Users',
|
||||||
'admin.weather': 'Weather',
|
'admin.weather': 'Weather',
|
||||||
|
|
||||||
// Weather admin
|
|
||||||
'admin.weather.title': 'Weather Widget',
|
'admin.weather.title': 'Weather Widget',
|
||||||
'admin.weather.description': 'Configure the weather widget displayed on the homepage',
|
'admin.weather.description': 'Configure the weather widget displayed on the homepage',
|
||||||
'admin.weather.enabled': 'Enable Weather Widget',
|
'admin.weather.enabled': 'Enable Weather Widget',
|
||||||
@@ -66,7 +66,6 @@ export const translations = {
|
|||||||
'admin.weather.save': 'Save Settings',
|
'admin.weather.save': 'Save Settings',
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
// Common
|
|
||||||
'common.hotel': 'Hotel',
|
'common.hotel': 'Hotel',
|
||||||
'common.restaurant': 'Restaurante',
|
'common.restaurant': 'Restaurante',
|
||||||
'common.andean_highlands': 'Tierras Altas Andinas',
|
'common.andean_highlands': 'Tierras Altas Andinas',
|
||||||
@@ -78,8 +77,9 @@ export const translations = {
|
|||||||
'common.user_portal': 'Portal de Usuario',
|
'common.user_portal': 'Portal de Usuario',
|
||||||
'common.login': 'Iniciar Sesión',
|
'common.login': 'Iniciar Sesión',
|
||||||
'common.logout': 'Cerrar Sesión',
|
'common.logout': 'Cerrar Sesión',
|
||||||
|
'common.language': 'Idioma',
|
||||||
// Navigation
|
'common.home_tagline': 'Hotel · Restaurante · Tierras Altas Andinas',
|
||||||
|
'common.home_subtitle': 'Un refugio de montaña con hospitalidad cálida y sabor local.',
|
||||||
'nav.brand': 'Marca',
|
'nav.brand': 'Marca',
|
||||||
'nav.gallery': 'Imágenes',
|
'nav.gallery': 'Imágenes',
|
||||||
'nav.slideshow': 'Presentación',
|
'nav.slideshow': 'Presentación',
|
||||||
@@ -91,8 +91,6 @@ export const translations = {
|
|||||||
'nav.reviews': 'Reseñas',
|
'nav.reviews': 'Reseñas',
|
||||||
'nav.users': 'Usuarios',
|
'nav.users': 'Usuarios',
|
||||||
'nav.weather': 'Clima',
|
'nav.weather': 'Clima',
|
||||||
|
|
||||||
// Weather widget
|
|
||||||
'weather.currently': 'Actualmente',
|
'weather.currently': 'Actualmente',
|
||||||
'weather.today': 'Hoy',
|
'weather.today': 'Hoy',
|
||||||
'weather.max': 'Máx',
|
'weather.max': 'Máx',
|
||||||
@@ -100,8 +98,6 @@ export const translations = {
|
|||||||
'weather.wind': 'Viento',
|
'weather.wind': 'Viento',
|
||||||
'weather.kmh': 'km/h',
|
'weather.kmh': 'km/h',
|
||||||
'weather_updated': 'Actualizado',
|
'weather_updated': 'Actualizado',
|
||||||
|
|
||||||
// Admin panel
|
|
||||||
'admin.brand': 'Marca',
|
'admin.brand': 'Marca',
|
||||||
'admin.gallery': 'Imágenes',
|
'admin.gallery': 'Imágenes',
|
||||||
'admin.slideshow': 'Presentación',
|
'admin.slideshow': 'Presentación',
|
||||||
@@ -113,8 +109,6 @@ export const translations = {
|
|||||||
'admin.reviews': 'Reseñas',
|
'admin.reviews': 'Reseñas',
|
||||||
'admin.users': 'Usuarios',
|
'admin.users': 'Usuarios',
|
||||||
'admin.weather': 'Clima',
|
'admin.weather': 'Clima',
|
||||||
|
|
||||||
// Weather admin
|
|
||||||
'admin.weather.title': 'Widget del Clima',
|
'admin.weather.title': 'Widget del Clima',
|
||||||
'admin.weather.description': 'Configurar el widget del clima mostrado en la página de inicio',
|
'admin.weather.description': 'Configurar el widget del clima mostrado en la página de inicio',
|
||||||
'admin.weather.enabled': 'Habilitar Widget del Clima',
|
'admin.weather.enabled': 'Habilitar Widget del Clima',
|
||||||
@@ -124,36 +118,65 @@ export const translations = {
|
|||||||
'admin.weather.latitude': 'Latitud',
|
'admin.weather.latitude': 'Latitud',
|
||||||
'admin.weather.longitude': 'Longitud',
|
'admin.weather.longitude': 'Longitud',
|
||||||
'admin.weather.save': 'Guardar Configuración',
|
'admin.weather.save': 'Guardar Configuración',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simple translation function
|
function normalizeLanguage(language?: string | null): Language {
|
||||||
export function t(key: string, language: Language = 'en'): string {
|
return language === 'es' ? 'es' : DEFAULT_LANGUAGE;
|
||||||
if (language === 'es' && key in translations.es) {
|
|
||||||
return translations.es[key as keyof typeof translations.es];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key in translations.en) {
|
export function t(key: string, language: Language = DEFAULT_LANGUAGE): string {
|
||||||
return translations.en[key as keyof typeof translations.en];
|
const current = translations[language]?.[key];
|
||||||
}
|
if (current) return current;
|
||||||
|
|
||||||
|
const fallback = translations[DEFAULT_LANGUAGE]?.[key];
|
||||||
|
if (fallback) return fallback;
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get language from localStorage or default to 'en'
|
|
||||||
export function getLanguage(): Language {
|
export function getLanguage(): Language {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
const savedLang = localStorage.getItem('language');
|
return DEFAULT_LANGUAGE;
|
||||||
if (savedLang && (savedLang === 'en' || savedLang === 'es')) {
|
}
|
||||||
return savedLang as Language;
|
|
||||||
}
|
try {
|
||||||
}
|
return normalizeLanguage(localStorage.getItem(LANGUAGE_STORAGE_KEY));
|
||||||
return 'en';
|
} catch {
|
||||||
|
return DEFAULT_LANGUAGE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set language in localStorage
|
|
||||||
export function setLanguage(lang: Language) {
|
export function setLanguage(lang: Language) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem('language', lang);
|
|
||||||
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
|
||||||
|
window.dispatchEvent(new Event(LANGUAGE_CHANGE_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscribeLanguage(callback: () => void) {
|
||||||
|
if (typeof window === 'undefined') return () => {};
|
||||||
|
|
||||||
|
const onLanguageChange = () => callback();
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key === LANGUAGE_STORAGE_KEY) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(LANGUAGE_CHANGE_EVENT, onLanguageChange);
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(LANGUAGE_CHANGE_EVENT, onLanguageChange);
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLanguage(): Language {
|
||||||
|
return useSyncExternalStore(subscribeLanguage, getLanguage, () => DEFAULT_LANGUAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFallbackLanguage(language: Language): Language {
|
||||||
|
return language === 'es' ? 'en' : 'es';
|
||||||
}
|
}
|
||||||
+19
-8
@@ -257,14 +257,25 @@ export async function seed() {
|
|||||||
throw new Error('Seeding is disabled in production');
|
throw new Error('Seeding is disabled in production');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminPassword = process.env.BOOTSTRAP_ADMIN_PASSWORD;
|
||||||
|
const userPassword = process.env.BOOTSTRAP_USER_PASSWORD;
|
||||||
|
const wifiPassword = process.env.BOOTSTRAP_WIFI_PASSWORD;
|
||||||
|
const tagline = process.env.BOOTSTRAP_TAGLINE || 'Hotel · Restaurant · Andean Highlands';
|
||||||
|
|
||||||
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
|
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
|
||||||
if (adminRows.length === 0) {
|
if (adminRows.length === 0 && adminPassword) {
|
||||||
const adminHash = bcrypt.hashSync('admin', 10);
|
const adminHash = bcrypt.hashSync(adminPassword, 10);
|
||||||
const userHash = bcrypt.hashSync('user', 10);
|
const seedRows: Array<[string, string, string]> = [['admin', adminHash, 'admin']];
|
||||||
|
|
||||||
|
if (userPassword) {
|
||||||
|
seedRows.push(['user', bcrypt.hashSync(userPassword, 10), 'user']);
|
||||||
|
}
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3), ($4, $5, $6)`,
|
`INSERT INTO users (username, password_hash, role) VALUES ${seedRows
|
||||||
['admin', adminHash, 'admin', 'user', userHash, 'user']
|
.map((_, index) => `($${index * 3 + 1}, $${index * 3 + 2}, $${index * 3 + 3})`)
|
||||||
|
.join(', ')}`,
|
||||||
|
seedRows.flat()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,13 +306,13 @@ export async function seed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rows: configWifiRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
|
const { rows: configWifiRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
|
||||||
if (configWifiRows.length === 0) {
|
if (configWifiRows.length === 0 && wifiPassword) {
|
||||||
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['wifi_password', 'LaHuascaGuest2026']);
|
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['wifi_password', wifiPassword]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows: configTaglineRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
|
const { rows: configTaglineRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
|
||||||
if (configTaglineRows.length === 0) {
|
if (configTaglineRows.length === 0) {
|
||||||
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', 'Hotel · Restaurant · Andean Highlands']);
|
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', tagline]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
// Simple middleware for Next.js Edge Runtime
|
||||||
|
// This is a basic implementation that doesn't verify tokens
|
||||||
|
// In a production environment, you would want to verify the token
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Protect admin routes
|
||||||
|
if (pathname.startsWith('/admin')) {
|
||||||
|
// Get session token from cookies
|
||||||
|
const token = request.cookies.get('session')?.value;
|
||||||
|
|
||||||
|
// If no token, redirect to login
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: In a real implementation, you would verify the token here
|
||||||
|
// For now, we're just checking that a token exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect authenticated users from login page to home
|
||||||
|
if (pathname === '/login') {
|
||||||
|
const token = request.cookies.get('session')?.value;
|
||||||
|
if (token) {
|
||||||
|
// In a real implementation, you would verify the token and check role
|
||||||
|
// For now, we'll just redirect to home
|
||||||
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure which paths the middleware should run on
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*', '/login'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { authenticateUser, createUser, hashPassword, verifyPassword } from '@/lib/auth';
|
||||||
|
|
||||||
|
const { query } = vi.hoisted(() => ({ query: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
db: {
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('auth helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashes passwords before storing a user', async () => {
|
||||||
|
query.mockResolvedValueOnce({ rows: [{ id: 1, username: 'alice', role: 'user' }] });
|
||||||
|
|
||||||
|
const user = await createUser('alice', 's3cret', 'user');
|
||||||
|
|
||||||
|
expect(user).toEqual({ id: 1, username: 'alice', role: 'user' });
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [, params] = query.mock.calls[0];
|
||||||
|
expect(params[0]).toBe('alice');
|
||||||
|
expect(params[1]).not.toBe('s3cret');
|
||||||
|
expect(await verifyPassword('s3cret', params[1] as string)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates a user with the stored password hash', async () => {
|
||||||
|
const passwordHash = await hashPassword('secret-password');
|
||||||
|
query.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({
|
||||||
|
id: 2,
|
||||||
|
username: 'bob',
|
||||||
|
role: 'admin',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid password', async () => {
|
||||||
|
const passwordHash = await hashPassword('secret-password');
|
||||||
|
query.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(authenticateUser('bob', 'wrong-password')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { POST } from '@/app/api/db/init/route';
|
||||||
|
|
||||||
|
const { initSchema, seed, resetDatabase, migrateFromLegacyPlaces } = vi.hoisted(() => ({
|
||||||
|
initSchema: vi.fn(),
|
||||||
|
seed: vi.fn(),
|
||||||
|
resetDatabase: vi.fn(),
|
||||||
|
migrateFromLegacyPlaces: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/schema', () => ({
|
||||||
|
initSchema,
|
||||||
|
seed,
|
||||||
|
resetDatabase,
|
||||||
|
migrateFromLegacyPlaces,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('/api/db/init', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initSchema.mockReset();
|
||||||
|
seed.mockReset();
|
||||||
|
resetDatabase.mockReset();
|
||||||
|
migrateFromLegacyPlaces.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes schema and seed data', async () => {
|
||||||
|
initSchema.mockResolvedValueOnce(undefined);
|
||||||
|
migrateFromLegacyPlaces.mockResolvedValueOnce(undefined);
|
||||||
|
seed.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const response = await POST(new Request('http://localhost/api/db/init'));
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ ok: true });
|
||||||
|
expect(initSchema).toHaveBeenCalledTimes(1);
|
||||||
|
expect(migrateFromLegacyPlaces).toHaveBeenCalledTimes(1);
|
||||||
|
expect(seed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resetDatabase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the reset path when requested', async () => {
|
||||||
|
resetDatabase.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const response = await POST(new Request('http://localhost/api/db/init?reset=true'));
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ ok: true, reset: true });
|
||||||
|
expect(resetDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
expect(initSchema).not.toHaveBeenCalled();
|
||||||
|
expect(seed).not.toHaveBeenCalled();
|
||||||
|
expect(migrateFromLegacyPlaces).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { GET } from '@/app/api/health/route';
|
||||||
|
|
||||||
|
describe('/api/health', () => {
|
||||||
|
it('returns a fast healthy response', async () => {
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.status).toBe('healthy');
|
||||||
|
expect(response.headers.get('cache-control')).toContain('no-store');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
|
|
||||||
|
describe('LanguageSwitcher', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches language without reloading the page', () => {
|
||||||
|
localStorage.setItem('language', 'en');
|
||||||
|
|
||||||
|
render(<LanguageSwitcher />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Switch to Spanish' });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(localStorage.getItem('language')).toBe('es');
|
||||||
|
expect(screen.getByRole('button', { name: 'Switch to English' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { GET, POST } from '@/app/api/rooms/route';
|
||||||
|
|
||||||
|
const { query, requireRole } = vi.hoisted(() => ({ query: vi.fn(), requireRole: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
db: {
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth', () => ({
|
||||||
|
requireRole,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('/api/rooms', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
query.mockReset();
|
||||||
|
requireRole.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns active rooms', async () => {
|
||||||
|
query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] });
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a room for admins', async () => {
|
||||||
|
requireRole.mockResolvedValueOnce({ id: 1, username: 'admin', role: 'admin' });
|
||||||
|
query.mockResolvedValueOnce({ rows: [{ id: 7, name: 'Garden Room', price: '120.00' }] });
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new Request('http://localhost/api/rooms', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Garden Room',
|
||||||
|
description: 'Nice view',
|
||||||
|
price: '120.00',
|
||||||
|
photos: ['a.jpg'],
|
||||||
|
sort_order: 1,
|
||||||
|
featured_photo: 'a.jpg',
|
||||||
|
}),
|
||||||
|
}) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(requireRole).toHaveBeenCalledWith('admin');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
Reference in New Issue
Block a user