Add database integration, auth API, admin/user portals, and deploy-db.sh
This commit is contained in:
@@ -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
@@ -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"
|
||||||
Vendored
+5
@@ -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.
|
||||||
Generated
+703
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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} · {r.guests} guests · {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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user