Add database integration, auth API, admin/user portals, and deploy-db.sh

This commit is contained in:
2026-06-27 16:40:58 -05:00
parent 26559d4cf8
commit cc59177a74
25 changed files with 1423 additions and 118 deletions
+47 -6
View File
@@ -21,23 +21,64 @@ next.config.js
tsconfig.json tsconfig.json
src/app/layout.tsx src/app/layout.tsx
src/app/page.tsx src/app/page.tsx
src/app/login/page.tsx
src/app/admin/page.tsx
src/app/user/page.tsx
src/app/api/...
src/components/Navbar.tsx
src/components/MeasuredText.tsx src/components/MeasuredText.tsx
src/lib/db.ts
src/lib/schema.ts
src/lib/auth.ts
deploy-db.sh
README.md README.md
``` ```
## Database
The app connects to a PostgreSQL database using the `DATABASE_URL` environment variable.
### Deploy / rotate the database secret on Kubernetes
Run the helper script. It generates a secure random password, encrypts it to `~/.lahuasca-db-pass.enc`, and applies it to the `la-huasca-db-secrets` secret in the `la-huasca` namespace.
```bash
./deploy-db.sh
```
To reveal the stored password, use the PIN `78963`:
```bash
./deploy-db.sh reveal
```
### Initialize the database schema and seed data
Once the database is reachable and `DATABASE_URL` is set, 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"
```
## Authentication ## Authentication
The app now includes simple browser-local authentication with two hardcoded accounts: The app includes JWT-based session authentication backed by the database. Demo accounts are seeded automatically:
- `admin` / `admin` Admin portal (`/admin`) - `admin` / `admin` Admin portal (`/admin`)
- `user` / `user` User portal (`/user`) - `user` / `user` User portal (`/user`)
The logged-in role is stored in `localStorage`. The **Login / Logout** control lives in the Navbar. The **Login / Logout** control lives in the Navbar.
## Admin portal ## Admin portal
Visit `/admin` after logging in as `admin` to upload images. Images are previewed in a gallery and can be deleted individually. For now, uploaded images are stored as base64 strings in the browser's `localStorage` under the key `la-huasca-admin-images` — they are not persisted on a server. Visit `/admin` after logging in as `admin` to upload images. Images are previewed in a gallery and can be deleted individually. Uploaded images are now persisted in PostgreSQL.
## User portal ## User portal
Visit `/user` after logging in as `user` to see a member portal with mock 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.
Executable
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
NAMESPACE="la-huasca"
SECRET_NAME="la-huasca-db-secrets"
PROTECTED_FILE="$HOME/.lahuasca-db-pass.enc"
PIN="78963"
generate_password() {
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 32
}
decrypt_password() {
openssl enc -aes-256-cbc -d -salt -pbkdf2 -iter 100000 -pass pass:"$PIN" -in "$PROTECTED_FILE" | tr -d '\n'
}
# Generate a secure random password if it doesn't already exist
if [ -f "$PROTECTED_FILE" ]; then
echo "Protected password file already exists at $PROTECTED_FILE"
echo "Run './deploy-db.sh reveal' to view it."
else
PASSWORD=***
if [ -z "$PASSWORD" ]; then
echo "Failed to generate password. Is openssl installed?"
exit 1
fi
echo -n "$PASSWORD" | openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -pass pass:"$PIN" -out "$PROTECTED_FILE"
echo "Generated secure password and saved it to $PROTECTED_FILE"
fi
if [ "${1:-}" = "reveal" ]; then
echo "Enter PIN to reveal the database password:"
read -s USER_PIN
if [ "$USER_PIN" != "$PIN" ]; then
echo "Incorrect PIN."
exit 1
fi
PASSWORD=***
if [ -z "$PASSWORD" ]; then
echo "Failed to decrypt password."
exit 1
fi
echo "Database password: $PASSWORD"
exit 0
fi
# Read password for deployment
PASSWORD=***
if [ -z "$PASSWORD" ]; then
echo "Failed to decrypt password. Protected file may be corrupt."
exit 1
fi
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: $SECRET_NAME
namespace: $NAMESPACE
type: Opaque
stringData:
POSTGRES_USER: lahuasca
POSTGRES_PASSWORD: "$PASSWORD"
POSTGRES_DB: lahuasca
EOF
echo "Database secret updated in namespace $NAMESPACE"
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+703
View File
@@ -0,0 +1,703 @@
{
"name": "la-huasca-ts",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "la-huasca-ts",
"version": "0.1.0",
"dependencies": {
"@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3",
"jose": "^5.6.3",
"next": "14.2.28",
"pg": "^8.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@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"
}
},
"node_modules/@chenglou/pretext": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.8.tgz",
"integrity": "sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz",
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz",
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.28",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.28",
"@next/swc-darwin-x64": "14.2.28",
"@next/swc-linux-arm64-gnu": "14.2.28",
"@next/swc-linux-arm64-musl": "14.2.28",
"@next/swc-linux-x64-gnu": "14.2.28",
"@next/swc-linux-x64-musl": "14.2.28",
"@next/swc-win32-arm64-msvc": "14.2.28",
"@next/swc-win32-ia32-msvc": "14.2.28",
"@next/swc-win32-x64-msvc": "14.2.28"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+5
View File
@@ -10,12 +10,17 @@
}, },
"dependencies": { "dependencies": {
"@chenglou/pretext": "^0.0.8", "@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3",
"jose": "^5.6.3",
"next": "14.2.28", "next": "14.2.28",
"pg": "^8.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.17.30", "@types/node": "^20.17.30",
"@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" "typescript": "^5.4.5"
+55 -36
View File
@@ -2,65 +2,84 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
const IMAGES_KEY = 'la-huasca-admin-images'; interface ImageRecord {
id: number;
filename: string;
data: string;
uploaded_at: string;
}
export default function AdminPage() { export default function AdminPage() {
const { isAdmin, hydrated } = useAuth();
const router = useRouter(); const router = useRouter();
const [images, setImages] = useState<string[]>([]); const [images, setImages] = useState<ImageRecord[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!hydrated) return; fetch('/api/admin/images', { credentials: 'same-origin' })
if (!isAdmin) { .then(async (res) => {
if (!res.ok) {
if (res.status === 401) {
router.push('/login'); router.push('/login');
return; return;
} }
const stored = localStorage.getItem(IMAGES_KEY); throw new Error('Failed to load images');
if (stored) {
try {
setImages(JSON.parse(stored));
} catch {
setImages([]);
} }
} const data = await res.json();
}, [isAdmin, hydrated, router]); setImages(data.images || []);
setCount(data.count || 0);
useEffect(() => { })
localStorage.setItem(IMAGES_KEY, JSON.stringify(images)); .catch((err) => setError(err.message))
}, [images]); .finally(() => setLoading(false));
}, [router]);
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = async () => {
const result = reader.result as string; const data = reader.result as string;
setImages((prev) => [...prev, result]); const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, data }),
credentials: 'same-origin',
});
if (!res.ok) {
setError('Upload failed');
return;
}
const result = await res.json();
setImages((prev) => [result.image, ...prev]);
setCount((prev) => prev + 1);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
e.target.value = ''; e.target.value = '';
}; };
const handleDelete = (index: number) => { const handleDelete = async (id: number) => {
setImages((prev) => prev.filter((_, i) => i !== index)); const res = await fetch(`/api/admin/images?id=${id}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (res.ok) {
setImages((prev) => prev.filter((img) => img.id !== id));
setCount((prev) => prev - 1);
}
}; };
if (!hydrated || !isAdmin) { if (loading) return <main style={{ padding: '2rem' }}><p>Loading...</p></main>;
return (
<main style={{ padding: '2rem' }}>
<p>Please log in as admin to access this page.</p>
</main>
);
}
return ( return (
<main style={{ padding: '2rem' }}> <main style={{ padding: '2rem' }}>
<h1>Admin Portal</h1> <h1>Admin Portal</h1>
<p>Total uploaded images: <strong>{images.length}</strong></p> <p>Total uploaded images: <strong>{count}</strong></p>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
<label <label
style={{ style={{
display: 'inline-block', display: 'inline-block',
@@ -91,9 +110,9 @@ export default function AdminPage() {
gap: '1rem', gap: '1rem',
}} }}
> >
{images.map((src, i) => ( {images.map((img) => (
<div <div
key={i} key={img.id}
style={{ style={{
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: '8px', borderRadius: '8px',
@@ -103,12 +122,12 @@ export default function AdminPage() {
}} }}
> >
<img <img
src={src} src={img.data}
alt={`Uploaded preview ${i + 1}`} alt={img.filename}
style={{ width: '100%', height: '160px', objectFit: 'cover' }} style={{ width: '100%', height: '160px', objectFit: 'cover' }}
/> />
<button <button
onClick={() => handleDelete(i)} onClick={() => handleDelete(img.id)}
style={{ style={{
padding: '0.5rem', padding: '0.5rem',
background: 'crimson', background: 'crimson',
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
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 });
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('List images error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { filename, data } = body;
if (!filename || !data) {
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
}
const { rows } = await db.query(
'INSERT INTO images (filename, data) VALUES ($1, $2) RETURNING id, filename, uploaded_at',
[filename, data]
);
return NextResponse.json({ image: rows[0] });
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Upload image error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 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: 'Missing id' }, { status: 400 });
}
await db.query('DELETE FROM images WHERE id = $1', [id]);
return NextResponse.json({ ok: true });
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Delete image error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateUser, createSession } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return NextResponse.json({ error: 'Missing username or password' }, { status: 400 });
}
const user = await authenticateUser(username, password);
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
await createSession(user);
return NextResponse.json({ role: user.role, username: user.username });
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { clearSession } from '@/lib/auth';
export async function POST() {
await clearSession();
return NextResponse.json({ ok: true });
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
export async function GET() {
const session = await getSession();
if (!session) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
return NextResponse.json({ authenticated: true, role: session.role, username: session.username });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { initSchema, seed, resetDatabase } from '@/lib/schema';
export async function POST(request: Request) {
try {
const { searchParams } = new URL(request.url);
const reset = searchParams.get('reset') === 'true';
if (reset) {
await resetDatabase();
return NextResponse.json({ ok: true, reset: true });
}
await initSchema();
await seed();
return NextResponse.json({ ok: true });
} catch (error) {
console.error('DB init error:', error);
return NextResponse.json({ error: 'Database initialization failed' }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
try {
const session = await requireRole('user');
const userId = session.id;
const { rows: reservations } = await db.query(
'SELECT id, date, time, guests, table_name FROM reservations WHERE user_id = $1 ORDER BY date, time',
[userId]
);
const { rows: coupons } = await db.query(
'SELECT id, code, discount FROM coupons WHERE user_id = $1 ORDER BY created_at',
[userId]
);
const { rows: offers } = await db.query(
'SELECT id, title, description FROM offers WHERE active = TRUE ORDER BY created_at'
);
const { rows: benefits } = await db.query(
'SELECT id, text FROM benefits ORDER BY id'
);
const { rows: configRows } = await db.query(
"SELECT value FROM config WHERE key = 'wifi_password'"
);
const wifiPassword = configRows[0]?.value || '';
return NextResponse.json({
reservations,
coupons,
offers,
benefits: benefits.map((b: { text: string }) => b.text),
wifiPassword,
});
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('User portal error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+8
View File
@@ -0,0 +1,8 @@
export default function CoffeePage() {
return (
<main style={{ padding: '2rem' }}>
<h1>Coffee</h1>
<p>Enjoy locally sourced coffee and fresh pastries.</p>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function CommentsPage() {
return (
<main style={{ padding: '2rem' }}>
<h1>Comments</h1>
<p>Read what guests are saying about their stay.</p>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function LandscapePage() {
return (
<main style={{ padding: '2rem' }}>
<h1>Landscape</h1>
<p>Explore the trails, rivers, and cloud forest around La Huasca.</p>
</main>
);
}
+7 -2
View File
@@ -1,6 +1,8 @@
import Navbar from '@/components/Navbar';
export const metadata = { export const metadata = {
title: 'La Huasca', title: 'La Huasca',
description: 'TypeScript + Next.js scaffold with Pretext text measurement', description: 'TypeScript + Next.js version of La Huasca',
}; };
export default function RootLayout({ export default function RootLayout({
@@ -10,7 +12,10 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
<Navbar />
{children}
</body>
</html> </html>
); );
} }
+16 -8
View File
@@ -2,24 +2,32 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { login } = useAuth();
const router = useRouter(); const router = useRouter();
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
const ok = login(username, password);
if (ok) { const res = await fetch('/api/auth/login', {
router.push(username === 'admin' ? '/admin' : '/user'); method: 'POST',
} else { headers: { 'Content-Type': 'application/json' },
setError('Invalid username or password.'); body: JSON.stringify({ username, password }),
credentials: 'same-origin',
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Invalid username or password.');
return;
} }
const data = await res.json();
router.push(data.role === 'admin' ? '/admin' : '/user');
}; };
return ( return (
+3 -3
View File
@@ -1,10 +1,10 @@
import MeasuredText from '@/components/MeasuredText'; import MeasuredText from '@/components/MeasuredText';
export default function Home() { export default function HomePage() {
return ( return (
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}> <main style={{ padding: '2rem' }}>
<h1>La Huasca</h1> <h1>La Huasca</h1>
<p>TypeScript + Next.js scaffold with Pretext text measurement.</p> <p>Welcome to the TypeScript + Next.js version of La Huasca.</p>
<MeasuredText text="Hello, Pretext!" /> <MeasuredText text="Hello, Pretext!" />
</main> </main>
); );
+8
View File
@@ -0,0 +1,8 @@
export default function RoomsPage() {
return (
<main style={{ padding: '2rem' }}>
<h1>Rooms</h1>
<p>Discover our comfortable rooms with stunning views.</p>
</main>
);
}
+54 -40
View File
@@ -1,48 +1,62 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
const reservations = [ interface Reservation {
{ id: 1, date: '2026-07-12', time: '19:00', guests: 4, table: 'Terrace 3' }, id: number;
{ id: 2, date: '2026-07-25', time: '20:30', guests: 2, table: 'Barrel 7' }, date: string;
]; time: string;
guests: number;
table_name: string;
}
const coupons = [ interface Coupon {
{ code: 'WELCOME20', discount: '20% off your next stay' }, id: number;
{ code: 'COFFEE5', discount: '$5 off any coffee tasting' }, code: string;
]; discount: string;
}
const offers = [ interface Offer {
{ title: 'Summer Wine Weekend', desc: 'Complimentary wine tasting with every two-night stay.' }, id: number;
{ title: 'Sunset Dinner', desc: 'Three-course menu on the terrace every Friday.' }, title: string;
]; description: string;
}
const benefits = [ interface PortalData {
'Priority booking for peak weekends', reservations: Reservation[];
'Late checkout on availability', coupons: Coupon[];
'Free welcome cocktail', offers: Offer[];
'Member-only events and tastings', benefits: string[];
]; wifiPassword: string;
}
export default function UserPage() { export default function UserPage() {
const { isUser, hydrated } = useAuth();
const router = useRouter(); const router = useRouter();
const [data, setData] = useState<PortalData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!hydrated) return; fetch('/api/user/portal', { credentials: 'same-origin' })
if (!isUser) { .then(async (res) => {
if (!res.ok) {
if (res.status === 401) {
router.push('/login'); router.push('/login');
return;
} }
}, [isUser, hydrated, router]); throw new Error('Failed to load portal data');
}
return res.json();
})
.then((json) => setData(json))
.catch((err) => console.error(err))
.finally(() => setLoading(false));
}, [router]);
if (!hydrated || !isUser) { if (loading) return <main style={{ padding: '2rem' }}><p>Loading...</p></main>;
return (
<main style={{ padding: '2rem' }}> if (!data) {
<p>Please log in as user to access this page.</p> return <main style={{ padding: '2rem' }}><p>Please log in as user to access this page.</p></main>;
</main>
);
} }
return ( return (
@@ -51,11 +65,11 @@ export default function UserPage() {
<section style={{ marginBottom: '2rem' }}> <section style={{ marginBottom: '2rem' }}>
<h2>Future Reservations</h2> <h2>Future Reservations</h2>
{reservations.length === 0 ? ( {data.reservations.length === 0 ? (
<p style={{ color: 'dimgray' }}>No upcoming reservations.</p> <p style={{ color: 'dimgray' }}>No upcoming reservations.</p>
) : ( ) : (
<ul style={{ padding: 0, listStyle: 'none' }}> <ul style={{ padding: 0, listStyle: 'none' }}>
{reservations.map((r) => ( {data.reservations.map((r) => (
<li <li
key={r.id} key={r.id}
style={{ style={{
@@ -65,7 +79,7 @@ export default function UserPage() {
borderRadius: '8px', borderRadius: '8px',
}} }}
> >
{r.date} at {r.time} &middot; {r.guests} guests &middot; {r.table} {r.date} at {r.time} · {r.guests} guests · {r.table_name}
</li> </li>
))} ))}
</ul> </ul>
@@ -75,9 +89,9 @@ export default function UserPage() {
<section style={{ marginBottom: '2rem' }}> <section style={{ marginBottom: '2rem' }}>
<h2>Discount Coupons</h2> <h2>Discount Coupons</h2>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
{coupons.map((c) => ( {data.coupons.map((c) => (
<div <div
key={c.code} key={c.id}
style={{ style={{
padding: '1rem', padding: '1rem',
border: '2px dashed #1a1a1a', border: '2px dashed #1a1a1a',
@@ -95,9 +109,9 @@ export default function UserPage() {
<section style={{ marginBottom: '2rem' }}> <section style={{ marginBottom: '2rem' }}>
<h2>Offers</h2> <h2>Offers</h2>
<ul> <ul>
{offers.map((o) => ( {data.offers.map((o) => (
<li key={o.title}> <li key={o.id}>
<strong>{o.title}</strong> {o.desc} <strong>{o.title}</strong> {o.description}
</li> </li>
))} ))}
</ul> </ul>
@@ -115,14 +129,14 @@ export default function UserPage() {
fontSize: '1.25rem', fontSize: '1.25rem',
}} }}
> >
LaHuascaGuest2026 {data.wifiPassword}
</p> </p>
</section> </section>
<section> <section>
<h2>Member Benefits</h2> <h2>Member Benefits</h2>
<ul> <ul>
{benefits.map((b) => ( {data.benefits.map((b) => (
<li key={b}>{b}</li> <li key={b}>{b}</li>
))} ))}
</ul> </ul>
+8 -12
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { measureText } from '@chenglou/pretext'; import { prepare, layout } from '@chenglou/pretext';
interface MeasuredTextProps { interface MeasuredTextProps {
text: string; text: string;
@@ -9,21 +9,17 @@ interface MeasuredTextProps {
export default function MeasuredText({ text }: MeasuredTextProps) { export default function MeasuredText({ text }: MeasuredTextProps) {
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number | null>(null); const [height, setHeight] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
const style = window.getComputedStyle(ref.current); const style = window.getComputedStyle(ref.current);
const measured = measureText(text, { const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily.split(',')[0].replace(/['"]/g, '')}`;
font: { const prepared = prepare(text, font);
family: style.fontFamily.split(',')[0].replace(/['"]/g, ''), const { height } = layout(prepared, ref.current.offsetWidth, parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2);
size: parseFloat(style.fontSize),
weight: style.fontWeight,
},
});
setWidth(measured.width); setHeight(height);
}, [text]); }, [text]);
return ( return (
@@ -31,8 +27,8 @@ export default function MeasuredText({ text }: MeasuredTextProps) {
<span ref={ref} style={{ fontSize: '2rem', fontWeight: 700 }}> <span ref={ref} style={{ fontSize: '2rem', fontWeight: 700 }}>
{text} {text}
</span> </span>
{width !== null && ( {height !== null && (
<p style={{ color: 'dimgray' }}>Measured width: {width.toFixed(2)}px</p> <p style={{ color: 'dimgray' }}>Measured height: {height.toFixed(2)}px</p>
)} )}
</div> </div>
); );
+13 -5
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth'; import { useEffect, useState } from 'react';
const links = [ const links = [
{ href: '/', label: 'Home' }, { href: '/', label: 'Home' },
@@ -13,11 +13,19 @@ const links = [
]; ];
export default function Navbar() { export default function Navbar() {
const { isLoggedIn, logout } = useAuth(); const [auth, setAuth] = useState<{ authenticated: boolean; role?: string } | null>(null);
const router = useRouter(); const router = useRouter();
const handleLogout = () => { useEffect(() => {
logout(); fetch('/api/auth/me', { credentials: 'same-origin' })
.then((res) => (res.ok ? res.json() : { authenticated: false }))
.then((data) => setAuth(data))
.catch(() => setAuth({ authenticated: false }));
}, []);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
setAuth({ authenticated: false });
router.push('/login'); router.push('/login');
}; };
@@ -47,7 +55,7 @@ export default function Navbar() {
{link.label} {link.label}
</Link> </Link>
))} ))}
{isLoggedIn ? ( {auth?.authenticated ? (
<button <button
onClick={handleLogout} onClick={handleLogout}
style={{ style={{
+64
View File
@@ -0,0 +1,64 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
import bcrypt from 'bcryptjs';
import { db } from './db';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const secret = new TextEncoder().encode(JWT_SECRET);
export async function hashPassword(password: string) {
return bcrypt.hashSync(password, 10);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compareSync(password, hash);
}
export async function authenticateUser(username: string, password: string) {
const { rows } = await db.query('SELECT id, username, password_hash, role FROM users WHERE username = $1', [username]);
if (rows.length === 0) return null;
const user = rows[0];
const valid = await verifyPassword(password, user.password_hash);
if (!valid) return null;
return { id: user.id, username: user.username, role: user.role };
}
export async function createSession(user: { id: number; username: string; role: string }) {
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret);
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 7 });
return token;
}
export async function getSession() {
const token = cookies().get('session')?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload as { id: number; username: string; role: string };
} catch {
return null;
}
}
export async function clearSession() {
cookies().delete('session');
}
export async function requireRole(role: 'admin' | 'user') {
const session = await getSession();
if (!session || session.role !== role) {
throw new Error('Unauthorized');
}
return session;
}
export async function requireAuth() {
const session = await getSession();
if (!session) {
throw new Error('Unauthorized');
}
return session;
}
+14
View File
@@ -0,0 +1,14 @@
import { Pool } from 'pg';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set');
}
export const db = new Pool({ connectionString });
export async function query(text: string, params?: unknown[]) {
const result = await db.query(text, params);
return result;
}
+151
View File
@@ -0,0 +1,151 @@
import { db } from './db';
import bcrypt from 'bcryptjs';
export async function initSchema() {
await db.query(`
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')),
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS images (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
data TEXT NOT NULL,
uploaded_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
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()
);
`);
await db.query(`
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()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS offers (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS benefits (
id SERIAL PRIMARY KEY,
text VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS config (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL
);
`);
}
export async function seed() {
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
if (adminRows.length === 0) {
const adminHash = bcrypt.hashSync('admin', 10);
const userHash = bcrypt.hashSync('user', 10);
await db.query(
`INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3), ($4, $5, $6)`,
['admin', adminHash, 'admin', 'user', userHash, 'user']
);
}
const { rows: offerRows } = await db.query(`SELECT id FROM offers`);
if (offerRows.length === 0) {
await db.query(
`INSERT INTO offers (title, description) VALUES
($1, $2), ($3, $4)`,
[
'Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.',
'Sunset Dinner', 'Three-course menu on the terrace every Friday.',
]
);
}
const { rows: benefitRows } = await db.query(`SELECT id FROM benefits`);
if (benefitRows.length === 0) {
await db.query(
`INSERT INTO benefits (text) VALUES
($1), ($2), ($3), ($4)`,
[
'Priority booking for peak weekends',
'Late checkout on availability',
'Free welcome cocktail',
'Member-only events and tastings',
]
);
}
const { rows: configRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
if (configRows.length === 0) {
await db.query(
`INSERT INTO config (key, value) VALUES ($1, $2)`,
['wifi_password', 'LaHuascaGuest2026']
);
}
const { rows: userResRows } = await db.query(`SELECT id FROM reservations WHERE user_id = (SELECT id FROM users WHERE username = 'user')`);
if (userResRows.length === 0) {
await db.query(
`INSERT INTO reservations (user_id, date, time, guests, table_name)
SELECT id, $2, $3, $4, $5 FROM users WHERE username = $1`,
['user', '2026-07-12', '19:00', 4, 'Terrace 3']
);
await db.query(
`INSERT INTO reservations (user_id, date, time, guests, table_name)
SELECT id, $2, $3, $4, $5 FROM users WHERE username = $1`,
['user', '2026-07-25', '20:30', 2, 'Barrel 7']
);
}
const { rows: userCouponRows } = await db.query(`SELECT id FROM coupons WHERE user_id = (SELECT id FROM users WHERE username = 'user')`);
if (userCouponRows.length === 0) {
await db.query(
`INSERT INTO coupons (user_id, code, discount)
SELECT id, $2, $3 FROM users WHERE username = $1`,
['user', 'WELCOME20', '20% off your next stay']
);
await db.query(
`INSERT INTO coupons (user_id, code, discount)
SELECT id, $2, $3 FROM users WHERE username = $1`,
['user', 'COFFEE5', '$5 off any coffee tasting']
);
}
}
export async function resetDatabase() {
await db.query('DROP TABLE IF EXISTS config, benefits, offers, coupons, reservations, images, users CASCADE');
await initSchema();
await seed();
}